fix(official-bots): resolve per-difficulty bot token on tournament join
Build & Test (NowChessSystems) TeamCity build finished

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 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-23 21:23:41 +02:00
parent d9f30f0bfe
commit fdf4c94811
2 changed files with 59 additions and 26 deletions
@@ -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
@@ -28,10 +28,10 @@ class TournamentBotGamePlayer:
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer]) private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var botController: BotController = uninitialized
@Inject var redis: RedisDataSource = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized @Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -119,7 +119,9 @@ class TournamentBotGamePlayer:
yield result yield result
private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] = private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] =
pairings.elements().asScala pairings
.elements()
.asScala
.flatMap { p => .flatMap { p =>
val whiteId = p.path("white").path("id").asText() val whiteId = p.path("white").path("id").asText()
val blackId = p.path("black").path("id").asText() val blackId = p.path("black").path("id").asText()
@@ -129,7 +131,9 @@ class TournamentBotGamePlayer:
.nextOption() .nextOption()
private def activeMatch(matches: JsonNode): Option[String] = 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)) .find(m => m.path("gameId").asText().nonEmpty && !(m.has("outcome") && !m.path("outcome").isNull))
.map(_.path("gameId").asText()) .map(_.path("gameId").asText())
@@ -151,9 +155,12 @@ class TournamentBotGamePlayer:
private def openTournaments(): List[String] = private def openTournaments(): List[String] =
Try { Try {
val response = client.target(autoJoinServerUrl) val response = client
.path("api").path("tournament") .target(autoJoinServerUrl)
.request(MediaType.APPLICATION_JSON).get() .path("api")
.path("tournament")
.request(MediaType.APPLICATION_JSON)
.get()
if response.getStatus == 200 then if response.getStatus == 200 then
val node = objectMapper.readTree(response.readEntity(classOf[String])) val node = objectMapper.readTree(response.readEntity(classOf[String]))
response.close() response.close()
@@ -164,8 +171,8 @@ class TournamentBotGamePlayer:
private def resolveToken(difficulty: String): Option[String] = private def resolveToken(difficulty: String): Option[String] =
val name = botName(difficulty) val name = botName(difficulty)
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name" val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name"
registerWithServer(tournamentServiceUrl, name) fetchTokenFromAccountService(name)
.orElse(fetchTokenFromAccountService(name)) .orElse(registerWithServer(tournamentServiceUrl, name))
.map { token => .map { token =>
redis.value(classOf[String]).set(redisKey, token) redis.value(classOf[String]).set(redisKey, token)
log.infof("Refreshed bot token for %s — stored in Redis", name) 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] = private def registerWithServer(serverUrl: String, name: String): Option[String] =
Try { Try {
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}""" val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}"""
val response = client.target(serverUrl) val response = client
.path("api").path("auth").path("register") .target(serverUrl)
.path("api")
.path("auth")
.path("register")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
val status = response.getStatus val status = response.getStatus
@@ -201,11 +211,11 @@ class TournamentBotGamePlayer:
log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody) log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody)
response.close() response.close()
None None
}.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None } }.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None }.toOption.flatten
.toOption.flatten
private def fetchTokenFromAccountService(name: String): Option[String] = private def fetchTokenFromAccountService(name: String): Option[String] =
Try(accountServiceClient.getBotToken(name).token).toOption.filter(_.nonEmpty) Try(accountServiceClient.getBotToken(name).token).toOption
.filter(_.nonEmpty)
.orElse { .orElse {
Try { Try {
val allNames = BotController.listBots.map(botName) val allNames = BotController.listBots.map(botName)
@@ -231,9 +241,13 @@ class TournamentBotGamePlayer:
private def fetchRemoteServers(): List[String] = private def fetchRemoteServers(): List[String] =
Try { Try {
val response = client.target(tournamentServiceUrl) val response = client
.path("api").path("tournament").path("servers") .target(tournamentServiceUrl)
.request(MediaType.APPLICATION_JSON).get() .path("api")
.path("tournament")
.path("servers")
.request(MediaType.APPLICATION_JSON)
.get()
if response.getStatus == 200 then if response.getStatus == 200 then
val node = objectMapper.readTree(response.readEntity(classOf[String])) val node = objectMapper.readTree(response.readEntity(classOf[String]))
response.close() response.close()
@@ -244,7 +258,11 @@ class TournamentBotGamePlayer:
private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit = private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit =
Try { Try {
val body = s"""{"name":"${botName(difficulty)}"}""" 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) .request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer $token") .header("Authorization", s"Bearer $token")
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
@@ -258,7 +276,10 @@ class TournamentBotGamePlayer:
private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit = private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit =
Try { Try {
val body = s"""{"name":"${name.replace("\"", "\\\"")}"}""" 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) .request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer $token") .header("Authorization", s"Bearer $token")
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
@@ -276,10 +297,11 @@ class TournamentBotGamePlayer:
botToken: Option[String], botToken: Option[String],
difficulty: String, difficulty: String,
): Either[String, String] = ): Either[String, String] =
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}" val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
val resolvedToken = botToken.filter(_.nonEmpty) val resolvedToken = botToken
.filter(_.nonEmpty)
.orElse(Option(redis.value(classOf[String]).get(redisKey)).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 resolvedToken match
case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured") case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured")
case Some(token) => case Some(token) =>
@@ -336,8 +358,7 @@ class TournamentBotGamePlayer:
log.infof("Listening to tournament %s event stream", cfg.tournamentId) log.infof("Listening to tournament %s event stream", cfg.tournamentId)
forEachLine(response.readEntity(classOf[InputStream])): line => forEachLine(response.readEntity(classOf[InputStream])): line =>
parse(line).foreach: node => parse(line).foreach: node =>
if node.path("type").asText() == "gameStart" then if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
onGameStart(cfg, node.path("gameId").asText())
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit = private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
if gameId.isEmpty then () if gameId.isEmpty then ()