From 01d5983b6e0e86017c8c90154fededed7ef60a73 Mon Sep 17 00:00:00 2001 From: ZzPowerTechzZ Date: Mon, 22 Jun 2026 00:23:04 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20corrigir=20broadcast=20de=20blog=20e?= =?UTF-8?q?=20gera=C3=A7=C3=A3o=20de=20NPCs=20(x-store-domain=20+=20merge?= =?UTF-8?q?=20de=20config)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A API CentralCart é multi-tenant: toda chamada exige o header x-store-domain. Ele estava vazio em produção porque o plugin só fazia saveDefaultConfig(), que não mescla chaves novas em config.yml já existentes — então api.store_domain (e a seção blog) nunca apareciam. Resultado: o blog batia 404 "Store not found" e a busca de top doadores falhava antes de criar qualquer NPC. Causa raiz: - onEnable: mergeConfigDefaults() (copyDefaults) mescla chaves novas, preservando os valores já definidos pelo usuário - CentralCartApiService: envia x-store-domain e loga o corpo do erro (antes só o código) - BlogPostService: aborta cedo com erro claro quando o domínio não está configurado Robustez do blog: - BlogPostCheckTask: seeding na 1a execução e anúncio de todos os posts novos (não só o último); título sanitizado; fallback de layout se notification.lines faltar Refatoração: - novos DateTimeUtil e BlogNotifier; helpers em PluginUtils (remove duplicação entre task e comando de teste) - build.gradle: copyJar sem nome de jar fixo; versão 1.1.0 -> 1.2.0 Docs: - adiciona CLAUDE.md (inclui gotcha de build: JDK 21 vs Gradle 8.8/JDK 25 global) Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 53 +++++++ build.gradle | 13 +- .../CentralCartTopPlugin.java | 17 +- .../command/TestBlogPostCommand.java | 88 ++--------- .../service/BlogPostService.java | 145 +++++++++++++----- .../service/CentralCartApiService.java | 21 ++- .../task/BlogPostCheckTask.java | 141 ++++++++--------- .../util/BlogNotifier.java | 79 ++++++++++ .../centralCartTopPlugin/util/Constants.java | 16 ++ .../util/DateTimeUtil.java | 58 +++++++ .../util/PluginUtils.java | 58 +++++++ src/main/resources/config.yml | 6 +- src/main/resources/plugin.yml | 2 +- 13 files changed, 495 insertions(+), 202 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java create mode 100644 src/main/java/plugin/centralCartTopPlugin/util/DateTimeUtil.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d1351ee --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## O que é + +Plugin Paper/Spigot (Java 21) que consome a API da **CentralCart** para: exibir os top 3 doadores do mês anterior, criar/atualizar NPCs (via Citizens) desses doadores, distribuir recompensas e fazer broadcast de novos posts do blog da loja. Nome do artefato: `centralCartTopPlugin`. Pacote raiz: `plugin.centralCartTopPlugin`. + +## Build, deploy e execução + +> ⚠️ **Gotcha de JDK (lê isto antes de buildar).** O `~/.gradle/gradle.properties` global desta máquina força `org.gradle.java.home=jdk-25.0.3` (para projetos Fabric). O **Gradle 8.8 não roda sobre Java 25** e falha com `Unsupported class file major version 69`. Há um **JDK 21** em `C:\Program Files\Java\jdk-21` (alvo do projeto). Sobrescreva por build, **sem** alterar o gradle.properties global: + +```bash +# Compilar +./gradlew compileJava --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21" + +# Gerar o jar (sai em build/libs/centralCartTopPlugin-.jar; inclui o gson shaded) +./gradlew jar --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21" + +# Copiar o jar para o servidor local (D:/AUSTV/localhost/plugins) +./gradlew copyJar --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21" + +# Subir um servidor Paper de teste (plugin run-paper, MC 1.21) +./gradlew runServer --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21" +``` + +Não há suíte de testes automatizados — a validação é via build + teste no servidor (comandos `/test*`). + +A versão vive em `build.gradle` (`version = '...'`) e é injetada no `plugin.yml` via `processResources` (`expand`, o `plugin.yml` usa `${version}`). **Convenção do dono do repo: sempre incrementar a versão em `build.gradle` a cada modificação.** + +## Arquitetura + +Camadas sob `src/main/java/plugin/centralCartTopPlugin/`: + +- **`CentralCartTopPlugin`** — entrypoint. No `onEnable`: `saveDefaultConfig()` → `mergeConfigDefaults()` → inicializa serviços → registra comandos/listeners → carrega NPCs após `STARTUP_DELAY_TICKS` → inicia as tasks. `reloadServices()` recria serviços e re-registra comandos (usado pelo `/centralcartreload`). +- **`service/`** — `CentralCartApiService` (top doadores, com `TopDonatorsCache`), `BlogPostService` (posts do blog), `RewardsManager`. Os serviços de rede expõem `CompletableFuture` e fazem I/O fora da main thread. +- **`task/`** — `MonthlyNpcUpdateTask` (timer horário; atualiza NPCs no dia 1º), `BlogPostCheckTask` (a cada 5 min; detecta posts novos). +- **`service/TopNpcManager`** — toda a integração com Citizens (criação/atualização/skin/posição dos NPCs). +- **`manager/MessagesManager`** + **`util/MessageFormatter`** — mensagens externalizadas em `messages.yml`; formatter aceita **códigos legados `&`/`§` E tags MiniMessage** na mesma string. +- **`util/`** — `Constants` (defaults, URLs, intervalos), `PluginUtils` (mapeamento posição→key, normalização de domínio, leitura de corpo de erro HTTP), `DateTimeUtil` (parsing de datas da API), `BlogNotifier` (montagem de placeholders + broadcast do blog, compartilhado entre task e comando de teste). + +### Invariantes que não são óbvias + +- **A API CentralCart é multi-tenant.** TODA chamada (`top_customers` e `webstore/post`) precisa do header **`x-store-domain`** com o domínio da loja (`api.store_domain`, ex.: `loja.austv.net`). Sem ele a API responde **`404 "Store not found"`** — foi a causa de NPCs e broadcast de blog pararem de funcionar. O token vai em `Authorization: Bearer`. Mantenha os dois sempre que adicionar novos endpoints. +- **Config self-healing.** `mergeConfigDefaults()` (`copyDefaults(true)` + `saveConfig()`) mescla chaves novas em `config.yml` já existentes — `saveDefaultConfig()` sozinho **não** atualiza arquivos existentes. Ao adicionar uma chave de config, garanta que ela exista no `config.yml` embutido, senão servidores antigos nunca a recebem. +- **Thread safety.** Serviços fazem HTTP em async (`CompletableFuture`/`runTaskAsynchronously`). Qualquer interação com Bukkit/Citizens (spawn de NPC, `broadcast`, `saveConfig`) DEVE voltar à main thread via `Bukkit.getScheduler().runTask(...)`. As tasks e comandos já seguem esse padrão — preserve-o. +- **NPCs são reutilizados, não recriados.** `TopNpcManager` persiste `npcs.saved_ids` (posição→id do NPC) no config; o Citizens recarrega os NPCs entre reinícios. Não destrua NPCs no `onDisable`. +- **Citizens é `softdepend`.** Sempre cheque `npcManager.isCitizensEnabled()` antes de mexer com NPCs. +- **Detecção de post novo.** `BlogPostCheckTask` faz *seeding* na primeira execução (marca o post mais recente como visto sem anunciar) e depois anuncia todos os posts com `id` maior que `blog.last_seen_post_id` (comparação numérica). A API entrega os posts em ordem decrescente de `id`. + +## Configuração (`src/main/resources/`) + +`config.yml` (API/token/store_domain, NPCs, blog), `messages.yml` (textos), `rewards.yml` (recompensas), `plugin.yml` (comandos/permissões). Permissão de admin: `centralcart.admin`. Detalhes de comandos no `README.md`. diff --git a/build.gradle b/build.gradle index 57abee6..4f0622f 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'com.centralcart' -version = '1.1.0' +version = '1.2.0' compileJava.options.encoding('UTF-8') @@ -68,9 +68,10 @@ processResources { } } -task copyJar { - copy { - from 'build/libs/centralCartTopPlugin-1.1.0.jar' - into 'D:/AUSTV/localhost/plugins' - } +// Copia o jar final para a pasta de plugins do servidor local. +// Usa a saída da task 'jar' (cria a dependência automaticamente) e a versão dinâmica, +// evitando o nome de arquivo fixo que quebrava a cada bump de versão. +tasks.register('copyJar', Copy) { + from tasks.named('jar') + into 'D:/AUSTV/localhost/plugins' } diff --git a/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java b/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java index 3b4d16c..dc5acc6 100644 --- a/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java +++ b/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java @@ -38,8 +38,9 @@ public final class CentralCartTopPlugin extends JavaPlugin { public void onEnable() { getLogger().info("§a[CentralCartTopPlugin] Plugin iniciado com sucesso!"); - // Salva o config.yml padrão se não existir + // Salva o config.yml padrão se não existir e mescla chaves novas em configs já existentes saveDefaultConfig(); + mergeConfigDefaults(); // Inicializa o gerenciador de mensagens PRIMEIRO messagesManager = new MessagesManager(this); @@ -122,6 +123,20 @@ public void onDisable() { getLogger().info("§c[CentralCartTopPlugin] Plugin desabilitado!"); } + /** + * Mescla no config.yml do servidor as chaves que foram adicionadas em versões posteriores + * do plugin (ex.: {@code api.store_domain}, seção {@code blog}). {@code saveDefaultConfig()} + * não atualiza arquivos já existentes, então sem isto servidores antigos ficam sem as chaves + * novas — foi a causa do broadcast de blog não funcionar (domínio ausente -> 404). + * + *

