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_API_KEY=your_deepseek_key
|
||||
|
||||
# Debate channel ID для мульти-AI диспутов
|
||||
AI_DEBATE_CHANNEL_ID=your_debate_channel_id
|
||||
|
||||
|
||||
# Lavalink
|
||||
LAVALINK_HOST=127.0.0.1
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ RUN addgroup -S bot && adduser -S bot -G bot
|
|||
WORKDIR /app
|
||||
COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar
|
||||
COPY data/prompts /app/prompts
|
||||
COPY data/knowledge /app/knowledge
|
||||
RUN mkdir -p /app/data /app/logs && chown -R bot:bot /app
|
||||
USER bot
|
||||
VOLUME /app/data
|
||||
|
|
|
|||
|
|
@ -42,12 +42,15 @@ services:
|
|||
AI_MSG_LENGTH: ${AI_MSG_LENGTH:-300}
|
||||
AI_CONTEXT_CHARS: ${AI_CONTEXT_CHARS:-3000}
|
||||
AI_DOSSIER_MSGS: ${AI_DOSSIER_MSGS:-30}
|
||||
AI_DEBATE_CHANNEL_ID: ${AI_DEBATE_CHANNEL_ID}
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "8"
|
||||
volumes:
|
||||
- bot-data:/app/data
|
||||
- ./data/prompts:/app/prompts:ro
|
||||
- ./data/knowledge:/app/knowledge:ro
|
||||
|
||||
volumes:
|
||||
bot-data:
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@ public final class CommandCategories {
|
|||
/** Команды с GIF (nekos.life): hug, slap, poke и т.п. */
|
||||
public static final String GIF_INTERACTIVE = "GIF-интерактив";
|
||||
|
||||
/** Команды для поиска изображений (e621, r34) — только в NSFW-каналах. */
|
||||
public static final String NSFW_IMAGES = "NSFW";
|
||||
|
||||
private CommandCategories() {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public class BotConfig {
|
|||
private final int aiContextChars;
|
||||
private final int aiDossierMsgs;
|
||||
private final int aiDossierUpdateHours;
|
||||
private final String aiDebateChannelId;
|
||||
private final LavalinkConfig lavalinkConfig;
|
||||
|
||||
public BotConfig(
|
||||
|
|
@ -34,6 +35,7 @@ public class BotConfig {
|
|||
int aiContextChars,
|
||||
int aiDossierMsgs,
|
||||
int aiDossierUpdateHours,
|
||||
String aiDebateChannelId,
|
||||
LavalinkConfig lavalinkConfig) {
|
||||
this.mainToken = mainToken;
|
||||
this.musicToken = musicToken;
|
||||
|
|
@ -50,6 +52,7 @@ public class BotConfig {
|
|||
this.aiContextChars = aiContextChars;
|
||||
this.aiDossierMsgs = aiDossierMsgs;
|
||||
this.aiDossierUpdateHours = aiDossierUpdateHours;
|
||||
this.aiDebateChannelId = aiDebateChannelId;
|
||||
this.lavalinkConfig = lavalinkConfig;
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +120,10 @@ public class BotConfig {
|
|||
return aiDossierUpdateHours;
|
||||
}
|
||||
|
||||
public String getAiDebateChannelId() {
|
||||
return aiDebateChannelId;
|
||||
}
|
||||
|
||||
public LavalinkConfig getLavalinkConfig() {
|
||||
return lavalinkConfig;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ public final class ConfigLoader {
|
|||
int aiContextChars = Integer.parseInt(getEnv(dotenv, "AI_CONTEXT_CHARS", "3000"));
|
||||
int aiDossierMsgs = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_MSGS", "30"));
|
||||
int aiDossierUpdateHours = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_UPDATE_HOURS", "24"));
|
||||
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
|
||||
|
||||
LavalinkConfig lavalinkConfig = getLavalinkConfig();
|
||||
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
|
||||
|
|
@ -100,6 +101,7 @@ public final class ConfigLoader {
|
|||
aiContextChars,
|
||||
aiDossierMsgs,
|
||||
aiDossierUpdateHours,
|
||||
aiDebateChannelId,
|
||||
lavalinkConfig);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import net.dv8tion.jda.api.JDA;
|
|||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.OnlineStatus;
|
||||
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.utils.cache.CacheFlag;
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -68,18 +67,6 @@ public class BotInitializer {
|
|||
LoggerDecorator.startup(logger, "Инициализация main бота...");
|
||||
registrar.registerAll();
|
||||
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);
|
||||
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
|
||||
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.music.MusicSlashCommands;
|
||||
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.VoiceModalHandler;
|
||||
import creator.loki.feature.voice.listener.VoiceChannelListener;
|
||||
|
|
@ -43,7 +44,8 @@ public class ListenerAggregator {
|
|||
MusicSlashCommands musicSlashCommands,
|
||||
FishMessageListener fishMessageListener,
|
||||
AndrertListener andrertListener,
|
||||
ClearSlashCommands clearSlashCommands) {
|
||||
ClearSlashCommands clearSlashCommands,
|
||||
HelperListener helperListener) {
|
||||
this.listeners =
|
||||
new Object[] {
|
||||
readyListener,
|
||||
|
|
@ -63,7 +65,8 @@ public class ListenerAggregator {
|
|||
musicSlashCommands,
|
||||
fishMessageListener,
|
||||
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.EconomyModule;
|
||||
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.commands.LevelCommandRegistrar;
|
||||
import creator.loki.feature.metrics.HealthHttpServer;
|
||||
|
|
@ -64,6 +66,7 @@ public class BotModule extends AbstractModule {
|
|||
install(new EconomyModule());
|
||||
install(new RadioModule());
|
||||
install(new RoastModule());
|
||||
install(new ImageModule());
|
||||
|
||||
Multibinder<CommandRegistrar> commandBinder =
|
||||
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
||||
|
|
@ -74,6 +77,7 @@ public class BotModule extends AbstractModule {
|
|||
commandBinder.addBinding().to(MusicCommandRegistrar.class);
|
||||
commandBinder.addBinding().to(EconomyCommandRegistrar.class);
|
||||
commandBinder.addBinding().to(RadioCommandRegistrar.class);
|
||||
commandBinder.addBinding().to(ImageCommandRegistrar.class);
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ public final class HelpCategories {
|
|||
Map.entry("Утилиты", "🔧"),
|
||||
Map.entry("Система", "💠"),
|
||||
Map.entry("Настройка", "⚙️"),
|
||||
Map.entry("Супер-Админ", "🔐"));
|
||||
Map.entry("Супер-Админ", "🔐"),
|
||||
Map.entry(CommandCategories.NSFW_IMAGES, "🔞"));
|
||||
|
||||
COLORS =
|
||||
Map.ofEntries(
|
||||
|
|
@ -35,7 +36,8 @@ public final class HelpCategories {
|
|||
Map.entry("Утилиты", new Color(52, 152, 219)),
|
||||
Map.entry("Система", new Color(52, 73, 94)),
|
||||
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 =
|
||||
Map.ofEntries(
|
||||
|
|
@ -49,7 +51,9 @@ public final class HelpCategories {
|
|||
Map.entry("Утилиты", "Полезные инструменты"),
|
||||
Map.entry("Система", "Метрики и служебная информация для админов"),
|
||||
Map.entry("Настройка", "Настройка систем бота (только админы)"),
|
||||
Map.entry("Супер-Админ", "Сервисные команды владельца бота"));
|
||||
Map.entry("Супер-Админ", "Сервисные команды владельца бота"),
|
||||
Map.entry(
|
||||
CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"));
|
||||
}
|
||||
|
||||
private HelpCategories() {}
|
||||
|
|
@ -66,10 +70,17 @@ public final class HelpCategories {
|
|||
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) {
|
||||
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.components.actionrow.ActionRow;
|
||||
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.hooks.ListenerAdapter;
|
||||
|
||||
|
|
@ -76,12 +77,13 @@ public class HelpMenuHandler extends ListenerAdapter {
|
|||
boolean isAdmin =
|
||||
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
|
||||
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
|
||||
boolean isNsfwChannel = isNsfw(event.getChannel().getType());
|
||||
|
||||
commandsByCategory.keySet().stream()
|
||||
.sorted()
|
||||
.forEach(
|
||||
cat -> {
|
||||
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper)) {
|
||||
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper, isNsfwChannel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -150,4 +152,11 @@ public class HelpMenuHandler extends ListenerAdapter {
|
|||
|
||||
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.selections.StringSelectMenu;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||
|
||||
public class HelpCommand implements Command {
|
||||
private final CommandRegistry registry;
|
||||
|
|
@ -48,9 +49,11 @@ public class HelpCommand implements Command {
|
|||
boolean isAdmin =
|
||||
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
|
||||
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 =
|
||||
|
|
@ -60,7 +63,7 @@ public class HelpCommand implements Command {
|
|||
.sorted()
|
||||
.forEach(
|
||||
category -> {
|
||||
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
|
||||
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -82,14 +85,22 @@ public class HelpCommand implements Command {
|
|||
.queue();
|
||||
}
|
||||
|
||||
private static boolean isNsfw(ChannelType type) {
|
||||
return type == ChannelType.TEXT
|
||||
|| type == ChannelType.NEWS
|
||||
|| type == ChannelType.FORUM
|
||||
|| type == ChannelType.MEDIA;
|
||||
}
|
||||
|
||||
private EmbedFactory createMainPage(
|
||||
CommandContext context,
|
||||
Map<String, List<Command>> commandsByCategory,
|
||||
boolean isAdmin,
|
||||
boolean isSuper) {
|
||||
boolean isSuper,
|
||||
boolean isNsfwChannel) {
|
||||
int totalCommands =
|
||||
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())
|
||||
.sum();
|
||||
|
||||
|
|
@ -98,7 +109,8 @@ public class HelpCommand implements Command {
|
|||
.sorted()
|
||||
.forEach(
|
||||
category -> {
|
||||
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
|
||||
if (!HelpCategories.isCategoryVisible(
|
||||
category, isAdmin, isSuper, isNsfwChannel)) {
|
||||
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.entities.Message;
|
||||
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.hooks.ListenerAdapter;
|
||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
||||
import net.dv8tion.jda.api.interactions.commands.build.Commands;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@Singleton
|
||||
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
|
||||
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
|
||||
if (!event.getName().equals("clear")) return;
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ public class MusicSlashCommands extends ListenerAdapter {
|
|||
Commands.slash("equalizer", "Эквалайзер")
|
||||
.addOption(OptionType.INTEGER, "band", "0-14", 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ package creator.loki.feature.roast;
|
|||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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 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.entities.channel.concrete.TextChannel;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||
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 int CHANNEL_CONTEXT = 15;
|
||||
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 String allowedChannelId;
|
||||
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
|
||||
public AndrertListener(RoastService roastService, BotConfig config) {
|
||||
|
|
@ -38,14 +46,23 @@ public class AndrertListener extends ListenerAdapter {
|
|||
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
|
||||
if (event.getAuthor().isBot()) return;
|
||||
|
||||
String userId = event.getAuthor().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 =
|
||||
allowedChannelId != null
|
||||
&& !allowedChannelId.isBlank()
|
||||
&& channelId.equals(allowedChannelId);
|
||||
|
||||
if (!isAllowedChannel) {
|
||||
String userId = event.getAuthor().getId();
|
||||
Long expiresAt = trackedUsers.get(userId);
|
||||
if (expiresAt == null) return;
|
||||
long now = System.currentTimeMillis();
|
||||
|
|
@ -58,7 +75,7 @@ public class AndrertListener extends ListenerAdapter {
|
|||
trackedUsers.put(userId, now + TRACK_TTL);
|
||||
}
|
||||
|
||||
respond(event, event.getAuthor().getId());
|
||||
respond(event, 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());
|
||||
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
|
||||
String authorId = msg.getAuthor().getId();
|
||||
boolean isCurrent = (idx == 0);
|
||||
|
||||
String label = isCurrent ? "[now]" : "[history]";
|
||||
String label = (idx == 0) ? "[now]" : "[history]";
|
||||
StringBuilder line = new StringBuilder();
|
||||
line.append(label)
|
||||
.append(" ")
|
||||
|
|
@ -115,9 +131,98 @@ public class AndrertListener extends ListenerAdapter {
|
|||
if (response == null || response.isBlank()) return;
|
||||
|
||||
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) {
|
||||
if (text.length() <= 2000) {
|
||||
event.getMessage().reply(text).queue();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.slf4j.LoggerFactory;
|
|||
@Singleton
|
||||
public class ContentFetcher {
|
||||
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_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.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.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
|
@ -28,7 +26,6 @@ public class DeepSeekClient {
|
|||
|
||||
private final OkHttpClient httpClient;
|
||||
private final String apiKey;
|
||||
private final Cache<String, String> responseCache;
|
||||
|
||||
@Inject
|
||||
public DeepSeekClient(BotConfig config) {
|
||||
|
|
@ -39,8 +36,6 @@ public class DeepSeekClient {
|
|||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
this.responseCache =
|
||||
Caffeine.newBuilder().maximumSize(200).expireAfterWrite(1, TimeUnit.HOURS).build();
|
||||
}
|
||||
|
||||
public String callModel(String systemPrompt, String userContent) {
|
||||
|
|
@ -50,10 +45,6 @@ public class DeepSeekClient {
|
|||
public String callModel(String systemPrompt, String userContent, double temperature) {
|
||||
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();
|
||||
body.addProperty("model", MODEL);
|
||||
|
||||
|
|
@ -98,9 +89,6 @@ public class DeepSeekClient {
|
|||
} else if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) {
|
||||
content = msg.get("reasoning").getAsString().strip();
|
||||
}
|
||||
if (content != null && !content.isBlank()) {
|
||||
responseCache.put(cacheKey, content);
|
||||
}
|
||||
return content;
|
||||
} catch (IOException 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(DossierManager.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(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.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.regex.Pattern;
|
||||
|
|
@ -12,6 +14,7 @@ import java.util.regex.Pattern;
|
|||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import creator.loki.config.BotConfig;
|
||||
import creator.loki.feature.metrics.MetricsCollector;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
|
|
@ -22,12 +25,17 @@ public class RoastService {
|
|||
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 final BotConfig config;
|
||||
private final DeepSeekClient deepSeekClient;
|
||||
private final DossierManager dossierManager;
|
||||
private final ContentFetcher contentFetcher;
|
||||
private final WikipediaService wikipediaService;
|
||||
private final KnowledgeService knowledgeService;
|
||||
private final WebSearchService webSearchService;
|
||||
private final MetricsCollector metricsCollector;
|
||||
private final String systemPrompt;
|
||||
private final String dossierGeneratePrompt;
|
||||
|
||||
|
|
@ -37,16 +45,31 @@ public class RoastService {
|
|||
DeepSeekClient deepSeekClient,
|
||||
DossierManager dossierManager,
|
||||
ContentFetcher contentFetcher,
|
||||
WebSearchService webSearchService) {
|
||||
WikipediaService wikipediaService,
|
||||
KnowledgeService knowledgeService,
|
||||
WebSearchService webSearchService,
|
||||
MetricsCollector metricsCollector) {
|
||||
this.config = config;
|
||||
this.deepSeekClient = deepSeekClient;
|
||||
this.dossierManager = dossierManager;
|
||||
this.contentFetcher = contentFetcher;
|
||||
this.wikipediaService = wikipediaService;
|
||||
this.knowledgeService = knowledgeService;
|
||||
this.webSearchService = webSearchService;
|
||||
this.metricsCollector = metricsCollector;
|
||||
this.systemPrompt = loadPrompt("system.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) {
|
||||
try {
|
||||
return Files.readString(PROMPTS_DIR.resolve(filename), StandardCharsets.UTF_8).strip();
|
||||
|
|
@ -123,9 +146,57 @@ public class RoastService {
|
|||
if (text.length() > config.getAiMsgLength()) {
|
||||
text = text.substring(0, config.getAiMsgLength()) + "...";
|
||||
}
|
||||
String parsed = contentFetcher.fetchUrlContent(text);
|
||||
if (parsed != null) {
|
||||
text += "\n[содержимое по ссылке]:\n" + parsed;
|
||||
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
|
||||
if (userContent.length() > config.getAiContextChars()) break;
|
||||
}
|
||||
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");
|
||||
if (userContent.length() > config.getAiContextChars()) break;
|
||||
|
|
@ -135,9 +206,23 @@ public class RoastService {
|
|||
text = text.substring(0, config.getAiContextChars());
|
||||
}
|
||||
|
||||
String webContext = webSearchService.search(text);
|
||||
if (webContext != null) {
|
||||
text += "\n\n[поиск в интернете]:\n" + webContext;
|
||||
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;
|
||||
}
|
||||
|
||||
String web = webSearchService.search(text);
|
||||
if (web != null) {
|
||||
text += "\n\n[web]:\n" + web;
|
||||
}
|
||||
|
||||
return deepSeekClient.callModel(systemPrompt, text, 0.9);
|
||||
|
|
|
|||
|
|
@ -1,90 +1,147 @@
|
|||
package creator.loki.feature.roast;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class WebSearchService {
|
||||
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;
|
||||
|
||||
public WebSearchService() {
|
||||
this.httpClient =
|
||||
new OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build();
|
||||
this.httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(12, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
public String search(String text) {
|
||||
if (text == null || text.isBlank()) return null;
|
||||
String query = text.length() > 200 ? text.substring(0, 200) : text;
|
||||
public String search(String query) {
|
||||
if (query == null || query.isBlank()) return null;
|
||||
try {
|
||||
String url =
|
||||
DDG_URL
|
||||
+ "?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();
|
||||
if (json.has("AbstractText") && !json.get("AbstractText").isJsonNull()) {
|
||||
String abs = json.get("AbstractText").getAsString().strip();
|
||||
if (!abs.isBlank()) sb.append(abs).append("\n");
|
||||
}
|
||||
JsonArray topics = json.getAsJsonArray("RelatedTopics");
|
||||
int count = 0;
|
||||
if (topics != null) {
|
||||
for (var el : topics) {
|
||||
if (count >= 5) break;
|
||||
if (el.isJsonObject()) {
|
||||
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 (t.has("Topics") && !t.get("Topics").isJsonNull()) {
|
||||
for (var sub : t.getAsJsonArray("Topics")) {
|
||||
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;
|
||||
List<SearchResult> results = searchDdg(query);
|
||||
if (results.isEmpty()) return null;
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < Math.min(results.size(), 3); i++) {
|
||||
SearchResult r = results.get(i);
|
||||
sb.append(i + 1).append(". ").append(r.title).append("\n");
|
||||
sb.append(r.snippet).append("\n");
|
||||
sb.append(r.url).append("\n\n");
|
||||
}
|
||||
|
||||
SearchResult top = results.get(0);
|
||||
String pageContent = fetchPageText(top.url);
|
||||
if (pageContent != null) {
|
||||
sb.append("--- страница ").append(top.title).append(" ---\n");
|
||||
sb.append(pageContent).append("\n");
|
||||
}
|
||||
|
||||
if (sb.length() > MAX_RESULT_CHARS) {
|
||||
sb.setLength(MAX_RESULT_CHARS);
|
||||
sb.append("...");
|
||||
}
|
||||
logger.info("Web search ok q={} results={} chars={}", query, results.size(), sb.length());
|
||||
return sb.toString().strip();
|
||||
} catch (Exception e) {
|
||||
logger.warn("Web search failed", e);
|
||||
logger.warn("Web search failed for: {}", query, e);
|
||||
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,
|
||||
30,
|
||||
24,
|
||||
null,
|
||||
lavalink);
|
||||
assertEquals("token123", config.getMainToken());
|
||||
assertEquals("token123", config.getToken());
|
||||
|
|
@ -44,7 +45,7 @@ class BotConfigTest {
|
|||
void handlesNullValues() {
|
||||
BotConfig config =
|
||||
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.getMusicToken());
|
||||
assertNull(config.getRadioToken());
|
||||
|
|
@ -61,7 +62,7 @@ class BotConfigTest {
|
|||
LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false);
|
||||
BotConfig config =
|
||||
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.getMusicToken());
|
||||
assertEquals("", config.getRadioToken());
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class LavalinkManagerTest {
|
|||
3000,
|
||||
30,
|
||||
24,
|
||||
null,
|
||||
lavalinkConfig);
|
||||
|
||||
var manager = new LavalinkManager(config, queueRepository);
|
||||
|
|
@ -66,6 +67,7 @@ class LavalinkManagerTest {
|
|||
3000,
|
||||
30,
|
||||
24,
|
||||
null,
|
||||
lavalinkConfig);
|
||||
var manager = new LavalinkManager(config, queueRepository);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue