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:
@@ -1 +1,4 @@
|
|||||||
target/
|
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 Settings settings;
|
||||||
private OfflineStats offlineStats;
|
private OfflineStats offlineStats;
|
||||||
private Milestones milestones;
|
private Milestones milestones;
|
||||||
|
private Ai ai;
|
||||||
private NamespacedKey optOutKey;
|
private NamespacedKey optOutKey;
|
||||||
private BukkitTask timerTask;
|
private BukkitTask timerTask;
|
||||||
private BukkitTask milestoneTask;
|
private BukkitTask milestoneTask;
|
||||||
@@ -60,11 +61,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
settings = new Settings(this);
|
settings = new Settings(this);
|
||||||
offlineStats = new OfflineStats(this);
|
offlineStats = new OfflineStats(this);
|
||||||
milestones = new Milestones(this);
|
milestones = new Milestones(this);
|
||||||
|
ai = new Ai(this);
|
||||||
optOutKey = new NamespacedKey(this, "opt_out");
|
optOutKey = new NamespacedKey(this, "opt_out");
|
||||||
|
|
||||||
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
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);
|
register(name, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return offlineStats;
|
return offlineStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ai ai() {
|
||||||
|
return ai;
|
||||||
|
}
|
||||||
|
|
||||||
// --- scheduling ---------------------------------------------------------
|
// --- scheduling ---------------------------------------------------------
|
||||||
|
|
||||||
/** Starts, stops or restarts the repeating curiosity task to match the mode. */
|
/** 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 "palpite" -> guessLatest(sender, args);
|
||||||
case "votar" -> voteLatest(sender, args);
|
case "votar" -> voteLatest(sender, args);
|
||||||
case "reacoes" -> whoReacted(sender);
|
case "reacoes" -> whoReacted(sender);
|
||||||
|
case "ia" -> ia(sender, args);
|
||||||
default -> {
|
default -> {
|
||||||
String reaction = plugin.settings().reactionForCommand(command.getName());
|
String reaction = plugin.settings().reactionForCommand(command.getName());
|
||||||
if (reaction != null) {
|
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, "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() {
|
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 P | A | B", "abre uma enquete com opções clicáveis");
|
||||||
commands.put("/enquete encerrar", "encerra a enquete aberta");
|
commands.put("/enquete encerrar", "encerra a enquete aberta");
|
||||||
commands.put("/ranking [categoria]", "mostra os placares do servidor");
|
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 status", "mostra toda a configuração");
|
||||||
commands.put("/canalhandia modulos", "lista os módulos e seu estado");
|
commands.put("/canalhandia modulos", "lista os módulos e seu estado");
|
||||||
commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock");
|
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) {
|
private boolean admin(CommandSender sender) {
|
||||||
if (sender.hasPermission(ADMIN)) {
|
if (sender.hasPermission(ADMIN)) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ enum Module {
|
|||||||
LUTO("luto", "Botão [F] nas mortes"),
|
LUTO("luto", "Botão [F] nas mortes"),
|
||||||
ENQUETE("enquete", "Enquetes"),
|
ENQUETE("enquete", "Enquetes"),
|
||||||
RANKING("ranking", "Rankings"),
|
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 key;
|
||||||
private final String label;
|
private final String label;
|
||||||
|
|||||||
@@ -210,6 +210,88 @@ final class Settings {
|
|||||||
set("ranking-tamanho", Math.max(3, size));
|
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 ------------------------------------------------------------
|
// --- content ------------------------------------------------------------
|
||||||
|
|
||||||
boolean categoryEnabled(Category category) {
|
boolean categoryEnabled(Category category) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ modulos:
|
|||||||
enquete: true # /enquete com votação clicável
|
enquete: true # /enquete com votação clicável
|
||||||
ranking: true # /ranking com placares do servidor
|
ranking: true # /ranking com placares do servidor
|
||||||
marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc.
|
marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc.
|
||||||
|
ia: true # /ia <pergunta> — só para quem tem canalhandia.ia
|
||||||
|
|
||||||
# --- Curiosidades ------------------------------------------------------------
|
# --- Curiosidades ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -103,3 +104,55 @@ enquete-minutos: 5
|
|||||||
|
|
||||||
# Quantas posições mostrar em cada /ranking.
|
# Quantas posições mostrar em cada /ranking.
|
||||||
ranking-tamanho: 5
|
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.
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ commands:
|
|||||||
f:
|
f:
|
||||||
description: Presta luto pela última morte.
|
description: Presta luto pela última morte.
|
||||||
usage: /f
|
usage: /f
|
||||||
|
ia:
|
||||||
|
description: Faz uma pergunta simples para a IA.
|
||||||
|
usage: /ia <pergunta>
|
||||||
|
aliases: [pergunta]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||||
@@ -75,6 +79,9 @@ permissions:
|
|||||||
canalhandia.admin:
|
canalhandia.admin:
|
||||||
description: Permite mudar módulos, modo, intervalo, categorias e reações.
|
description: Permite mudar módulos, modo, intervalo, categorias e reações.
|
||||||
default: op
|
default: op
|
||||||
|
canalhandia.ia:
|
||||||
|
description: Permite usar /ia. Padrão op; o LuckPerms pode conceder a outros.
|
||||||
|
default: op
|
||||||
canalhandia.isento:
|
canalhandia.isento:
|
||||||
description: Quem tem isto nunca é sorteado como assunto.
|
description: Quem tem isto nunca é sorteado como assunto.
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
Reference in New Issue
Block a user