feat: /ia — chat question-and-answer backed by MiniMax

Adds an `ia` module: `/ia <pergunta>` 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.
This commit is contained in:
marcos
2026-08-05 13:41:28 +00:00
parent be6d34f456
commit 0d84a606d4
8 changed files with 481 additions and 2 deletions
@@ -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).
*
* <p><b>The model can only ever produce chat text.</b> 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.
*
* <p>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<UUID, Long> lastAsk = new HashMap<>();
/** In-flight guard: one question per player at a time. */
private final Map<UUID, Boolean> 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.
*
* <p>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<String> 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.
*
* <p>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.
*
* <p>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;
}
}
@@ -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. */
@@ -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>", "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.
*
* <p>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 <nome> 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 <pergunta>");
return true;
}
plugin.ai().ask(player, String.join(" ", args));
return true;
}
private boolean admin(CommandSender sender) {
if (sender.hasPermission(ADMIN)) {
return true;
@@ -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;
@@ -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) {