Valores já definidos pelo usuário são preservados; apenas chaves ausentes recebem o + * valor padrão embutido no jar. + */ + private void mergeConfigDefaults() { + getConfig().options().copyDefaults(true); + saveConfig(); + } + /** * Inicializa os serviços do plugin */ diff --git a/src/main/java/plugin/centralCartTopPlugin/command/TestBlogPostCommand.java b/src/main/java/plugin/centralCartTopPlugin/command/TestBlogPostCommand.java index 1c975d4..22b5e49 100644 --- a/src/main/java/plugin/centralCartTopPlugin/command/TestBlogPostCommand.java +++ b/src/main/java/plugin/centralCartTopPlugin/command/TestBlogPostCommand.java @@ -1,6 +1,5 @@ package plugin.centralCartTopPlugin.command; -import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; @@ -8,29 +7,17 @@ import org.jetbrains.annotations.NotNull; import plugin.centralCartTopPlugin.CentralCartTopPlugin; import plugin.centralCartTopPlugin.model.BlogPost; -import plugin.centralCartTopPlugin.util.MessageFormatter; - -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import plugin.centralCartTopPlugin.util.BlogNotifier; +import plugin.centralCartTopPlugin.util.Constants; + import java.util.logging.Level; +/** + * Testa o broadcast de novo post do blog usando o MESMO caminho da task automática + * (via {@link BlogNotifier}). Não altera {@code last_seen_post_id}. + */ public class TestBlogPostCommand implements CommandExecutor { - private static final DateTimeFormatter[] PARSERS = { - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"), - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"), - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), - }; - - private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); - private final CentralCartTopPlugin plugin; public TestBlogPostCommand(CentralCartTopPlugin plugin) { @@ -39,11 +26,17 @@ public TestBlogPostCommand(CentralCartTopPlugin plugin) { @Override public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { - if (!sender.hasPermission("centralcart.admin")) { + if (!sender.hasPermission(Constants.PERMISSION_ADMIN)) { sender.sendMessage("§c§l[CentralCart] §cVocê não tem permissão para usar este comando."); return true; } + if (!plugin.getBlogPostService().isConfigured()) { + sender.sendMessage("§c§l[Blog] §capi.store_domain não está configurado no config.yml."); + sender.sendMessage("§7Configure §fapi.store_domain: \"loja.austv.net\" §7e use §f/centralcartreload§7."); + return true; + } + sender.sendMessage("§e§l[Blog] §eBuscando último post na API..."); plugin.getBlogPostService().getLatestPost().thenAccept(optPost -> { @@ -63,8 +56,6 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return; } - Map placeholders = buildPlaceholders(post); - Bukkit.getScheduler().runTask(plugin, () -> { sender.sendMessage("§a§l[Blog] §aPost obtido com sucesso!"); sender.sendMessage("§7ID: §f" + post.getId()); @@ -73,13 +64,10 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command sender.sendMessage("§7Data: §f" + post.getCreatedAt()); sender.sendMessage("§e§l[Blog] §eDisparando broadcast..."); - List lines = plugin.getConfig().getStringList("blog.notification.lines"); - for (String line : lines) { - Component component = MessageFormatter.parse(line, placeholders); - Bukkit.getServer().broadcast(component); - } + int sent = BlogNotifier.broadcast(plugin, post); - sender.sendMessage("§a§l[Blog] §aBroadcast enviado! (last_seen_post_id NÃO foi alterado)"); + sender.sendMessage("§a§l[Blog] §aBroadcast enviado (" + sent + + " linha(s))! §7(last_seen_post_id NÃO foi alterado)"); }); }).exceptionally(throwable -> { @@ -92,46 +80,4 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command return true; } - - private Map buildPlaceholders(BlogPost post) { - Map map = new HashMap<>(); - map.put("title", post.getTitle() != null ? post.getTitle() : ""); - map.put("url", post.getUrl() != null ? post.getUrl() : ""); - - String time = ""; - String date = ""; - - if (post.getCreatedAt() != null && !post.getCreatedAt().isEmpty()) { - LocalDateTime dt = tryParseDateTime(post.getCreatedAt()); - if (dt != null) { - time = dt.format(TIME_FORMAT); - date = dt.format(DATE_FORMAT); - } else { - time = post.getCreatedAt(); - date = post.getCreatedAt(); - } - } - - map.put("time", time); - map.put("date", date); - return map; - } - - private LocalDateTime tryParseDateTime(String raw) { - // Tenta primeiro como OffsetDateTime (ex: "2024-03-03T23:43:12.000-03:00") - try { - return OffsetDateTime.parse(raw, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime(); - } catch (DateTimeParseException ignored) { - // segue para os formatos legados - } - - for (DateTimeFormatter fmt : PARSERS) { - try { - return LocalDateTime.parse(raw, fmt); - } catch (DateTimeParseException ignored) { - // tenta próximo formato - } - } - return null; - } } diff --git a/src/main/java/plugin/centralCartTopPlugin/service/BlogPostService.java b/src/main/java/plugin/centralCartTopPlugin/service/BlogPostService.java index 4715eb1..b9e401e 100644 --- a/src/main/java/plugin/centralCartTopPlugin/service/BlogPostService.java +++ b/src/main/java/plugin/centralCartTopPlugin/service/BlogPostService.java @@ -2,11 +2,13 @@ import com.google.gson.Gson; import com.google.gson.JsonArray; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import org.bukkit.configuration.file.FileConfiguration; import plugin.centralCartTopPlugin.model.BlogPost; import plugin.centralCartTopPlugin.util.Constants; +import plugin.centralCartTopPlugin.util.PluginUtils; import java.io.BufferedReader; import java.io.IOException; @@ -14,11 +16,21 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.logging.Level; import java.util.logging.Logger; +/** + * Acessa a API de posts da loja (webstore) da CentralCart. + * + *

A API é multi-tenant: cada requisição precisa do header {@code x-store-domain} identificando + * a loja. Sem ele, a API responde {@code 404 "Store not found"}. Por isso o serviço aborta cedo + * (com erro explícito) quando {@code api.store_domain} não está configurado. + */ public class BlogPostService { private final Gson gson; @@ -36,26 +48,50 @@ public BlogPostService(Logger logger, FileConfiguration config) { this.retryDelay = config.getInt("api.retry_delay", Constants.DEFAULT_RETRY_DELAY); // Aceita URL completa (https://loja.austv.net/) ou apenas o domínio (loja.austv.net) - String raw = config.getString("api.store_domain", ""); - this.storeDomain = raw.replaceAll("^https?://", "").replaceAll("/+$", ""); - - if (this.storeDomain.isEmpty() || this.storeDomain.equals("COLOQUE_SEU_DOMINIO_AQUI")) { - logger.warning("[Blog] store_domain não configurado! Configure api.store_domain no config.yml"); + this.storeDomain = PluginUtils.normalizeStoreDomain(config.getString("api.store_domain", "")); + + if (!PluginUtils.isStoreDomainConfigured(storeDomain)) { + logger.warning("=========================================="); + logger.warning("[Blog] api.store_domain NÃO configurado!"); + logger.warning("[Blog] As notificações de blog ficarão indisponíveis (a API responde 404 'Store not found')."); + logger.warning("[Blog] Configure em config.yml -> api.store_domain: \"loja.austv.net\""); + logger.warning("=========================================="); } } /** - * Busca o último post publicado no blog de forma assíncrona + * @return true se o domínio da loja está configurado e o serviço pode operar + */ + public boolean isConfigured() { + return PluginUtils.isStoreDomainConfigured(storeDomain); + } + + /** + * Busca o post mais recente publicado no blog de forma assíncrona (conveniência). */ public CompletableFuture> getLatestPost() { + return getRecentPosts() + .thenApply(posts -> posts.isEmpty() ? Optional.empty() : Optional.of(posts.get(0))); + } + + /** + * Busca os posts recentes do blog, ordenados do mais recente para o mais antigo + * (mesma ordem retornada pela API). Retorna lista vazia em caso de falha. + */ + public CompletableFuture> getRecentPosts() { return CompletableFuture.supplyAsync(() -> { + if (!PluginUtils.isStoreDomainConfigured(storeDomain)) { + logger.severe("[Blog] Requisição abortada: api.store_domain não configurado em config.yml."); + return Collections.emptyList(); + } + for (int attempt = 1; attempt <= retryAttempts; attempt++) { try { if (attempt > 1) { logger.log(Level.INFO, "[Blog] Tentativa {0} de {1}...", new Object[]{attempt, retryAttempts}); } - return Optional.ofNullable(fetchLatestPost()); + return fetchRecentPosts(); } catch (java.net.SocketTimeoutException e) { logger.log(Level.WARNING, "[Blog] Timeout na tentativa {0} de {1}", new Object[]{attempt, retryAttempts}); @@ -70,7 +106,7 @@ public CompletableFuture> getLatestPost() { } catch (Exception e) { logger.log(Level.WARNING, "[Blog] Erro na tentativa {0}: {1}", new Object[]{attempt, e.getMessage()}); if (attempt == retryAttempts) { - logger.log(Level.SEVERE, "[Blog] Erro ao buscar último post após {0} tentativas: {1}", + logger.log(Level.SEVERE, "[Blog] Erro ao buscar posts após {0} tentativas: {1}", new Object[]{retryAttempts, e.getMessage()}); } else { sleepRetry(); @@ -78,11 +114,11 @@ public CompletableFuture> getLatestPost() { } } - return Optional.empty(); + return Collections.emptyList(); }); } - private BlogPost fetchLatestPost() throws Exception { + private List fetchRecentPosts() throws Exception { URL url = new URL(Constants.BLOG_API_URL); HttpURLConnection connection = null; BufferedReader reader = null; @@ -96,22 +132,12 @@ private BlogPost fetchLatestPost() throws Exception { connection.setReadTimeout(timeout); logger.log(Level.INFO, "[Blog] GET {0} | x-store-domain: {1}", - new Object[]{Constants.BLOG_API_URL, storeDomain.isEmpty() ? "(vazio)" : storeDomain}); + new Object[]{Constants.BLOG_API_URL, storeDomain}); int responseCode = connection.getResponseCode(); if (responseCode != 200) { - // Lê o corpo do erro para diagnóstico - java.io.InputStream errStream = connection.getErrorStream(); - String errBody = ""; - if (errStream != null) { - try (BufferedReader errReader = new BufferedReader(new InputStreamReader(errStream, StandardCharsets.UTF_8))) { - StringBuilder sb = new StringBuilder(); - String l; - while ((l = errReader.readLine()) != null) sb.append(l); - errBody = sb.toString(); - } catch (IOException ignored) {} - } + String errBody = PluginUtils.readErrorBody(connection); throw new Exception("[Blog] HTTP " + responseCode + " — body: " + errBody); } @@ -130,36 +156,26 @@ private BlogPost fetchLatestPost() throws Exception { } // A API retorna { "meta": {...}, "data": [ posts ] } - if (!root.has("data") || !root.get("data").isJsonArray()) { + if (root == null || !root.has("data") || !root.get("data").isJsonArray()) { throw new Exception("[Blog] Resposta sem campo 'data' esperado."); } JsonArray data = root.getAsJsonArray("data"); if (data.isEmpty()) { logger.info("[Blog] Nenhum post encontrado no blog."); - return null; + return Collections.emptyList(); } - // O primeiro elemento é o post mais recente - JsonObject obj = data.get(0).getAsJsonObject(); - - BlogPost post = new BlogPost(); - - // id é numérico na API - if (obj.has("id")) post.setId(String.valueOf(obj.get("id").getAsLong())); - if (obj.has("title")) post.setTitle(obj.get("title").getAsString()); - - // url completa = domínio da loja + path - if (obj.has("path")) post.setUrl("https://" + storeDomain + obj.get("path").getAsString()); - - if (obj.has("created_at")) { - post.setCreatedAt(obj.get("created_at").getAsString()); - } else if (obj.has("createdAt")) { - post.setCreatedAt(obj.get("createdAt").getAsString()); + List posts = new ArrayList<>(data.size()); + for (JsonElement element : data) { + if (element.isJsonObject()) { + posts.add(parsePost(element.getAsJsonObject())); + } } - logger.log(Level.INFO, "[Blog] Post obtido: {0}", post.getTitle()); - return post; + logger.log(Level.INFO, "[Blog] {0} post(s) obtido(s). Mais recente: {1}", + new Object[]{posts.size(), posts.isEmpty() ? "(nenhum)" : posts.get(0).getTitle()}); + return posts; } finally { if (reader != null) { @@ -175,6 +191,51 @@ private BlogPost fetchLatestPost() throws Exception { } } + /** + * Converte um objeto JSON de post em {@link BlogPost}, montando a URL completa + * (domínio + path) e sanitizando o título. + */ + private BlogPost parsePost(JsonObject obj) { + BlogPost post = new BlogPost(); + + // id é numérico na API + if (hasValue(obj, "id")) { + post.setId(String.valueOf(obj.get("id").getAsLong())); + } + if (hasValue(obj, "title")) { + post.setTitle(sanitizeTitle(obj.get("title").getAsString())); + } + // url completa = domínio da loja + path + if (hasValue(obj, "path")) { + post.setUrl("https://" + storeDomain + obj.get("path").getAsString()); + } + if (hasValue(obj, "created_at")) { + post.setCreatedAt(obj.get("created_at").getAsString()); + } else if (hasValue(obj, "createdAt")) { + post.setCreatedAt(obj.get("createdAt").getAsString()); + } + + return post; + } + + private boolean hasValue(JsonObject obj, String key) { + return obj.has(key) && !obj.get(key).isJsonNull(); + } + + /** + * Limpa o título vindo da API: remove prefixo markdown de heading ("# ") e caracteres + * {@code < >} que quebrariam o parsing do MiniMessage ao serem injetados via placeholder. + */ + private String sanitizeTitle(String raw) { + if (raw == null) { + return null; + } + return raw.replaceAll("^#+\\s*", "") + .replace("<", "") + .replace(">", "") + .trim(); + } + private void sleepRetry() { try { Thread.sleep(retryDelay); diff --git a/src/main/java/plugin/centralCartTopPlugin/service/CentralCartApiService.java b/src/main/java/plugin/centralCartTopPlugin/service/CentralCartApiService.java index d189cab..e011ece 100644 --- a/src/main/java/plugin/centralCartTopPlugin/service/CentralCartApiService.java +++ b/src/main/java/plugin/centralCartTopPlugin/service/CentralCartApiService.java @@ -35,6 +35,7 @@ public class CentralCartApiService { private final String apiUrl; private final int timeout; private final String authToken; + private final String storeDomain; private final int retryAttempts; private final int retryDelay; private final TopDonatorsCache cache; @@ -45,6 +46,7 @@ public CentralCartApiService(Logger logger, FileConfiguration config) { this.apiUrl = config.getString("api.url", Constants.DEFAULT_API_URL); this.timeout = config.getInt("api.timeout", Constants.DEFAULT_TIMEOUT); this.authToken = config.getString("api.token", ""); + this.storeDomain = PluginUtils.normalizeStoreDomain(config.getString("api.store_domain", "")); this.retryAttempts = config.getInt("api.retry_attempts", Constants.DEFAULT_RETRY_ATTEMPTS); this.retryDelay = config.getInt("api.retry_delay", Constants.DEFAULT_RETRY_DELAY); @@ -71,6 +73,15 @@ public CentralCartApiService(Logger logger, FileConfiguration config) { logger.warning("Configure o token em: plugins/centralCartTopPlugin/config.yml"); logger.warning("=========================================="); } + + // Validar se o domínio da loja foi configurado (necessário no header x-store-domain) + if (!PluginUtils.isStoreDomainConfigured(storeDomain)) { + logger.warning("=========================================="); + logger.warning("ATENÇÃO: api.store_domain não configurado!"); + logger.warning("A API CentralCart pode responder 404 'Store not found' sem o domínio da loja."); + logger.warning("Configure em config.yml -> api.store_domain: \"loja.austv.net\""); + logger.warning("=========================================="); + } } /** @@ -218,9 +229,16 @@ private List fetchTopCustomers(String from, String to) throws Excep connection.setRequestMethod("GET"); connection.setRequestProperty("Accept", "application/json"); connection.setRequestProperty("Authorization", "Bearer " + authToken); + // A API CentralCart é multi-tenant: identifica a loja pelo domínio. + if (PluginUtils.isStoreDomainConfigured(storeDomain)) { + connection.setRequestProperty("x-store-domain", storeDomain); + } connection.setConnectTimeout(timeout); connection.setReadTimeout(timeout); + logger.log(Level.INFO, "[Top] GET {0} | x-store-domain: {1}", + new Object[]{urlString, storeDomain.isEmpty() ? "(vazio)" : storeDomain}); + int responseCode = connection.getResponseCode(); if (responseCode == 401) { @@ -230,7 +248,8 @@ private List fetchTopCustomers(String from, String to) throws Excep } if (responseCode != 200) { - throw new Exception("HTTP error code: " + responseCode); + String errBody = PluginUtils.readErrorBody(connection); + throw new Exception("HTTP " + responseCode + " — body: " + errBody); } // Lê a resposta usando try-with-resources diff --git a/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java b/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java index 4cb0098..0ba1707 100644 --- a/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java +++ b/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java @@ -1,33 +1,29 @@ package plugin.centralCartTopPlugin.task; -import net.kyori.adventure.text.Component; import org.bukkit.Bukkit; import org.bukkit.scheduler.BukkitRunnable; import plugin.centralCartTopPlugin.CentralCartTopPlugin; import plugin.centralCartTopPlugin.model.BlogPost; -import plugin.centralCartTopPlugin.util.MessageFormatter; +import plugin.centralCartTopPlugin.util.BlogNotifier; -import java.time.LocalDateTime; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.DateTimeParseException; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.logging.Level; +/** + * Verifica periodicamente novos posts no blog e dispara o broadcast. + * + *

Estratégia de detecção: + *

    + *
  • Seeding: na primeira execução (sem {@code last_seen_post_id}), apenas marca o post + * mais recente como visto, sem anunciar — evita spammar posts antigos ao subir o servidor.
  • + *
  • Múltiplos posts: anuncia todos os posts mais novos que o último visto (do mais antigo + * para o mais recente), e não apenas o primeiro da lista.
  • + *
+ */ public class BlogPostCheckTask extends BukkitRunnable { - private static final DateTimeFormatter[] PARSERS = { - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"), - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"), - DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), - }; - - private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); - private final CentralCartTopPlugin plugin; public BlogPostCheckTask(CentralCartTopPlugin plugin) { @@ -36,42 +32,61 @@ public BlogPostCheckTask(CentralCartTopPlugin plugin) { @Override public void run() { - plugin.getBlogPostService().getLatestPost().thenAccept(optPost -> { - if (optPost.isEmpty()) { - plugin.getLogger().warning("[Blog] Nenhum post retornado pela API."); - return; + plugin.getBlogPostService().getRecentPosts().thenAccept(posts -> { + List valid = new ArrayList<>(); + for (BlogPost post : posts) { + if (post.getId() != null && !post.getId().isEmpty()) { + valid.add(post); + } } - BlogPost post = optPost.get(); + if (valid.isEmpty()) { + plugin.getLogger().log(Level.FINE, "[Blog] Nenhum post válido retornado pela API."); + return; + } - if (post.getId() == null || post.getId().isEmpty()) { - plugin.getLogger().warning("[Blog] Post sem ID recebido — ignorando."); + BlogPost newest = valid.get(0); + String lastSeen = plugin.getConfig().getString("blog.last_seen_post_id", ""); + + // Primeira execução / estado limpo: faz seeding sem anunciar. + if (lastSeen == null || lastSeen.isEmpty()) { + Bukkit.getScheduler().runTask(plugin, () -> { + plugin.getConfig().set("blog.last_seen_post_id", newest.getId()); + plugin.saveConfig(); + plugin.getLogger().info("[Blog] Inicialização: post mais recente (ID " + newest.getId() + + ") marcado como visto. Próximos posts serão anunciados."); + }); return; } - String lastSeenId = plugin.getConfig().getString("blog.last_seen_post_id", ""); + // Coleta os posts mais novos que o último visto. A API entrega em ordem decrescente, + // então paramos assim que encontramos um post que não é mais novo. + List novos = new ArrayList<>(); + for (BlogPost post : valid) { + if (isNewer(post.getId(), lastSeen)) { + novos.add(post); + } else { + break; + } + } - if (post.getId().equals(lastSeenId)) { - plugin.getLogger().log(Level.FINE, "[Blog] Nenhum post novo (ID {0} já visto).", post.getId()); + if (novos.isEmpty()) { + plugin.getLogger().log(Level.FINE, "[Blog] Nenhum post novo (último visto: {0}).", lastSeen); return; } - plugin.getLogger().info("[Blog] Novo post detectado: " + post.getTitle()); - - Map placeholders = buildPlaceholders(post); + // Anuncia do mais antigo para o mais recente, para manter a ordem cronológica no chat. + Collections.reverse(novos); - // Agenda broadcast na thread principal Bukkit.getScheduler().runTask(plugin, () -> { - List lines = plugin.getConfig().getStringList("blog.notification.lines"); - for (String line : lines) { - Component component = MessageFormatter.parse(line, placeholders); - Bukkit.getServer().broadcast(component); + for (BlogPost post : novos) { + plugin.getLogger().info("[Blog] Novo post detectado: " + post.getTitle()); + BlogNotifier.broadcast(plugin, post); } - - plugin.getConfig().set("blog.last_seen_post_id", post.getId()); + plugin.getConfig().set("blog.last_seen_post_id", newest.getId()); plugin.saveConfig(); - - plugin.getLogger().info("[Blog] Broadcast realizado e last_seen_post_id atualizado para " + post.getId()); + plugin.getLogger().info("[Blog] " + novos.size() + " post(s) anunciado(s). last_seen_post_id = " + + newest.getId()); }); }).exceptionally(throwable -> { @@ -80,45 +95,15 @@ public void run() { }); } - private Map buildPlaceholders(BlogPost post) { - Map map = new HashMap<>(); - map.put("title", post.getTitle() != null ? post.getTitle() : ""); - map.put("url", post.getUrl() != null ? post.getUrl() : ""); - - String time = ""; - String date = ""; - - if (post.getCreatedAt() != null && !post.getCreatedAt().isEmpty()) { - LocalDateTime dt = tryParseDateTime(post.getCreatedAt()); - if (dt != null) { - time = dt.format(TIME_FORMAT); - date = dt.format(DATE_FORMAT); - } else { - time = post.getCreatedAt(); - date = post.getCreatedAt(); - } - } - - map.put("time", time); - map.put("date", date); - return map; - } - - private LocalDateTime tryParseDateTime(String raw) { - // Tenta primeiro como OffsetDateTime (ex: "2024-03-03T23:43:12.000-03:00") + /** + * Compara IDs de post. IDs são numéricos (auto-increment) na API; usa comparação numérica + * e degrada para comparação textual de igualdade caso não sejam parseáveis. + */ + private boolean isNewer(String candidateId, String lastSeenId) { try { - return OffsetDateTime.parse(raw, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime(); - } catch (DateTimeParseException ignored) { - // segue para os formatos legados - } - - for (DateTimeFormatter fmt : PARSERS) { - try { - return LocalDateTime.parse(raw, fmt); - } catch (DateTimeParseException ignored) { - // tenta próximo formato - } + return Long.parseLong(candidateId) > Long.parseLong(lastSeenId); + } catch (NumberFormatException e) { + return !candidateId.equals(lastSeenId); } - return null; } } diff --git a/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java b/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java new file mode 100644 index 0000000..b0133c8 --- /dev/null +++ b/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java @@ -0,0 +1,79 @@ +package plugin.centralCartTopPlugin.util; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import plugin.centralCartTopPlugin.model.BlogPost; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Centraliza a formatação e o broadcast de notificações de novos posts do blog. + * + *

Garante que a task automática ({@code BlogPostCheckTask}) e o comando manual + * ({@code /testblogpost}) produzam exatamente a mesma mensagem, evitando divergência. + */ +public final class BlogNotifier { + + private BlogNotifier() { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Monta o mapa de placeholders ({@code title}, {@code url}, {@code time}, {@code date}) + * a partir de um post. + */ + public static Map buildPlaceholders(BlogPost post) { + Map map = new HashMap<>(); + map.put("title", post.getTitle() != null ? post.getTitle() : ""); + map.put("url", post.getUrl() != null ? post.getUrl() : ""); + + String time = ""; + String date = ""; + + String createdAt = post.getCreatedAt(); + if (createdAt != null && !createdAt.isEmpty()) { + LocalDateTime dt = DateTimeUtil.tryParse(createdAt); + if (dt != null) { + time = dt.format(DateTimeUtil.TIME_FORMAT); + date = dt.format(DateTimeUtil.DATE_FORMAT); + } else { + // Não conseguimos parsear: expõe o valor bruto em vez de string vazia + time = createdAt; + date = createdAt; + } + } + + map.put("time", time); + map.put("date", date); + return map; + } + + /** + * Dispara o broadcast da notificação do post para todo o servidor. + * + *

Deve ser chamado na thread principal. Usa {@code blog.notification.lines} do config + * e, se essa lista estiver ausente/vazia, recorre a {@link Constants#DEFAULT_BLOG_NOTIFICATION_LINES} + * para nunca enviar uma notificação em branco. + * + * @return a quantidade de linhas efetivamente enviadas + */ + public static int broadcast(Plugin plugin, BlogPost post) { + Map placeholders = buildPlaceholders(post); + + List lines = plugin.getConfig().getStringList("blog.notification.lines"); + if (lines == null || lines.isEmpty()) { + lines = Constants.DEFAULT_BLOG_NOTIFICATION_LINES; + plugin.getLogger().warning("[Blog] 'blog.notification.lines' ausente no config — usando layout padrão."); + } + + for (String line : lines) { + Component component = MessageFormatter.parse(line, placeholders); + Bukkit.getServer().broadcast(component); + } + return lines.size(); + } +} diff --git a/src/main/java/plugin/centralCartTopPlugin/util/Constants.java b/src/main/java/plugin/centralCartTopPlugin/util/Constants.java index 836f37f..fdb41c0 100644 --- a/src/main/java/plugin/centralCartTopPlugin/util/Constants.java +++ b/src/main/java/plugin/centralCartTopPlugin/util/Constants.java @@ -1,5 +1,7 @@ package plugin.centralCartTopPlugin.util; +import java.util.List; + /** * Constantes do plugin */ @@ -42,11 +44,25 @@ private Constants() { public static final long DEFAULT_CACHE_DURATION_MINUTES = 30; public static final String DEFAULT_CURRENCY_SYMBOL = "R$"; public static final String PLACEHOLDER_TOKEN = "COLOQUE_SEU_TOKEN_AQUI"; + public static final String PLACEHOLDER_STORE_DOMAIN = "COLOQUE_SEU_DOMINIO_AQUI"; // Blog public static final long BLOG_CHECK_INTERVAL_TICKS = TICKS_PER_MINUTE * 5; // 6000 ticks (5 minutos) public static final String BLOG_API_URL = "https://api.centralcart.com.br/v1/webstore/post"; + /** + * Linhas de notificação usadas como fallback quando {@code blog.notification.lines} + * está ausente no config (ex.: servidores cujo config.yml foi gerado antes da feature de blog). + */ + public static final List DEFAULT_BLOG_NOTIFICATION_LINES = List.of( + "&6&l========================================", + " &f📢 Nova Postagem no Blog!", + " Uma nova postagem foi feita no site!", + " ➡️ Clique aqui para ler", + " 🕒 {time} - {title}", + "&6&l========================================" + ); + // Prefixos de log public static final String LOG_PREFIX = "[CentralCartTopPlugin]"; public static final String CACHE_PREFIX = "[Cache]"; diff --git a/src/main/java/plugin/centralCartTopPlugin/util/DateTimeUtil.java b/src/main/java/plugin/centralCartTopPlugin/util/DateTimeUtil.java new file mode 100644 index 0000000..7346dbb --- /dev/null +++ b/src/main/java/plugin/centralCartTopPlugin/util/DateTimeUtil.java @@ -0,0 +1,58 @@ +package plugin.centralCartTopPlugin.util; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +/** + * Centraliza o parsing e a formatação das datas vindas da API CentralCart. + * + *

A API retorna {@code created_at} no formato {@link DateTimeFormatter#ISO_OFFSET_DATE_TIME} + * (ex.: {@code 2026-06-21T23:26:58.000-03:00}). Mantemos parsers legados como fallback para + * tolerar variações de formato entre versões da API. + */ +public final class DateTimeUtil { + + private DateTimeUtil() { + throw new UnsupportedOperationException("Utility class"); + } + + private static final DateTimeFormatter[] LEGACY_PARSERS = { + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"), + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + }; + + public static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm"); + public static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + + /** + * Tenta converter a string de data da API em {@link LocalDateTime}. + * + * @param raw valor bruto de {@code created_at} (pode ser nulo) + * @return o {@link LocalDateTime} correspondente ou {@code null} se nenhum formato casar + */ + public static LocalDateTime tryParse(String raw) { + if (raw == null || raw.isEmpty()) { + return null; + } + + // Formato principal atual da API: ISO com offset (-03:00) + try { + return OffsetDateTime.parse(raw, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime(); + } catch (DateTimeParseException ignored) { + // segue para os formatos legados + } + + for (DateTimeFormatter fmt : LEGACY_PARSERS) { + try { + return LocalDateTime.parse(raw, fmt); + } catch (DateTimeParseException ignored) { + // tenta o próximo formato + } + } + return null; + } +} diff --git a/src/main/java/plugin/centralCartTopPlugin/util/PluginUtils.java b/src/main/java/plugin/centralCartTopPlugin/util/PluginUtils.java index 50c44e7..5621137 100644 --- a/src/main/java/plugin/centralCartTopPlugin/util/PluginUtils.java +++ b/src/main/java/plugin/centralCartTopPlugin/util/PluginUtils.java @@ -1,5 +1,12 @@ package plugin.centralCartTopPlugin.util; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.nio.charset.StandardCharsets; + import plugin.centralCartTopPlugin.manager.MessagesManager; /** @@ -12,6 +19,57 @@ private PluginUtils() { throw new UnsupportedOperationException("Utility class"); } + /** + * Normaliza o domínio da loja aceitando tanto a URL completa + * (ex.: {@code https://loja.austv.net/}) quanto apenas o host ({@code loja.austv.net}). + * + * @param raw valor bruto vindo do config (pode ser nulo) + * @return o host sem protocolo nem barras finais, ou string vazia se nulo + */ + public static String normalizeStoreDomain(String raw) { + if (raw == null) { + return ""; + } + return raw.trim() + .replaceAll("^https?://", "") + .replaceAll("/+$", ""); + } + + /** + * Indica se o domínio da loja foi de fato configurado (não vazio e não placeholder). + * + * @param domain domínio já normalizado + * @return true se utilizável em uma requisição + */ + public static boolean isStoreDomainConfigured(String domain) { + return domain != null + && !domain.isEmpty() + && !domain.equalsIgnoreCase(Constants.PLACEHOLDER_STORE_DOMAIN); + } + + /** + * Lê o corpo do {@link HttpURLConnection#getErrorStream()} para diagnóstico de respostas != 200. + * + * @param connection conexão já com responseCode obtido + * @return corpo do erro como texto, ou string vazia se indisponível + */ + public static String readErrorBody(HttpURLConnection connection) { + InputStream errStream = connection.getErrorStream(); + if (errStream == null) { + return ""; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(errStream, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } catch (IOException ignored) { + return ""; + } + } + /** * Converte posição para chave de configuração * diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0c9d25f..837c6f5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -10,8 +10,10 @@ api: # IMPORTANTE: Nunca compartilhe este token publicamente! # Obtenha seu token em: https://centralcart.com.br token: "COLOQUE_SEU_TOKEN_AQUI" - # Domínio da sua loja (usado no header x-store-domain das APIs de blog/webstore) - # Exemplo: loja.austv.net ou sualoja.centralcart.com.br + # Domínio da sua loja — enviado no header x-store-domain em TODAS as chamadas + # (top doadores/NPCs e posts do blog). A API é multi-tenant: SEM este domínio ela + # responde 404 "Store not found" e nem os NPCs nem o broadcast de blog funcionam. + # Aceita o host (loja.austv.net) ou a URL completa (https://loja.austv.net/). store_domain: "COLOQUE_SEU_DOMINIO_AQUI" # Mensagens personalizáveis diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index cbef6aa..4e3151a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: centralCartTopPlugin -version: '1.0' +version: '${version}' main: plugin.centralCartTopPlugin.CentralCartTopPlugin api-version: '1.21' author: CentralCart From 019cf9a3ebcc72323cbdc97e764313136aecd1e0 Mon Sep 17 00:00:00 2001 From: ZzPowerTechzZ Date: Mon, 22 Jun 2026 12:31:07 -0300 Subject: [PATCH 2/2] fix: resolver avisos do Qodana no escopo do PR (null-checks redundantes e NPE em setExecutor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerCommands: helper registerCommand() com null-check em getCommand() elimina os 10 avisos de NPE em setExecutor e loga se um comando faltar no plugin.yml - BlogPostCheckTask / BlogNotifier: remove condições 'x == null' sempre falsas (getString com default e getStringList nunca retornam null no Bukkit) Co-Authored-By: Claude Opus 4.8 --- .../CentralCartTopPlugin.java | 35 +++++++++++++------ .../task/BlogPostCheckTask.java | 2 +- .../util/BlogNotifier.java | 2 +- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java b/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java index dc5acc6..b884cf2 100644 --- a/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java +++ b/src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java @@ -1,6 +1,8 @@ package plugin.centralCartTopPlugin; import org.bukkit.Bukkit; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; import plugin.centralCartTopPlugin.command.CacheInfoCommand; import plugin.centralCartTopPlugin.command.MessagesCommand; @@ -166,16 +168,29 @@ private void initializeServices() { * Registra todos os comandos do plugin */ private void registerCommands() { - getCommand("topdonadores").setExecutor(new TopDonadoresCommand(this)); - getCommand("spawntopnpcs").setExecutor(new SpawnTopNpcsCommand(this, apiService, npcManager)); - getCommand("removetopnpcs").setExecutor(new RemoveTopNpcsCommand(this, npcManager)); - getCommand("centralcartreload").setExecutor(new ReloadCommand(this)); - getCommand("testschedule").setExecutor(new TestScheduleCommand(this)); - getCommand("scheduleinfo").setExecutor(new ScheduleInfoCommand(this)); - getCommand("testrewards").setExecutor(new TestRewardsCommand(this)); - getCommand("cacheinfo").setExecutor(new CacheInfoCommand(this)); - getCommand("messages").setExecutor(new MessagesCommand(this)); - getCommand("testblogpost").setExecutor(new TestBlogPostCommand(this)); + registerCommand("topdonadores", new TopDonadoresCommand(this)); + registerCommand("spawntopnpcs", new SpawnTopNpcsCommand(this, apiService, npcManager)); + registerCommand("removetopnpcs", new RemoveTopNpcsCommand(this, npcManager)); + registerCommand("centralcartreload", new ReloadCommand(this)); + registerCommand("testschedule", new TestScheduleCommand(this)); + registerCommand("scheduleinfo", new ScheduleInfoCommand(this)); + registerCommand("testrewards", new TestRewardsCommand(this)); + registerCommand("cacheinfo", new CacheInfoCommand(this)); + registerCommand("messages", new MessagesCommand(this)); + registerCommand("testblogpost", new TestBlogPostCommand(this)); + } + + /** + * Registra um executor para um comando declarado no plugin.yml. Se o comando não existir + * (typo no nome ou ausente do plugin.yml), loga um aviso em vez de estourar NPE. + */ + private void registerCommand(String name, CommandExecutor executor) { + PluginCommand command = getCommand(name); + if (command != null) { + command.setExecutor(executor); + } else { + getLogger().warning("Comando '" + name + "' não encontrado no plugin.yml — não foi registrado."); + } } /** diff --git a/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java b/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java index 0ba1707..017cfde 100644 --- a/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java +++ b/src/main/java/plugin/centralCartTopPlugin/task/BlogPostCheckTask.java @@ -49,7 +49,7 @@ public void run() { String lastSeen = plugin.getConfig().getString("blog.last_seen_post_id", ""); // Primeira execução / estado limpo: faz seeding sem anunciar. - if (lastSeen == null || lastSeen.isEmpty()) { + if (lastSeen.isEmpty()) { Bukkit.getScheduler().runTask(plugin, () -> { plugin.getConfig().set("blog.last_seen_post_id", newest.getId()); plugin.saveConfig(); diff --git a/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java b/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java index b0133c8..214eb6e 100644 --- a/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java +++ b/src/main/java/plugin/centralCartTopPlugin/util/BlogNotifier.java @@ -65,7 +65,7 @@ public static int broadcast(Plugin plugin, BlogPost post) { Map placeholders = buildPlaceholders(post); List lines = plugin.getConfig().getStringList("blog.notification.lines"); - if (lines == null || lines.isEmpty()) { + if (lines.isEmpty()) { lines = Constants.DEFAULT_BLOG_NOTIFICATION_LINES; plugin.getLogger().warning("[Blog] 'blog.notification.lines' ausente no config — usando layout padrão."); }