skills/ktgbotapi-patterns/SKILL.md
Telegram bot architecture patterns (ktgbotapi 33.x) — project structure, modular handlers, DI via Metro (standalone) or Spring (embedded in backend), callback models, keyboards, utils. Always use the versions listed below; never regress to ktgbotapi 31.x. Koin is no longer recommended — use Metro for compile-time safety.
npx skillsauth add andvl1/claude-plugin ktgbotapi-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Patterns for organizing Telegram bot projects with ktgbotapi.
| Component | Version | Notes |
|---|---|---|
| ktgbotapi | 33.1.0 | See ktgbotapi skill for v32→v33 breaking changes (BotToken value class, Unit return types, Poll API). |
| Metro | 1.0.0 | Default DI for standalone bots — compile-time graph, no runtime crashes. See metro-di-mobile skill. |
| Spring Boot | 3.4.x | Use Spring DI when bot lives inside a full backend (sharing services, DB, observability). See kotlin-spring-boot skill. |
| Ktor client | 3.4.3 | See ktor-client skill for client patterns. |
| Kotlin | 2.3.21 | |
Koin is intentionally not in this skill anymore. Earlier revisions documented Koin 4.x as the default. We removed it because: (1) compile-time DI catches missing bindings before deploy — Telegram bots can run for weeks without hitting a code path, so a runtime DI miss is found by users, not CI; (2) for monostack setups (mobile + bot + backend on Metro), one DI framework everywhere wins over two; (3) when the bot is part of a Spring backend, Spring DI is already there. If a project genuinely needs Koin (KMP bot sharing modules with a Koin-driven mobile app, or hot-reload module swapping), apply Koin manually — but don't take it from this skill.
src/main/kotlin/com/example/bot/
├── Application.kt # Entry point
├── di/
│ ├── BotGraph.kt # Metro @DependencyGraph (standalone)
│ └── BotPlatformModule.kt # @BindingContainer with @Provides for HttpClient, Json, env config
├── config/
│ └── BotConfig.kt # Bot configuration value class
├── handlers/
│ ├── CommandHandlers.kt # /start, /help, etc.
│ ├── MessageHandlers.kt # Text message handlers
│ ├── CallbackHandlers.kt # Inline button callbacks
│ └── MediaHandlers.kt # Photo, document, etc.
├── keyboards/
│ ├── InlineKeyboards.kt # Inline keyboard builders
│ └── ReplyKeyboards.kt # Reply keyboard builders
├── fsm/
│ ├── States.kt # FSM state definitions
│ └── StateHandlers.kt # State transition handlers
├── api/
│ ├── BackendApiService.kt # HTTP calls to backend (see ktor-client skill)
│ └── ApiModels.kt # Request/Response DTOs
├── services/
│ ├── UserService.kt # Business logic
│ └── NotificationService.kt
├── models/
│ ├── User.kt # Domain models
│ └── CallbackData.kt # Callback payload models
└── utils/
├── Extensions.kt # Useful extensions
└── Formatters.kt # Text formatting helpers
Note: For backend API communication patterns, see the
ktor-clientskill. Embedded-in-Spring deployment uses Spring's project structure (@Service,@Configuration) instead ofdi/— see "DI: Spring Boot variant" below.
// Application.kt — standalone bot with Metro
suspend fun main() {
val graph = createGraph<BotGraph>()
val bot = graph.telegramBot
bot.buildBehaviourWithLongPolling(
defaultExceptionsHandler = { logger.error("Bot error", it) }
) {
with(graph.commandHandlers) { register() }
with(graph.messageHandlers) { register() }
with(graph.callbackHandlers) { register() }
with(graph.mediaHandlers) { register() }
}.join()
}
Each handler class exposes suspend fun BehaviourContext.register() as an extension on its enclosing class — see "Modular Handler Pattern" below.
Handlers are classes with constructor-injected dependencies. They expose register() as an extension on BehaviourContext, which keeps the DSL receiver while still letting the handler hold injected services.
// handlers/CommandHandlers.kt
@Inject
class CommandHandlers(
private val userService: UserService,
) {
suspend fun BehaviourContext.register() {
onCommand("start") { message ->
reply(message, "Welcome!", replyMarkup = ReplyKeyboards.main())
}
onCommand("help") { message ->
reply(message, HelpTexts.commands())
}
onCommand("profile") { message ->
val user = userService.findByChatId(message.chat.id.chatId)
reply(message, user?.let { "Name: ${it.name}" } ?: "Not registered")
}
onDeepLink { message, deepLink -> handleDeepLink(message, deepLink) }
}
}
// handlers/MessageHandlers.kt
@Inject
class MessageHandlers {
suspend fun BehaviourContext.register() {
onText(initialFilter = { it.content.text == "📋 Menu" }) { showMenu(it) }
onText(initialFilter = { it.content.text == "⚙️ Settings" }) { showSettings(it) }
}
}
// handlers/CallbackHandlers.kt
@Inject
class CallbackHandlers(
private val api: BackendApiService,
) {
suspend fun BehaviourContext.register() {
onDataCallbackQuery(Regex("menu:.*")) { handleMenuCallback(it) }
onDataCallbackQuery(Regex("item:.*")) { handleItemCallback(it, api) }
onDataCallbackQuery(Regex("page:.*")) { handlePaginationCallback(it) }
}
}
Why extension on
BehaviourContextinside a class? ktgbotapi's DSL (onCommand,onText, ...) requiresBehaviourContextas receiver. Free-standing extension functions can't carry constructor state, so we put the extension inside the class —register()is a member extension, the class holds dependencies, and the DSL receiver flows in at the call site (with(graph.commandHandlers) { register() }).
Type-safe callback data parsing:
// models/CallbackData.kt
sealed class CallbackData {
abstract fun encode(): String
// Menu actions
data class Menu(val action: String) : CallbackData() {
override fun encode() = "m:$action"
}
// Item operations
data class Item(val action: String, val id: String) : CallbackData() {
override fun encode() = "i:$action:$id"
}
// Pagination
data class Page(val list: String, val page: Int) : CallbackData() {
override fun encode() = "p:$list:$page"
}
// Confirmation
data class Confirm(val action: String, val id: String) : CallbackData() {
override fun encode() = "c:$action:$id"
}
companion object {
fun parse(data: String): CallbackData? {
val parts = data.split(":")
return when (parts.getOrNull(0)) {
"m" -> Menu(parts[1])
"i" -> Item(parts[1], parts[2])
"p" -> Page(parts[1], parts[2].toInt())
"c" -> Confirm(parts[1], parts[2])
else -> null
}
}
}
}
// Usage in handlers
suspend fun BehaviourContext.setupCallbackHandlers() {
onDataCallbackQuery { query ->
when (val cb = CallbackData.parse(query.data)) {
is CallbackData.Menu -> handleMenu(query, cb.action)
is CallbackData.Item -> handleItem(query, cb.action, cb.id)
is CallbackData.Page -> handlePage(query, cb.list, cb.page)
is CallbackData.Confirm -> handleConfirm(query, cb.action, cb.id)
null -> answer(query, "Unknown action")
}
}
}
// Usage in keyboard builders
fun itemKeyboard(itemId: String) = inlineKeyboard {
row {
dataButton("✏️ Edit", CallbackData.Item("edit", itemId).encode())
dataButton("🗑 Delete", CallbackData.Item("delete", itemId).encode())
}
}
// keyboards/InlineKeyboards.kt
object InlineKeyboards {
fun mainMenu() = inlineKeyboard {
row { dataButton("📊 Statistics", "m:stats") }
row {
dataButton("👤 Profile", "m:profile")
dataButton("⚙️ Settings", "m:settings")
}
row { urlButton("📖 Help", "https://example.com/help") }
}
fun confirmation(action: String, id: String) = inlineKeyboard {
row {
dataButton("✅ Confirm", "c:$action:$id")
dataButton("❌ Cancel", "c:cancel:$id")
}
}
fun pagination(list: String, current: Int, total: Int) = inlineKeyboard {
row {
if (current > 1) dataButton("◀️", "p:$list:${current - 1}")
dataButton("$current / $total", "p:$list:$current")
if (current < total) dataButton("▶️", "p:$list:${current + 1}")
}
}
fun itemActions(id: String) = inlineKeyboard {
row {
dataButton("✏️ Edit", "i:edit:$id")
dataButton("🗑 Delete", "i:delete:$id")
}
row { dataButton("◀️ Back", "m:back") }
}
fun backButton(target: String = "back") = inlineKeyboard {
row { dataButton("◀️ Back", "m:$target") }
}
}
// keyboards/ReplyKeyboards.kt
object ReplyKeyboards {
fun main() = replyKeyboard(resizeKeyboard = true) {
row {
simpleButton("📋 Menu")
simpleButton("⚙️ Settings")
}
row { simpleButton("❓ Help") }
}
fun cancel() = replyKeyboard(resizeKeyboard = true, oneTimeKeyboard = true) {
row { simpleButton("❌ Cancel") }
}
fun yesNo() = replyKeyboard(resizeKeyboard = true, oneTimeKeyboard = true) {
row {
simpleButton("✅ Yes")
simpleButton("❌ No")
}
}
fun phoneRequest() = replyKeyboard(resizeKeyboard = true) {
row { requestContactButton("📱 Share Phone") }
row { simpleButton("❌ Cancel") }
}
fun remove() = ReplyKeyboardRemove()
}
// fsm/States.kt
sealed interface BotState : State {
override val context: IdChatIdentifier
// Registration flow
data class AwaitingName(override val context: IdChatIdentifier) : BotState
data class AwaitingEmail(override val context: IdChatIdentifier, val name: String) : BotState
data class AwaitingConfirmation(
override val context: IdChatIdentifier,
val name: String,
val email: String
) : BotState
// Feedback flow
data class AwaitingFeedback(override val context: IdChatIdentifier) : BotState
data class AwaitingRating(override val context: IdChatIdentifier, val feedback: String) : BotState
}
// fsm/StateHandlers.kt
suspend fun BehaviourContextWithFSM<BotState>.setupRegistrationFlow() {
onCommand("register") { startChain(BotState.AwaitingName(it.chat.id)) }
strictlyOn<BotState.AwaitingName> { state ->
send(state.context, "Enter your name:", replyMarkup = ReplyKeyboards.cancel())
val response = waitTextOrCancel(state.context) ?: return@strictlyOn null
if (response.length < 2) {
send(state.context, "Name too short. Try again:")
return@strictlyOn state
}
BotState.AwaitingEmail(state.context, response)
}
strictlyOn<BotState.AwaitingEmail> { state ->
send(state.context, "Enter your email:")
val response = waitTextOrCancel(state.context) ?: return@strictlyOn null
if (!response.contains("@")) {
send(state.context, "Invalid email. Try again:")
return@strictlyOn state
}
BotState.AwaitingConfirmation(state.context, state.name, response)
}
strictlyOn<BotState.AwaitingConfirmation> { state ->
send(state.context, buildEntities {
+"Confirm registration:\n\n"
bold("Name: ") + state.name + "\n"
bold("Email: ") + state.email
}, replyMarkup = ReplyKeyboards.yesNo())
val response = waitTextOrCancel(state.context) ?: return@strictlyOn null
when (response) {
"✅ Yes" -> {
userService.register(state.name, state.email)
send(state.context, "✅ Registered!", replyMarkup = ReplyKeyboards.main())
}
else -> send(state.context, "❌ Cancelled", replyMarkup = ReplyKeyboards.main())
}
null
}
}
// Helper function
private suspend fun BehaviourContextWithFSM<BotState>.waitTextOrCancel(
chatId: IdChatIdentifier
): String? {
val message = waitText { it.chat.id == chatId }.first()
return if (message.content.text == "❌ Cancel") {
send(chatId, "Cancelled", replyMarkup = ReplyKeyboards.main())
null
} else {
message.content.text
}
}
| Deployment | DI choice | |---|---| | Standalone bot (own JVM process, just bot logic + HTTP client) | Metro | | Bot is part of a Spring Boot backend (shares DB, services, observability with REST/gRPC layer) | Spring DI | | Bot is part of a KMP monostack with mobile + web on Metro | Metro (single DI everywhere) | | Bot is a Ktor server with embedded handlers | Metro |
The discriminator is "is there already a DI container running in this process?". If yes (Spring), use it. If no, use Metro.
// di/BotPlatformModule.kt
@BindingContainer
object BotPlatformModule {
@Provides
fun provideBotConfig(): BotConfig = BotConfig(
token = System.getenv("BOT_TOKEN") ?: error("BOT_TOKEN not set"),
backendUrl = System.getenv("BACKEND_URL") ?: error("BACKEND_URL not set"),
apiKey = System.getenv("API_KEY") ?: error("API_KEY not set"),
)
@Provides
fun provideTelegramBot(config: BotConfig): TelegramBot = telegramBot(config.token)
@Provides
fun provideJson(): Json = Json { ignoreUnknownKeys = true }
@Provides
fun provideHttpClient(config: BotConfig, json: Json): HttpClient = HttpClient(CIO) {
install(ContentNegotiation) { json(json) }
install(HttpTimeout) { requestTimeoutMillis = 30_000 }
defaultRequest {
url(config.backendUrl)
header("X-API-Key", config.apiKey)
}
}
}
// di/BotGraph.kt
@DependencyGraph(bindingContainers = [BotPlatformModule::class])
interface BotGraph {
val telegramBot: TelegramBot
val commandHandlers: CommandHandlers
val messageHandlers: MessageHandlers
val callbackHandlers: CallbackHandlers
val mediaHandlers: MediaHandlers
}
// Services and handlers carry @Inject — Metro auto-wires:
@Inject class UserService(private val api: BackendApiService)
@Inject class BackendApiService(private val httpClient: HttpClient, private val json: Json)
// CommandHandlers, etc. — see "Handler Modules" above
createGraph<BotGraph>() is called once in main(). The graph instance is process-wide; the bot lives until SIGTERM. No graph teardown needed unless you have per-update scopes (which you usually don't — handlers are stateless).
Per-message scope? Telegram updates are inherently stateless from the bot's perspective. If you need per-update context (request ID, user-scoped logger), pass it through the DSL closure or
BehaviourContextextensions — don't reach for@AssistedInjectunless you have a real reason. Most bots never need it.
When the bot lives inside a full Spring Boot service (sharing JPA repositories, business services, Micrometer metrics, the same application.yml), use Spring DI directly. No Metro on top.
// config/TelegramBotConfig.kt
@ConfigurationProperties(prefix = "telegram")
data class TelegramProperties(
val token: String,
val adminIds: List<Long> = emptyList(),
)
@Configuration
@EnableConfigurationProperties(TelegramProperties::class)
class TelegramBotConfig {
@Bean
fun telegramBot(props: TelegramProperties): TelegramBot = telegramBot(props.token)
}
// handlers/CommandHandlers.kt
@Component
class CommandHandlers(
private val userService: UserService, // @Service from main backend
private val orderRepository: OrderRepository, // @Repository from JPA layer
) {
suspend fun BehaviourContext.register() {
onCommand("start") { /* ... */ }
onCommand("orders") { message ->
val orders = orderRepository.findRecent(message.chat.id.chatId)
reply(message, formatOrders(orders))
}
}
}
// BotService.kt — owns bot lifecycle as a Spring bean
@Service
class BotService(
private val bot: TelegramBot,
private val commandHandlers: CommandHandlers,
private val callbackHandlers: CallbackHandlers,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@EventListener(ApplicationReadyEvent::class)
fun onReady() {
scope.launch {
bot.buildBehaviourWithLongPolling {
with(commandHandlers) { register() }
with(callbackHandlers) { register() }
}.join()
}
}
@PreDestroy
fun stop() = scope.cancel()
}
Why
ApplicationReadyEvent, not@PostConstruct?@PostConstructfires while context still wiring — Flyway migrations may not have run, JPA EntityManagerFactory may not be ready, other beans uninitialized. First incoming Telegram update hits half-built app →LazyInitializationException, missing tables, NPEs from null-injected beans.ApplicationReadyEventfires after entireApplicationContextup: migrations done, all beans initialized,CommandLineRunners finished. Bot starts polling only when backend genuinely ready to serve.Why
Dispatchers.IO, notDispatchers.Default?Dispatchers.Defaultsized for CPU work (typicallynCputhreads). JPA/JDBC calls are blocking I/O —repository.save(...),entityManager.flush(), rawDataSource.getConnection()all park the thread. Handler doing 3 DB lookups blocks 3 of your ~8 CPU threads; under load Default pool starves and bot stops responding.Dispatchers.IOdesigned for blocking work — default 64 threads, grows on demand. For pure async (Ktor client, WebFlux, R2DBC) Default fine; moment JPA/JDBC enters picture, switch to IO.
Handlers receive BehaviourContext from DSL — but plenty of bot-relevant moments happen outside handler. OrderService.markShipped(orderId) called from REST controller needs to ping customer on Telegram. No BehaviourContext in scope, can't call reply(...).
Solution: thin facade bean wrapping TelegramBot.execute(...) directly (raw API, no DSL receiver needed).
// notifications/TelegramNotifier.kt
@Component
class TelegramNotifier(private val bot: TelegramBot) {
suspend fun notify(chatId: ChatId, text: String) {
bot.execute(SendTextMessage(chatId, text))
}
suspend fun notify(chatId: ChatId, text: String, replyMarkup: KeyboardMarkup) {
bot.execute(SendTextMessage(chatId, text, replyMarkup = replyMarkup))
}
}
// services/OrderService.kt — existing service, gains notification
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun markShipped(orderId: Long) {
val order = orderRepository.findById(orderId).orElseThrow()
order.status = OrderStatus.SHIPPED
orderRepository.save(order)
eventPublisher.publishEvent(OrderShippedEvent(orderId, order.userChatId))
}
}
data class OrderShippedEvent(val orderId: Long, val chatId: Long)
// notifications/OrderNotificationListener.kt
@Component
class OrderNotificationListener(
private val notifier: TelegramNotifier,
) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onShipped(event: OrderShippedEvent) = runBlocking {
notifier.notify(ChatId(event.chatId), "📦 Order #${event.orderId} shipped!")
}
}
Why
@TransactionalEventListener(AFTER_COMMIT), not direct call frommarkShipped? Direct call sends Telegram message before DB commit. Transaction rolls back (constraint violation, optimistic lock, app crash mid-method) → user already got "shipped!" but DB still says PENDING.AFTER_COMMITphase fires only after transaction successfully committed — message reflects real state. If commit fails, listener never runs, no false notification. Bonus: keepsOrderServiceagnostic of Telegram (publishes domain event, listener handles delivery channel).
runBlockingin listener acceptable here — listener invoked on caller thread after transaction, brief blocking onbot.execute(single HTTP call) won't hold DB connections. For high-volume notifications, replace with launch intoDispatchers.IOscope owned by listener bean.
This variant gives you transactions (@Transactional on services called from handlers), metrics (@Timed), shared connection pools, and unified logging out of the box. The price is the Spring runtime — don't pick this for a standalone bot.
For Spring patterns themselves, see kotlin-spring-boot and kotlin-spring-patterns skills.
// utils/Extensions.kt
// User display name
val CommonMessage<*>.userDisplayName: String
get() = chat.asPrivateChat()?.let {
listOfNotNull(it.firstName, it.lastName).joinToString(" ").ifEmpty { "User" }
} ?: "User"
// Safe callback answer
suspend fun BehaviourContext.safeAnswer(
query: CallbackQuery,
text: String? = null,
showAlert: Boolean = false
) = runCatching { answer(query, text, showAlert) }
// Edit or send new
suspend fun BehaviourContext.editOrSend(
query: DataCallbackQuery,
text: String,
replyMarkup: InlineKeyboardMarkup? = null
) {
runCatching {
edit(query.message!!, text, replyMarkup = replyMarkup)
}.onFailure {
send(query.message!!.chat, text, replyMarkup = replyMarkup)
}
}
// Chunked message sending
suspend fun BehaviourContext.sendLongMessage(
chatId: IdChatIdentifier,
text: String,
chunkSize: Int = 4000
) {
text.chunked(chunkSize).forEach { chunk ->
sendMessage(chatId, chunk)
delay(50)
}
}
// Admin check
suspend fun BehaviourContext.isAdmin(chatId: IdChatIdentifier, userId: UserId): Boolean {
return runCatching {
val member = getChatMember(chatId, userId)
member is Administrator || member is Creator
}.getOrDefault(false)
}
// utils/ErrorHandling.kt
class BotException(message: String, val userMessage: String = message) : Exception(message)
class ValidationException(message: String) : BotException(message)
class NotFoundException(message: String) : BotException(message, "Not found")
suspend fun BehaviourContext.withErrorHandling(
message: CommonMessage<*>,
block: suspend () -> Unit
) {
try {
block()
} catch (e: ValidationException) {
reply(message, "⚠️ ${e.userMessage}")
} catch (e: NotFoundException) {
reply(message, "❌ ${e.userMessage}")
} catch (e: BotException) {
reply(message, "❌ ${e.userMessage}")
} catch (e: Exception) {
logger.error("Unexpected error", e)
reply(message, "❌ Something went wrong")
}
}
// Usage
onCommand("action") { message ->
withErrorHandling(message) {
val result = service.performAction()
reply(message, "✅ Done: $result")
}
}
// utils/RateLimiter.kt
class RateLimiter(private val maxRequests: Int = 30) {
private val semaphore = Semaphore(maxRequests)
suspend fun <T> withLimit(block: suspend () -> T): T {
return semaphore.withPermit { block() }
}
}
// Broadcast helper
suspend fun BehaviourContext.broadcast(
userIds: List<Long>,
text: String,
rateLimiter: RateLimiter = RateLimiter(25)
): BroadcastResult {
var success = 0
var failed = 0
userIds.forEach { userId ->
rateLimiter.withLimit {
runCatching {
sendMessage(ChatId(userId), text)
success++
}.onFailure { failed++ }
}
}
return BroadcastResult(success, failed)
}
data class BroadcastResult(val success: Int, val failed: Int)
// Test with MockK
class CommandHandlersTest {
private val mockBot = mockk<TelegramBot>(relaxed = true)
@Test
fun `start command sends welcome`() = runTest {
val message = createTestMessage("/start")
coEvery { mockBot.execute(any<SendTextMessage>()) } returns mockk()
testBehaviourContext(mockBot) {
setupCommandHandlers()
// trigger handler
}
coVerify {
mockBot.execute(match<SendTextMessage> {
it.text.contains("Welcome")
})
}
}
}
// Test helper
suspend fun testBehaviourContext(
bot: TelegramBot,
block: suspend BehaviourContext.() -> Unit
) {
bot.buildBehaviour { block() }
}
| Skill | When to load |
|---|---|
| ktgbotapi | Always — base bot API (BotToken, behaviour DSL, message types). |
| metro-di-mobile | Standalone bot DI variant. |
| kotlin-spring-boot, kotlin-spring-patterns | Embedded-in-backend variant — service layer, transactions, observability. |
| ktor-client | All HTTP calls to backend / external APIs. |
testing
Android WorkManager for guaranteed background execution - use for deferred tasks, periodic syncs, file uploads, notifications, and task chains. Covers CoroutineWorker, constraints, chaining, testing, and troubleshooting. Use when implementing background work that needs reliable execution across app restarts and doze mode.
development
Telegram Mini Apps development - use for building Mini App frontend, WebApp API, initData authentication, and Telegram integration
tools
Systematic feature planning workflow - use when starting complex features requiring structured approach
development
React 18+ with Vite patterns - use for Mini App frontend development, component structure, hooks, and TypeScript setup