Files
canalhandia/src/main/java/dev/marcospaulo/canalhandia/Ai.java
T
Marcos Paulo f1d210ddc4 feat(ia): agentic tool-calling — web search, stats, ranking, wiki
Turn /ia from a fixed-prompt chatbot into an agent that pulls what it needs.
Instead of one hardcoded wiki pre-fetch, the model is offered read-only tools
and MiniMax's tool_choice=auto lets it decide which to call; results are fed
back until it answers (MiniMax.answerWithTools), capped by ia.max-ferramentas.

- Search: web search via the cluster's self-hosted SearXNG (JSON API, no
  external key); results boiled down to a few "título — trecho (url)" lines.
- Tools: registry + dispatch for pesquisar_web, wiki, estatisticas_jogador,
  conquistas_jogador, ranking. All read-only and thread-safe off the main
  thread, so they run on the existing async answer worker.
- Ai.ask: agentic path when ia.ferramentas is on (default), else the previous
  behaviour untouched.
- Config: ia.ferramentas, ia.max-ferramentas, ia.searxng-url, ia.resultados-web,
  ia.trecho-web; getters in Settings. Reloadable via /canalhandia reload.

Verified live against MiniMax-M2.7 + SearXNG: the model auto-calls the tool and
answers from the result. SearchTest covers the pure result formatter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:52:04 -03:00

596 lines
27 KiB
Java

