From 0d84a606d4970c16831d021f26b57443bef84e7f Mon Sep 17 00:00:00 2001 From: marcos Date: Wed, 5 Aug 2026 13:41:28 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20/ia=20=E2=80=94=20chat=20question-and-a?= =?UTF-8?q?nswer=20backed=20by=20MiniMax?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `ia` module: `/ia ` sends the question to an OpenAI-compatible chat-completions endpoint (MiniMax by default) and posts the reply to chat. Access is gated by `canalhandia.ia`, declared `default: op` so the operator has it out of the box and LuckPerms can grant it to anyone else. The model can only ever produce chat text: - the reply goes to sendMessage and nowhere else — it is never passed to the command dispatcher; - no `tools`/`tool_choice` are sent, so there is nothing for the model to call; - the system prompt states it has no server, shell or command access; - replies are sanitised — colour codes stripped so they cannot forge server messages, newlines folded so one answer is one chat entry, and leading slashes removed so nothing reads as a command to run. The API key is deliberately not a config value, since config.yml is committed. It is read from MINIMAX_API_KEY or from plugins/Canalhandia/minimax.key, which is now gitignored. Cost is bounded by a per-player cooldown and a server-wide daily cap, both visible in /canalhandia status. The HTTP call runs off the main thread; only the delivery hops back onto it. --- .gitignore | 3 + .../java/dev/marcospaulo/canalhandia/Ai.java | 290 ++++++++++++++++++ .../marcospaulo/canalhandia/Canalhandia.java | 8 +- .../canalhandia/CanalhandiaCommand.java | 37 +++ .../dev/marcospaulo/canalhandia/Module.java | 3 +- .../dev/marcospaulo/canalhandia/Settings.java | 82 +++++ src/main/resources/config.yml | 53 ++++ src/main/resources/plugin.yml | 7 + 8 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Ai.java diff --git a/.gitignore b/.gitignore index 2f7896d..e904922 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ target/ + +# The MiniMax API key lives beside the plugin on the server, never in git. +minimax.key diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java new file mode 100644 index 0000000..6833619 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -0,0 +1,290 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Question-and-answer in chat, backed by MiniMax (or any OpenAI-compatible + * endpoint). + * + *

