feat(security): add internal secret handling and Redis integration for bot events

This commit is contained in:
2026-04-28 09:29:05 +02:00
parent c10a4d7e64
commit 1ab6532b0a
50 changed files with 951 additions and 214 deletions
@@ -1,26 +0,0 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
case class MoveRequest(
gameId: String,
fen: String,
turn: String,
playingAs: String,
difficulty: Int,
botAccountId: String,
)
object MoveRequestParser:
def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
scala.util.Try {
val node = mapper.readTree(json)
MoveRequest(
gameId = node.get("gameId").asText(),
fen = node.get("fen").asText(),
turn = node.get("turn").asText(),
playingAs = node.get("playingAs").asText(),
difficulty = node.get("difficulty").asInt(1400),
botAccountId = node.get("botAccountId").asText(),
)
}.toOption
@@ -2,7 +2,6 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.Bot
import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.config.RedisConfig
@@ -12,6 +11,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
@ApplicationScoped
@@ -24,35 +24,72 @@ class OfficialBotService:
@Inject var botController: BotController = uninitialized
// scalafix:on DisableSyntax.var
private val terminalStatuses =
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
def onStart(@Observes event: StartupEvent): Unit =
Thread.ofVirtual().start(() => runWorker())
BotController.listBots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events")
topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleBotEvent(botName, msg),
)
()
private def runWorker(): Unit =
val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
while true do
try
val json = queue.take()
MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
catch case _: InterruptedException => Thread.currentThread().interrupt()
private def handleBotEvent(botName: String, msg: String): Unit =
try
val node = objectMapper.readTree(msg)
if node.path("type").asText() == "gameStart" then
val gameId = node.path("gameId").asText()
val playingAs = node.path("playingAs").asText()
val difficulty = node.path("difficulty").asInt(1400)
val botAccountId = node.path("botAccountId").asText()
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
catch case _: Exception => ()
private def processRequest(req: MoveRequest): Unit =
val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
val botName = difficulty match
case BotDifficulty.Easy => "easy"
case BotDifficulty.Medium => "medium"
case BotDifficulty.Hard => "hard"
case BotDifficulty.Expert => "expert"
botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
)
()
private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
FenParser.parseFen(req.fen).toOption.foreach { context =>
bot(context).foreach { move =>
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
redisson.getTopic(c2sTopic).publish(moveMsg)
()
private def handleGameEvent(
botName: String,
gameId: String,
playingAs: String,
difficulty: Int,
botAccountId: String,
msg: String,
): Unit =
try
val node = objectMapper.readTree(msg)
val status = node.path("state").path("status").asText("")
if !terminalStatuses.contains(status) then
val turn = node.path("state").path("turn").asText("")
if turn == playingAs then
val fen = node.path("state").path("fen").asText()
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
catch case _: Exception => ()
private def computeAndSendMove(botName: String, gameId: String, fen: String, difficulty: Int, botAccountId: String): Unit =
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
FenParser.parseFen(fen).toOption.foreach { context =>
bot(context).foreach { move =>
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
redisson.getTopic(c2sTopic).publish(moveMsg)
()
}
}
}