fix(official-bots): resolve per-difficulty bot token on tournament join
Build & Test (NowChessSystems) TeamCity build finished
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:
+12
@@ -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
|
||||||
+47
-26
@@ -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 ()
|
||||||
|
|||||||
Reference in New Issue
Block a user