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
+3
View File
@@ -1 +1,4 @@
target/
# The MiniMax API key lives beside the plugin on the server, never in git.
minimax.key
@@ -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) {
+53
View File
@@ -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 <pergunta> — 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 <nome> permission set canalhandia.ia true
# lp group <grupo> 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.
+7
View File
@@ -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 <pergunta>
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