package dev.marcospaulo.canalhandia;
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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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 AiText#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.
*
* <p>This class is an <em>orchestrator</em>: it assembles context (server facts,
* operator corrections, recipes, conversation memory, a wiki article) into a
* list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It
* holds no {@code HttpClient} of its own.
*/
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 MiniMax api;
private final Wiki wiki;
private final Tools tools;
private final Conversations conversations;
private final Corrections corrections;
/** 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;
/**
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
* correct it without the operator retyping anything.
*/
private Answered lastAnswer;
record Answered(UUID asker, String question, String answer) {
}
/**
* How many answers players have flagged wrong with {@code /ia feedback ruim}.
* Shown in {@code /canalhandia status}. Simple in-memory counter — a restart
* resets it, like {@link #askedToday}.
*/
private int feedbackWrong;
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
Answered lastAnswer() {
return lastAnswer;
}
/** Package-private so Task 11 can wire quit-forget. */
Conversations conversations() {
return conversations;
}
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
Corrections corrections() {
return corrections;
}
/** A player marked the last answer wrong with {@code /ia feedback ruim}. */
boolean flagLastAnswerWrong() {
if (lastAnswer == null) {
return false;
}
feedbackWrong++;
plugin.getLogger().info("IA: resposta marcada como errada — pergunta: " + lastAnswer.question());
return true;
}
/** How many answers players have flagged wrong. Shown in /canalhandia status. */
int feedbackWrong() {
return feedbackWrong;
}
Ai(Canalhandia plugin) {
this.plugin = plugin;
Settings settings = plugin.settings();
Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
// production, so every wiki failure here is logged.
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
this.tools = new Tools(plugin, wiki,
new Search(fetcher, settings.aiSearxngUrl(), settings.aiSearchResults(),
settings.aiSearchSnippet(), plugin.getLogger()::warning),
plugin.getLogger()::info);
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
// are baked here at construction (passed to the MiniMax/Wiki/Conversations
// constructors and not re-read). Everything else — aiProfile(), aiModel(),
// aiMaxTokens(), aiTemperature(), aiInstructions(), aiServerContext() — is
// read live in the async body, so operators can hot-swap them. Profiles in
// particular switch live without a restart; the baked four are not meant
// to be hot-swapped.
}
/** True if a key is configured. Without one the module stays quiet. */
boolean configured() {
return apiKey() != null;
}
/**
* The first non-blank line of a key file or variable, with any control
* character removed. Null if there is nothing usable.
*
* <p>{@code trim()} alone leaves an <em>interior</em> newline — a key file
* with the key on line 1 and a comment on line 2 survives it. Such a key
* cannot go in an HTTP header, and the JDK's rejection of it quotes the
* whole header value, key included, into the exception message, which then
* reaches the server log. Cleaning at the source means that never happens;
* {@link HttpFetcher} checks again as a backstop.
*
* <p>Takes the first line rather than deleting the newline and joining, so
* a trailing comment line cannot be silently welded onto the key to make a
* different, wrong one — that would turn a readable failure into a puzzling
* authentication error.
*/
static String cleanKey(String raw) {
if (raw == null) {
return null;
}
for (String line : raw.split("\\R")) {
StringBuilder out = new StringBuilder(line.length());
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
// Printable ASCII only, which is all a bearer token contains.
// A deny-list of low controls is not enough: the JDK also
// rejects every char above U+00FF, so a U+FEFF byte order mark
// — what Notepad and PowerShell Out-File put at the head of a
// file — would survive and produce a header the JDK quotes
// back, key included. See HttpFetcher.checkBearer for the
// invariant this half must satisfy.
if (c > 0x20 && c < 0x7F) {
out.append(c);
}
}
String key = out.toString();
if (!key.isEmpty()) {
return key;
}
}
return null;
}
/** Logs a warning with any occurrence of the key removed. */
private void warnWithout(String key, String message) {
plugin.getLogger().warning(
key == null || key.isEmpty() ? message : message.replace(key, "***"));
}
private String apiKey() {
String fromEnv = System.getenv(KEY_ENV);
if (fromEnv != null && !fromEnv.isBlank()) {
return cleanKey(fromEnv);
}
Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE);
try {
if (Files.isReadable(file)) {
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
}
} catch (java.io.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) {
ask(asker, question, false);
}
/**
* Asks the model on behalf of a player and delivers the answer to chat.
*
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
* go only to the asker even when the module is public. A private
* question never broadcasts the question either.
*/
void ask(Player asker, String question, boolean isPrivate) {
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 (!isPrivate && 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();
final boolean isPriv = isPrivate;
// Captured HERE, on the main thread, because both read the Bukkit world
// and player API. The async body below only ever sees the resulting
// strings — moving either of these inside it would be a thread-safety
// bug that shows up as rare, confusing world-state corruption.
final String liveState = settings.aiServerState() ? ServerState.snapshot(asker) : null;
final String chatContext = plugin.chatLog().formatRecent(settings.aiChatContextLines());
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String answer = null;
try {
java.util.List<MiniMax.Turn> messages =
compose(asker, prompt, settings, liveState, chatContext);
if (settings.aiTools()) {
// Agentic path: the model pulls what it needs (web search,
// stats, ranking, wiki) via tools instead of a single fixed
// pre-fetch. The tools run on this same async worker.
answer = api.answerWithTools(key, settings.aiModel(), messages,
tools.definitions(), tools::run,
settings.aiMaxTokens(), settings.aiTemperature(),
settings.aiMaxToolCalls());
if (answer != null && AiText.hasForeignScript(answer)) {
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
answer = null;
}
} else {
if (settings.aiProfile() == AiProfile.PRECISO) {
String term = api.searchTerm(key, settings.aiModel(), prompt);
Wiki.Article article = term == null ? null : wiki.lookup(term);
if (article != null) {
messages.add(messages.size() - 1, new MiniMax.Turn("system",
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
+ article.text()));
}
}
answer = api.answer(key, settings.aiModel(), messages,
settings.aiMaxTokens(), settings.aiTemperature());
// Hidden reasoning can swallow the budget, and the model
// occasionally drops a foreign word mid-sentence. Both are
// worth one retry before giving up (carry-forwards #3 and #7).
if (answer == null || AiText.hasForeignScript(answer)) {
// Cap before doubling: an absurd ia.max-tokens near
// Integer.MAX_VALUE would overflow to a negative budget
// and be sent to the API. The default (1200) is unaffected.
int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2;
answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1);
}
if (answer != null && AiText.hasForeignScript(answer)) {
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
answer = null;
}
}
} catch (Exception e) {
// Redacts the key: the JDK's header validator quotes the whole
// Authorization value back into the exception message, so a key
// with a stray newline would otherwise reach the log verbatim.
// cleanKey makes that impossible; this is the backstop.
warnWithout(key, "Falha na chamada à IA: " + e);
answer = null;
}
String finalAnswer = answer;
Bukkit.getScheduler().runTask(plugin, () -> {
pending.remove(id);
deliver(id, prompt, finalAnswer, settings, isPriv);
});
});
}
/**
* Builds the messages for one question.
*
* <p>Order matters: system instructions, server context, operator
* corrections, recipes (which the wiki cannot supply — {@code explaintext}
* drops tables), the conversation history, then the question itself.
*/
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings,
String liveState, String chatContext) {
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
// Tone. Sent as its own turn right after the base instructions so the
// safety rules above are read first and the persona is decoration on
// top of them, never a replacement for them (Persona.GUARD restates the
// limits inside the persona's own frame as a second layer).
messages.add(new MiniMax.Turn("system", settings.aiPersona().systemText()));
String serverContext = settings.aiServerContext();
if (!serverContext.isBlank()) {
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
}
// The asker's own stats, so "quantos blocos eu minerei?" gets a real
// number instead of "não tenho acesso ao servidor". ~30 tokens; gated
// by ia.estatisticas-jogador so an operator can turn it off. Stale by
// under a minute (the server writes the stats JSON periodically).
if (settings.aiPlayerStats()) {
String stats = plugin.offlineStats().summary(asker.getUniqueId());
if (stats != null && !stats.isBlank()) {
messages.add(new MiniMax.Turn("system",
"Estatísticas do jogador que fez a pergunta — " + stats
+ ". Use estes números para responder perguntas sobre as estatísticas dele "
+ "(blocos minerados, tempo jogado, distância, mortes, monstros)."));
}
}
// Live world snapshot and recent chat. Both are captured on the main
// thread by the caller and arrive here as plain strings — nothing in
// this method may touch the Bukkit API, because compose() runs on the
// async task.
if (liveState != null && !liveState.isBlank()) {
messages.add(new MiniMax.Turn("system", liveState));
}
// Public notes only — Notes.publicSummary never returns a private one,
// and that filter lives there rather than here so no future caller can
// leak personal text to a third-party API by accident.
if (settings.moduleEnabled(Module.NOTAS)) {
String notes = plugin.notes().publicSummary(settings.aiNotes());
if (notes != null) {
messages.add(new MiniMax.Turn("system",
"Anotações públicas que os jogadores deixaram no servidor. "
+ "Use como fatos ao responder sobre lugares e combinados:\n" + notes));
}
}
if (chatContext != null && !chatContext.isBlank()) {
messages.add(new MiniMax.Turn("system",
"Últimas mensagens do chat público, da mais antiga para a mais recente. "
+ "Use só como contexto para entender do que estão falando; "
+ "não responda a elas, responda à pergunta:\n" + chatContext));
}
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
messages.add(new MiniMax.Turn("system",
"Correção registrada por um operador. Pergunta parecida: \""
+ entry.question() + "\" Resposta correta: " + entry.answer()));
}
// Recipes never appear in wiki text: explaintext drops tables. The wiki
// is consulted here only for the englishTitle translation; the recipe
// data comes from the Bukkit snapshot taken at enable (RecipeBook.preload).
if (RecipeBook.isRecipeQuestion(question)) {
String recipes = RecipeBook.describe(question, wiki);
if (recipes != null) {
messages.add(new MiniMax.Turn("system", recipes));
}
}
messages.addAll(conversations.history(asker.getUniqueId()));
messages.add(new MiniMax.Turn("user", question));
return messages;
}
private void deliver(UUID askerId, String question, String answer,
Settings settings, boolean isPrivate) {
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;
}
java.util.List<String> segments = AiText.segments(answer, settings.aiMaxAnswer(), settings.aiMaxMessages());
if (segments.isEmpty()) {
if (asker != null) {
Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes.");
}
return;
}
String clean = String.join(" ", segments);
// Only remember if the asker is still online: a PlayerQuitEvent forgets
// the player's history (carry-forward #6), and re-adding here after the
// quit would resurrect it. lastAnswer stays regardless, so /ia corrigir
// can still correct the last answer even after the asker left.
if (asker != null) {
conversations.remember(askerId, question, clean);
}
lastAnswer = new Answered(askerId, question, clean);
if (isPrivate || !settings.aiPublic()) {
if (asker != null) {
boolean bedrock = Platform.isBedrock(asker);
for (int i = 0; i < segments.size(); i++) {
asker.sendMessage(style(segments.get(i), question, settings, bedrock, i == 0));
}
}
return;
}
// Built per platform: Bedrock renders neither hover nor click, so it
// gets the plain line instead of silently losing the interaction. Each
// segment is its own broadcast — a list sent as five one-line messages
// reads as a list; sent as one flattened line it reads as noise.
for (int i = 0; i < segments.size(); i++) {
String segment = segments.get(i);
boolean first = i == 0;
plugin.broadcastPerPlatform(bedrock -> style(segment, question, settings, bedrock, first));
}
plugin.openAiReactions(askerId);
}
/**
* Renders one answer for chat.
*
* <p>Java players get a hover card naming the persona and the question that
* produced the answer, plus a click that pre-fills {@code /ia } so a
* follow-up is one keystroke away — {@code suggestCommand}, never
* {@code runCommand}, so nothing executes without the player pressing enter.
*
* <p>Bedrock gets the same text with no hover and no click, because it
* renders neither; the styling is decoration and its absence costs nothing.
* {@code ia.estilo-rico: false} forces the plain form everywhere.
*
* <p>A long or list-shaped answer arrives as several segments ({@link
* AiText#segments}); the first carries the full {@code [IA]} tag, the rest
* carry a plain grey continuation mark instead of repeating the tag on
* every line, so a five-item list reads as one grouped answer rather than
* five separate IA replies.
*/
private Component style(String answer, String question, Settings settings, boolean bedrock, boolean firstLine) {
Component body = Component.text(answer, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false);
if (!bedrock && settings.aiFancy()) {
Persona persona = settings.aiPersona();
body = body
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Pergunta: ", NamedTextColor.GRAY)
.append(Component.text(AiText.forLog(question), NamedTextColor.WHITE))
.append(Component.newline())
.append(Component.text("Personalidade: ", NamedTextColor.GRAY))
.append(Component.text(persona.key(), NamedTextColor.LIGHT_PURPLE))
.append(Component.newline())
.append(Component.text("Clique para perguntar outra coisa",
NamedTextColor.DARK_GRAY))))
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia "));
}
Component prefix = firstLine
? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
: Component.text(" » ", NamedTextColor.DARK_GRAY);
return prefix.append(body);
}
// --- spontaneous lines --------------------------------------------------
/**
* Says something unprompted, in the active persona — a jab at a death
* streak, a greeting for someone who just joined.
*
* <p>Everything about this is deliberately more restricted than {@code /ia}:
* it is gated by {@link Budget} (see the reasons there), it never consults
* the wiki, it asks for a much smaller answer, and it is silent on failure.
* A spontaneous line that errors should leave no trace — nobody asked for
* it, so nobody should see it fail.
*
* @param subject the player it is about, for the per-subject cooldown; may
* be null
* @param prompt what to comment on, already phrased as an instruction
*/
void saySomething(String subject, String prompt, Budget budget) {
Settings settings = plugin.settings();
if (!settings.moduleEnabled(Module.IA)) {
return;
}
String key = apiKey();
if (key == null) {
return;
}
long now = System.currentTimeMillis();
if (!budget.allows(subject, now)) {
return;
}
// Spent up front, not on success: two events landing in the same tick
// would otherwise both pass allows() and fire together, which is the
// exact double-message the gap exists to prevent.
budget.spend(subject, now);
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
messages.add(new MiniMax.Turn("system", settings.aiPersona().systemText()));
String serverContext = settings.aiServerContext();
if (!serverContext.isBlank()) {
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
}
messages.add(new MiniMax.Turn("system",
"Escreva UMA frase curta de no máximo 20 palavras para o chat do servidor, "
+ "no seu tom de sempre. Não faça perguntas, não cumprimente o chat, "
+ "não explique o que você está fazendo: só a frase."));
messages.add(new MiniMax.Turn("user", prompt));
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String answer;
try {
answer = api.answer(key, settings.aiModel(), messages,
settings.aiSpontaneousTokens(), settings.aiTemperature());
} catch (Exception e) {
warnWithout(key, "Falha na fala espontânea da IA: " + e);
return;
}
if (answer == null || answer.isBlank() || AiText.hasForeignScript(answer)) {
return;
}
String clean = AiText.sanitise(answer, settings.aiSpontaneousChars());
if (clean.isBlank()) {
return;
}
Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast(
Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(clean, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))));
});
}
// --- 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;
}
}