From fdf4c94811d086996447bb4657fac1d9bd6e5a93 Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Tue, 23 Jun 2026 21:23:41 +0200 Subject: [PATCH] fix(official-bots): resolve per-difficulty bot token on tournament join joinTournament only ever had a token for the startup difficulty (default medium); other difficulties fell back to the single shared TOURNAMENT_BOT_TOKEN, which our tournament server rejects (401), surfacing as 400 "Failed to join tournament" in the UI. Resolve and cache a token for the requested difficulty instead. Prefer the account-service token over anonymous register in resolveToken so the bot joins as its canonical identity rather than a throwaway account (medium joined but never appeared as a participant). Add NativeReflectionConfig for JoinTournamentRequest/Response so the success path serializes in native image instead of returning an empty 200 body. Co-Authored-By: Claude Opus 4.8 --- .../bot/config/NativeReflectionConfig.scala | 12 +++ .../bot/service/TournamentBotGamePlayer.scala | 73 ++++++++++++------- 2 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 modules/official-bots/src/main/scala/de/nowchess/bot/config/NativeReflectionConfig.scala diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/NativeReflectionConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/NativeReflectionConfig.scala new file mode 100644 index 0000000..0e5c2c8 --- /dev/null +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/NativeReflectionConfig.scala @@ -0,0 +1,12 @@ +package de.nowchess.bot.config + +import de.nowchess.bot.resource.{JoinTournamentRequest, JoinTournamentResponse} +import io.quarkus.runtime.annotations.RegisterForReflection + +@RegisterForReflection( + targets = Array( + classOf[JoinTournamentRequest], + classOf[JoinTournamentResponse], + ), +) +class NativeReflectionConfig diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala index b0beea8..1430f6d 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala @@ -28,10 +28,10 @@ class TournamentBotGamePlayer: private val log = Logger.getLogger(classOf[TournamentBotGamePlayer]) // scalafix:off DisableSyntax.var - @Inject var objectMapper: ObjectMapper = uninitialized - @Inject var botController: BotController = uninitialized - @Inject var redis: RedisDataSource = uninitialized - @Inject var redisConfig: RedisConfig = uninitialized + @Inject var objectMapper: ObjectMapper = uninitialized + @Inject var botController: BotController = uninitialized + @Inject var redis: RedisDataSource = uninitialized + @Inject var redisConfig: RedisConfig = uninitialized @Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized // scalafix:on DisableSyntax.var @@ -119,7 +119,9 @@ class TournamentBotGamePlayer: yield result private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] = - pairings.elements().asScala + pairings + .elements() + .asScala .flatMap { p => val whiteId = p.path("white").path("id").asText() val blackId = p.path("black").path("id").asText() @@ -129,7 +131,9 @@ class TournamentBotGamePlayer: .nextOption() private def activeMatch(matches: JsonNode): Option[String] = - matches.elements().asScala + matches + .elements() + .asScala .find(m => m.path("gameId").asText().nonEmpty && !(m.has("outcome") && !m.path("outcome").isNull)) .map(_.path("gameId").asText()) @@ -151,9 +155,12 @@ class TournamentBotGamePlayer: private def openTournaments(): List[String] = Try { - val response = client.target(autoJoinServerUrl) - .path("api").path("tournament") - .request(MediaType.APPLICATION_JSON).get() + val response = client + .target(autoJoinServerUrl) + .path("api") + .path("tournament") + .request(MediaType.APPLICATION_JSON) + .get() if response.getStatus == 200 then val node = objectMapper.readTree(response.readEntity(classOf[String])) response.close() @@ -164,8 +171,8 @@ class TournamentBotGamePlayer: private def resolveToken(difficulty: String): Option[String] = val name = botName(difficulty) val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name" - registerWithServer(tournamentServiceUrl, name) - .orElse(fetchTokenFromAccountService(name)) + fetchTokenFromAccountService(name) + .orElse(registerWithServer(tournamentServiceUrl, name)) .map { token => redis.value(classOf[String]).set(redisKey, token) log.infof("Refreshed bot token for %s — stored in Redis", name) @@ -187,8 +194,11 @@ class TournamentBotGamePlayer: private def registerWithServer(serverUrl: String, name: String): Option[String] = Try { val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}""" - val response = client.target(serverUrl) - .path("api").path("auth").path("register") + val response = client + .target(serverUrl) + .path("api") + .path("auth") + .path("register") .request(MediaType.APPLICATION_JSON) .post(Entity.entity(body, MediaType.APPLICATION_JSON)) val status = response.getStatus @@ -201,11 +211,11 @@ class TournamentBotGamePlayer: log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody) response.close() None - }.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None } - .toOption.flatten + }.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None }.toOption.flatten private def fetchTokenFromAccountService(name: String): Option[String] = - Try(accountServiceClient.getBotToken(name).token).toOption.filter(_.nonEmpty) + Try(accountServiceClient.getBotToken(name).token).toOption + .filter(_.nonEmpty) .orElse { Try { val allNames = BotController.listBots.map(botName) @@ -231,9 +241,13 @@ class TournamentBotGamePlayer: private def fetchRemoteServers(): List[String] = Try { - val response = client.target(tournamentServiceUrl) - .path("api").path("tournament").path("servers") - .request(MediaType.APPLICATION_JSON).get() + val response = client + .target(tournamentServiceUrl) + .path("api") + .path("tournament") + .path("servers") + .request(MediaType.APPLICATION_JSON) + .get() if response.getStatus == 200 then val node = objectMapper.readTree(response.readEntity(classOf[String])) response.close() @@ -244,7 +258,11 @@ class TournamentBotGamePlayer: private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit = Try { val body = s"""{"name":"${botName(difficulty)}"}""" - val response = client.target(serverUrl).path("api").path("account").path("bots") + val response = client + .target(serverUrl) + .path("api") + .path("account") + .path("bots") .request(MediaType.APPLICATION_JSON) .header("Authorization", s"Bearer $token") .post(Entity.entity(body, MediaType.APPLICATION_JSON)) @@ -258,7 +276,10 @@ class TournamentBotGamePlayer: private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit = Try { val body = s"""{"name":"${name.replace("\"", "\\\"")}"}""" - val response = client.target(serverUrl).path("api").path("bots") + val response = client + .target(serverUrl) + .path("api") + .path("bots") .request(MediaType.APPLICATION_JSON) .header("Authorization", s"Bearer $token") .post(Entity.entity(body, MediaType.APPLICATION_JSON)) @@ -276,10 +297,11 @@ class TournamentBotGamePlayer: botToken: Option[String], difficulty: String, ): Either[String, String] = - val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}" - val resolvedToken = botToken.filter(_.nonEmpty) + val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}" + val resolvedToken = botToken + .filter(_.nonEmpty) .orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty)) - .orElse(System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)) + .orElse(resolveToken(difficulty)) resolvedToken match case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured") case Some(token) => @@ -336,8 +358,7 @@ class TournamentBotGamePlayer: log.infof("Listening to tournament %s event stream", cfg.tournamentId) forEachLine(response.readEntity(classOf[InputStream])): line => parse(line).foreach: node => - if node.path("type").asText() == "gameStart" then - onGameStart(cfg, node.path("gameId").asText()) + if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText()) private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit = if gameId.isEmpty then ()