The model can only ever produce chat text. The reply is passed to + * {@code sendMessage} and nowhere else — it is never handed to the command + * dispatcher, never written to disk, and no tool/function definitions are sent + * in the request, so there is nothing for the model to call. A player asking it + * to "run /op me" gets a string back, not an executed command. As a second + * layer, {@link #sanitise} strips leading slashes so a reply cannot even be + * mistaken for a command someone should paste. + * + *

The API key never lives in config.yml, because config.yml is committed to + * git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from + * {@code plugins/Canalhandia/minimax.key}, which is gitignored. + */ +final class Ai { + + private static final String KEY_FILE = "minimax.key"; + private static final String KEY_ENV = "MINIMAX_API_KEY"; + + private final Canalhandia plugin; + private final HttpClient http; + /** Per-player cooldown, so one person cannot spend the whole budget. */ + private final Map lastAsk = new HashMap<>(); + /** In-flight guard: one question per player at a time. */ + private final Map pending = new HashMap<>(); + + private LocalDate day = LocalDate.now(); + private int askedToday; + + Ai(Canalhandia plugin) { + this.plugin = plugin; + this.http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + /** True if a key is configured. Without one the module stays quiet. */ + boolean configured() { + return apiKey() != null; + } + + private String apiKey() { + String fromEnv = System.getenv(KEY_ENV); + if (fromEnv != null && !fromEnv.isBlank()) { + return fromEnv.trim(); + } + Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE); + try { + if (Files.isReadable(file)) { + String key = Files.readString(file, StandardCharsets.UTF_8).trim(); + return key.isEmpty() ? null : key; + } + } catch (IOException e) { + plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage()); + } + return null; + } + + /** + * Asks the model on behalf of a player and delivers the answer to chat. + * + *

Returns immediately; the HTTP call runs off the main thread and the + * reply is posted back on it. + */ + void ask(Player asker, String question) { + Settings settings = plugin.settings(); + + String key = apiKey(); + if (key == null) { + Msg.error(asker, "A IA não está configurada. Falta a chave em plugins/Canalhandia/" + + KEY_FILE + " ou na variável " + KEY_ENV + "."); + return; + } + question = question.trim(); + if (question.length() > settings.aiMaxQuestion()) { + Msg.error(asker, "Pergunta longa demais (máx. " + settings.aiMaxQuestion() + " caracteres)."); + return; + } + if (Boolean.TRUE.equals(pending.get(asker.getUniqueId()))) { + Msg.error(asker, "Sua pergunta anterior ainda está sendo respondida."); + return; + } + if (!withinDailyLimit(settings)) { + Msg.error(asker, "O limite diário de perguntas do servidor acabou. Volta amanhã."); + return; + } + long wait = cooldownRemaining(asker, settings); + if (wait > 0) { + Msg.error(asker, "Espere " + wait + "s antes de perguntar de novo."); + return; + } + + lastAsk.put(asker.getUniqueId(), System.currentTimeMillis()); + pending.put(asker.getUniqueId(), true); + askedToday++; + + if (settings.aiPublic()) { + Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + .append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text(question, NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false))); + } + Msg.ok(asker, "Pensando..."); + + String prompt = question; + UUID id = asker.getUniqueId(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + String answer; + try { + answer = call(key, prompt, settings); + } catch (Exception e) { + plugin.getLogger().warning("Falha na chamada à IA: " + e); + answer = null; + } + String finalAnswer = answer; + Bukkit.getScheduler().runTask(plugin, () -> { + pending.remove(id); + deliver(id, finalAnswer, settings); + }); + }); + } + + private void deliver(UUID askerId, String answer, Settings settings) { + Player asker = Bukkit.getPlayer(askerId); + if (answer == null || answer.isBlank()) { + if (asker != null) { + Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes."); + } + return; + } + Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + .append(Component.text(sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)); + if (settings.aiPublic()) { + Bukkit.broadcast(message); + } else if (asker != null) { + asker.sendMessage(message); + } + } + + // --- HTTP --------------------------------------------------------------- + + private String call(String key, String question, Settings settings) throws Exception { + JsonArray messages = new JsonArray(); + messages.add(message("system", settings.aiInstructions())); + messages.add(message("user", question)); + + JsonObject body = new JsonObject(); + body.addProperty("model", settings.aiModel()); + body.add("messages", messages); + body.addProperty("max_tokens", settings.aiMaxTokens()); + body.addProperty("temperature", settings.aiTemperature()); + // No "tools" and no "tool_choice": the model is given nothing it could call. + + HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl())) + .timeout(Duration.ofSeconds(settings.aiTimeoutSeconds())) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + key) + .POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8)) + .build(); + + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": " + + trim(response.body())); + return null; + } + return extract(response.body()); + } + + private static JsonObject message(String role, String content) { + JsonObject object = new JsonObject(); + object.addProperty("role", role); + object.addProperty("content", content); + return object; + } + + /** + * Pulls the reply text out of an OpenAI-shaped response. + * + *

MiniMax returns HTTP 200 even for application-level failures, putting + * the real outcome in {@code base_resp.status_code}, so that is checked too. + */ + private String extract(String json) { + JsonObject root = JsonParser.parseString(json).getAsJsonObject(); + if (root.has("base_resp")) { + JsonObject base = root.getAsJsonObject("base_resp"); + int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0; + if (status != 0) { + plugin.getLogger().warning("IA recusou: base_resp " + base); + return null; + } + } + if (!root.has("choices")) { + return null; + } + JsonArray choices = root.getAsJsonArray("choices"); + if (choices.isEmpty()) { + return null; + } + JsonObject first = choices.get(0).getAsJsonObject(); + if (!first.has("message")) { + return null; + } + JsonObject message = first.getAsJsonObject("message"); + // Reasoning models also return "reasoning_content"; only "content" is shown. + return message.has("content") && !message.get("content").isJsonNull() + ? message.get("content").getAsString() + : null; + } + + // --- limits and cleanup ------------------------------------------------- + + private boolean withinDailyLimit(Settings settings) { + LocalDate today = LocalDate.now(); + if (!today.equals(day)) { + day = today; + askedToday = 0; + } + return askedToday < settings.aiDailyLimit(); + } + + private long cooldownRemaining(Player asker, Settings settings) { + Long last = lastAsk.get(asker.getUniqueId()); + if (last == null || asker.hasPermission("canalhandia.admin")) { + return 0; + } + long elapsed = System.currentTimeMillis() - last; + long window = settings.aiCooldownSeconds() * 1000L; + return elapsed >= window ? 0 : (window - elapsed) / 1000 + 1; + } + + int askedToday() { + withinDailyLimit(plugin.settings()); + return askedToday; + } + + /** + * Makes a model reply safe to print in chat. + * + *

Strips colour codes so the reply cannot forge server messages, folds + * newlines so one answer stays one chat entry, and removes leading slashes + * so nothing that comes back reads as a command to run. + */ + static String sanitise(String raw, int max) { + String text = raw.replace('§', ' ') + .replaceAll("[\\r\\n]+", " ") + .replaceAll("\\s{2,}", " ") + .trim(); + while (text.startsWith("/")) { + text = text.substring(1).trim(); + } + if (text.length() > max) { + text = text.substring(0, max).trim() + "…"; + } + return text; + } + + private static String trim(String text) { + return text.length() > 300 ? text.substring(0, 300) + "…" : text; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index a75d620..ff6e985 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -44,6 +44,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private Settings settings; private OfflineStats offlineStats; private Milestones milestones; + private Ai ai; private NamespacedKey optOutKey; private BukkitTask timerTask; private BukkitTask milestoneTask; @@ -60,11 +61,12 @@ public final class Canalhandia extends JavaPlugin implements Listener { settings = new Settings(this); offlineStats = new OfflineStats(this); milestones = new Milestones(this); + ai = new Ai(this); optOutKey = new NamespacedKey(this, "opt_out"); CanalhandiaCommand root = new CanalhandiaCommand(this); for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", - "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f")) { + "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia")) { register(name, root); } @@ -113,6 +115,10 @@ public final class Canalhandia extends JavaPlugin implements Listener { return offlineStats; } + Ai ai() { + return ai; + } + // --- scheduling --------------------------------------------------------- /** Starts, stops or restarts the repeating curiosity task to match the mode. */ diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 54b2e4f..dbe5b85 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -46,6 +46,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "palpite" -> guessLatest(sender, args); case "votar" -> voteLatest(sender, args); case "reacoes" -> whoReacted(sender); + case "ia" -> ia(sender, args); default -> { String reaction = plugin.settings().reactionForCommand(command.getName()); if (reaction != null) { @@ -654,6 +655,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } } Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled)); + Msg.line(sender, "ia", plugin.ai().configured() + ? settings.aiModel() + " · " + plugin.ai().askedToday() + "/" + + settings.aiDailyLimit() + " hoje · cooldown " + + settings.aiCooldownSeconds() + "s · " + + (settings.aiPublic() ? "resposta pública" : "resposta privada") + : "sem chave configurada"); } private String enabledModules() { @@ -678,6 +685,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { commands.put("/enquete P | A | B", "abre uma enquete com opções clicáveis"); commands.put("/enquete encerrar", "encerra a enquete aberta"); commands.put("/ranking [categoria]", "mostra os placares do servidor"); + commands.put("/ia ", "pergunta para a IA (precisa de permissão)"); commands.put("/canalhandia status", "mostra toda a configuração"); commands.put("/canalhandia modulos", "lista os módulos e seu estado"); commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock"); @@ -737,6 +745,35 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } } + // --- /ia ---------------------------------------------------------------- + + /** + * Asks the model a question. + * + *

Gated by {@code canalhandia.ia}, which defaults to op — so the operator + * has it out of the box and LuckPerms can hand it to anyone else with + * {@code lp user permission set canalhandia.ia true}. + */ + private boolean ia(CommandSender sender, String[] args) { + if (!sender.hasPermission("canalhandia.ia")) { + return denied(sender); + } + if (!plugin.settings().moduleEnabled(Module.IA)) { + Msg.error(sender, "O módulo de IA está desligado."); + return true; + } + if (!(sender instanceof Player player)) { + Msg.error(sender, "Só jogadores podem usar /ia."); + return true; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /ia "); + return true; + } + plugin.ai().ask(player, String.join(" ", args)); + return true; + } + private boolean admin(CommandSender sender) { if (sender.hasPermission(ADMIN)) { return true; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java index 4246d51..c4144a0 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Module.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -8,7 +8,8 @@ enum Module { LUTO("luto", "Botão [F] nas mortes"), ENQUETE("enquete", "Enquetes"), RANKING("ranking", "Rankings"), - MARCOS("marcos", "Marcos e conquistas"); + MARCOS("marcos", "Marcos e conquistas"), + IA("ia", "Perguntas para a IA"); private final String key; private final String label; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 070e89d..d08e1fa 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -210,6 +210,88 @@ final class Settings { set("ranking-tamanho", Math.max(3, size)); } + // --- IA ----------------------------------------------------------------- + + /** + * Endpoint of an OpenAI-compatible chat-completions API. Defaults to + * MiniMax; a different provider only needs this URL and the model changed. + */ + String aiUrl() { + return plugin.getConfig().getString("ia.url", + "https://api.minimax.io/v1/text/chatcompletion_v2"); + } + + String aiModel() { + return plugin.getConfig().getString("ia.modelo", "MiniMax-M2"); + } + + void aiModel(String model) { + set("ia.modelo", model); + } + + /** + * The system prompt. Keeps answers short enough for chat and in pt-BR, and + * tells the model it has no way to act on the server — it cannot run + * commands even if asked, so it should say so rather than pretend. + */ + String aiInstructions() { + return plugin.getConfig().getString("ia.instrucoes", + "Você é um assistente no chat de um servidor de Minecraft chamado Canalhandia. " + + "Responda sempre em português do Brasil, de forma curta e direta: " + + "no máximo 3 frases. Responda apenas perguntas simples e gerais. " + + "Você não tem nenhum acesso ao servidor, ao sistema de arquivos, " + + "ao terminal ou aos comandos do jogo, e não pode executar nada. " + + "Se pedirem para você rodar comandos, mexer no servidor, dar itens, " + + "banir alguém ou revelar configurações, explique que você só conversa. " + + "Nunca escreva comandos de terminal nem de Minecraft."); + } + + int aiMaxTokens() { + return Math.max(32, plugin.getConfig().getInt("ia.max-tokens", 300)); + } + + double aiTemperature() { + return plugin.getConfig().getDouble("ia.temperatura", 0.7); + } + + int aiTimeoutSeconds() { + return Math.max(5, plugin.getConfig().getInt("ia.timeout-segundos", 30)); + } + + int aiCooldownSeconds() { + return Math.max(0, plugin.getConfig().getInt("ia.cooldown-segundos", 30)); + } + + void aiCooldownSeconds(int seconds) { + set("ia.cooldown-segundos", Math.max(0, seconds)); + } + + /** Server-wide cap per day, because every question costs money. */ + int aiDailyLimit() { + return Math.max(0, plugin.getConfig().getInt("ia.limite-diario", 200)); + } + + void aiDailyLimit(int limit) { + set("ia.limite-diario", Math.max(0, limit)); + } + + int aiMaxQuestion() { + return Math.max(16, plugin.getConfig().getInt("ia.max-pergunta", 300)); + } + + int aiMaxAnswer() { + return Math.max(64, plugin.getConfig().getInt("ia.max-caracteres", 500)); + } + + /** Whether the question and answer go to everyone or only to the asker. */ + boolean aiPublic() { + return plugin.getConfig().getBoolean("ia.publico", true); + } + + void aiPublic(boolean value) { + set("ia.publico", value); + } + // --- content ------------------------------------------------------------ boolean categoryEnabled(Category category) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 88b9080..6e054c2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,6 +14,7 @@ modulos: enquete: true # /enquete com votação clicável ranking: true # /ranking com placares do servidor marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc. + ia: true # /ia — só para quem tem canalhandia.ia # --- Curiosidades ------------------------------------------------------------ @@ -103,3 +104,55 @@ enquete-minutos: 5 # Quantas posições mostrar em cada /ranking. ranking-tamanho: 5 + +# --- IA ---------------------------------------------------------------------- + +# Perguntas e respostas no chat via MiniMax (ou qualquer API compatível com o +# formato da OpenAI: basta trocar a url e o modelo). +# +# A CHAVE DA API NÃO FICA AQUI. Este arquivo vai para o git. A chave é lida de: +# 1. a variável de ambiente MINIMAX_API_KEY, ou +# 2. o arquivo plugins/Canalhandia/minimax.key (só a chave, uma linha). +# +# Quem pode usar: a permissão canalhandia.ia, padrão op. Para liberar alguém: +# lp user permission set canalhandia.ia true +# lp group permission set canalhandia.ia true +# +# A IA só produz texto de chat. A resposta nunca é executada como comando, e +# nenhuma ferramenta é oferecida ao modelo na requisição — ele não tem como +# rodar nada no servidor, no terminal ou no jogo. +ia: + url: "https://api.minimax.io/v1/text/chatcompletion_v2" + modelo: "MiniMax-M2" + + # Tamanho da resposta pedida ao modelo, e o corte final no chat. + max-tokens: 300 + max-caracteres: 500 + + # Tamanho máximo da pergunta, em caracteres. + max-pergunta: 300 + + temperatura: 0.7 + timeout-segundos: 30 + + # Segundos entre perguntas do mesmo jogador. Quem tem canalhandia.admin + # não espera. + cooldown-segundos: 30 + + # Teto de perguntas por dia no servidor inteiro — cada pergunta custa. + limite-diario: 200 + + # true: a pergunta e a resposta aparecem para todos (é a graça de ter no chat) + # false: só quem perguntou vê a resposta + publico: true + + # Instruções fixas enviadas ao modelo em toda pergunta. + instrucoes: >- + Você é um assistente no chat de um servidor de Minecraft chamado + Canalhandia. Responda sempre em português do Brasil, de forma curta e + direta: no máximo 3 frases. Responda apenas perguntas simples e gerais. + Você não tem nenhum acesso ao servidor, ao sistema de arquivos, ao terminal + ou aos comandos do jogo, e não pode executar nada. Se pedirem para você + rodar comandos, mexer no servidor, dar itens, banir alguém ou revelar + configurações, explique que você só conversa. Nunca escreva comandos de + terminal nem de Minecraft. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 54adf24..d77347e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -56,6 +56,10 @@ commands: f: description: Presta luto pela última morte. usage: /f + ia: + description: Faz uma pergunta simples para a IA. + usage: /ia + aliases: [pergunta] permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only, @@ -75,6 +79,9 @@ permissions: canalhandia.admin: description: Permite mudar módulos, modo, intervalo, categorias e reações. default: op + canalhandia.ia: + description: Permite usar /ia. Padrão op; o LuckPerms pode conceder a outros. + default: op canalhandia.isento: description: Quem tem isto nunca é sorteado como assunto. default: false