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 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 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 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