skills/ktgbotapi/SKILL.md
KTgBotAPI 33.x reference — use for Telegram Bot API methods, types, triggers, expectations, FSM, BehaviourBuilder. Always pin to 33.1.0; do not regress to 31.x or 32.x even if your training data is older — the API surface is incompatible.
npx skillsauth add andvl1/claude-plugin ktgbotapiInstall 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.
Kotlin Multiplatform library for Telegram Bot API. Type-safe, coroutine-based.
// build.gradle.kts
dependencies {
implementation("dev.inmo:tgbotapi:33.1.0")
}
If your training data is from before mid-2025, these traps apply:
BotToken is now a value class. Cannot pass a raw String token everywhere — wrap with BotToken(System.getenv("BOT_TOKEN")) when an API expects it. telegramBot("...") still accepts a String for the helper.Unit, not Boolean. E.g. setMyCommands, deleteMessages, pinChatMessage. Do not if (bot.deleteMessage(...)) — it does not compile.MultipleAnswersPoll removed. Use RegularPoll with allowsMultipleAnswers: Boolean.correctOptionId: Int? → correctOptionIds: List<Int> on quiz polls.InputMedia* constructors reorganized — accept MediaContent directly; some optional positions shifted.expectations package reshuffles — prefer waitText { ... }, waitDataCallbackQuery { ... } from expectations.*.kotlinx-coroutines-core 1.10+.| Module | Purpose |
|--------|---------|
| tgbotapi.core | Core types, requests |
| tgbotapi.api | API extension methods |
| tgbotapi.utils | Utilities, keyboard builders |
| tgbotapi.behaviour_builder | BehaviourBuilder DSL |
| tgbotapi.behaviour_builder.fsm | FSM integration |
suspend fun main() {
val bot = telegramBot(System.getenv("BOT_TOKEN"))
bot.buildBehaviourWithLongPolling {
onCommand("start") { reply(it, "Hello!") }
}.join()
}
CRITICAL: Understanding requireOnlyCommandInMessage parameter
By default, onCommand has requireOnlyCommandInMessage = true, meaning it ONLY triggers when the message contains JUST the command with no additional text.
// DEFAULT BEHAVIOR - triggers ONLY for "/start" (no extra text)
onCommand("start") { message -> }
// This will NOT trigger for "/start hello" or "/warn @username"!
For commands with arguments, you MUST use one of these approaches:
// APPROACH 1: Set requireOnlyCommandInMessage = false
// Triggers for "/mute @username reason" - you parse args manually
onCommand("mute", requireOnlyCommandInMessage = false) { message ->
val text = (message.content as TextContent).text
val args = text.split(" ").drop(1) // skip command
}
// APPROACH 2: Use onCommandWithArgs (recommended for simple args)
// Automatically sets requireOnlyCommandInMessage = false and parses args
onCommandWithArgs("echo") { message, args ->
// args = arrayOf("hello", "world") for "/echo hello world"
reply(message, args.joinToString(" "))
}
// APPROACH 3: Use onCommandWithNamedArgs for key=value format
onCommandWithNamedArgs("config") { message, args ->
// args = listOf("key" to "value") for "/config key=value"
}
Common patterns:
onCommand("start") { message -> } // no args expected
onCommand("help", "info") { message -> } // multiple commands, no args
onCommand(Regex("set_.*")) { message -> } // regex pattern
onCommand("warn", requireOnlyCommandInMessage = false) { } // manual arg parsing
onCommandWithArgs("echo") { message, args -> } // auto arg parsing
onDeepLink { message, deepLink -> } // t.me/bot?start=payload
onUnhandledCommand { message -> } // fallback for unknown commands
onText { message -> }
onText(initialFilter = { it.content.text.length > 10 }) { message -> }
onText(Regex("\\d+")) { message -> }
onPhoto { message -> }
onVideo { message -> }
onAudio { message -> }
onDocument { message -> }
onVoice { message -> }
onVideoNote { message -> }
onSticker { message -> }
onAnimation { message -> }
onMediaGroup { messages -> } // album
onVisualMediaGroup { messages -> } // photos/videos only
onDataCallbackQuery { query -> }
onDataCallbackQuery(Regex("action:.*")) { query -> }
onInlineQuery { query -> }
onChosenInlineResult { result -> }
onContact { message -> }
onLocation { message -> }
onPoll { poll -> }
onPollAnswer { answer -> }
onChatMemberUpdated { update -> }
onMyChatMemberUpdated { update -> }
onNewChatMembers { message -> }
onLeftChatMember { message -> }
Wait for specific user input:
// Wait for text
val text = waitText().first()
val text = waitText { it.chat.id == chatId }.first()
// Wait for media
val photo = waitPhoto().first()
val document = waitDocument().first()
// Wait for callback
val callback = waitDataCallbackQuery().first()
val callback = waitDataCallbackQuery { it.data.startsWith("confirm:") }.first()
// With request (send message and wait)
val photo = waitPhoto(
SendTextMessage(chatId, "Send me a photo")
).first()
// Text
sendMessage(chatId, "Hello")
sendTextMessage(chatId, "Hello", parseMode = ParseMode.HTML)
reply(message, "Reply text")
// With entities
send(chatId, buildEntities {
bold("Bold") + " and " + italic("italic")
})
// Media
sendPhoto(chatId, InputFile.fromFile(File("photo.jpg")))
sendPhoto(chatId, InputFile.fromUrl("https://..."))
sendDocument(chatId, InputFile.fromFile(File("doc.pdf")))
sendVideo(chatId, InputFile.fromFile(File("video.mp4")))
sendAudio(chatId, InputFile.fromFile(File("audio.mp3")))
sendVoice(chatId, InputFile.fromFile(File("voice.ogg")))
sendSticker(chatId, InputFile.fromId(stickerFileId))
// Media group
sendMediaGroup(chatId, listOf(
TelegramMediaPhoto(InputFile.fromFile(File("1.jpg"))),
TelegramMediaPhoto(InputFile.fromFile(File("2.jpg")))
))
// Location
sendLocation(chatId, latitude = 55.75, longitude = 37.62)
// Contact
sendContact(chatId, phoneNumber = "+123456789", firstName = "John")
val text = buildEntities {
bold("Bold") + "\n"
italic("Italic") + "\n"
underline("Underline") + "\n"
strikethrough("Strike") + "\n"
spoiler("Spoiler") + "\n"
code("inline code") + "\n"
pre("code block", language = "kotlin")
link("Link", "https://example.com") + "\n"
mention("username")
textMention("User", userId)
botCommand("start")
hashtag("tag")
cashtag("USD")
email("[email protected]")
phoneNumber("+123456789")
regular("Plain text")
}
// HTML
sendTextMessage(chatId, """
<b>Bold</b>, <i>italic</i>, <u>underline</u>
<s>strikethrough</s>, <tg-spoiler>spoiler</tg-spoiler>
<code>code</code>, <pre>block</pre>
<a href="https://...">link</a>
""".trimIndent(), parseMode = ParseMode.HTML)
// MarkdownV2 (escape special chars: _*[]()~`>#+-=|{}.!)
sendTextMessage(chatId, """
*bold*, _italic_, __underline__
~strikethrough~, ||spoiler||
`code`, ```block```
[link](https://...)
""".trimIndent(), parseMode = ParseMode.MarkdownV2)
val keyboard = replyKeyboard(
resizeKeyboard = true,
oneTimeKeyboard = true,
inputFieldPlaceholder = "Choose option"
) {
row {
simpleButton("Button 1")
simpleButton("Button 2")
}
row {
requestContactButton("Share Contact")
requestLocationButton("Share Location")
}
row {
requestPollButton("Create Poll", type = RegularPoll)
webAppButton("Web App", WebAppInfo("https://..."))
}
}
sendMessage(chatId, "Menu:", replyMarkup = keyboard)
// Remove keyboard
sendMessage(chatId, "Done", replyMarkup = ReplyKeyboardRemove())
val keyboard = inlineKeyboard {
row {
dataButton("Action", "callback_data")
urlButton("Link", "https://example.com")
}
row {
webAppButton("Web App", WebAppInfo("https://..."))
loginButton("Login", LoginUrl("https://..."))
}
row {
inlineQueryInCurrentChatButton("Search", "query")
inlineQueryInChosenChatButton("Search chat", "query")
}
row {
payButton("Pay")
gameButton("Play")
}
}
sendMessage(chatId, "Actions:", replyMarkup = keyboard)
onDataCallbackQuery { query ->
val data = query.data // callback data string
val message = query.message // original message (nullable)
val user = query.user // user who clicked
// Answer callback (removes loading indicator)
answer(query)
answer(query, "Notification text")
answer(query, "Alert!", showAlert = true)
// Edit original message
edit(query.message!!, "New text")
edit(query.message!!, replyMarkup = newKeyboard)
// Delete original message
delete(query.message!!)
}
onInlineQuery { query ->
val searchQuery = query.query
val results = listOf(
InlineQueryResultArticle(
id = "1",
title = "Result Title",
description = "Description",
inputMessageContent = InputTextMessageContent("Selected: Result 1"),
replyMarkup = inlineKeyboard { row { dataButton("Details", "d:1") } }
),
InlineQueryResultPhoto(
id = "2",
photoUrl = "https://example.com/photo.jpg",
thumbnailUrl = "https://example.com/thumb.jpg"
),
InlineQueryResultGif(
id = "3",
gifUrl = "https://example.com/animation.gif",
thumbnailUrl = "https://example.com/thumb.gif"
)
)
answerInlineQuery(
query,
results,
cacheTime = 300,
isPersonal = false,
button = InlineQueryResultsButton("Open Bot", StartParameter("param"))
)
}
onChosenInlineResult { result ->
val resultId = result.resultId
val query = result.query
}
FSM imports — easy 2 mis-import; pin these 4 ktgbotapi 33.1.0:
import dev.inmo.tgbotapi.extensions.behaviour_builder.fsm.*
import dev.inmo.tgbotapi.extensions.behaviour_builder.fsm.strictlyOn
import dev.inmo.micro_utils.fsm.common.State
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitText
import dev.inmo.tgbotapi.extensions.behaviour_builder.buildBehaviourWithFSMAndStartLongPolling
Notes:
State lives in micro_utils.fsm.common (NOT tgbotapi.*) — separate artifact dev.inmo:micro_utils.fsm.common.strictlyOn + FSM context builders live under extensions.behaviour_builder.fsm (artifact tgbotapi.behaviour_builder.fsm).waitText / other wait* expectations live under extensions.behaviour_builder.expectations, NOT under .fsm.buildBehaviourWithFSMAndStartLongPolling sits directly in extensions.behaviour_builder (parent package), not .fsm.// Define states
sealed interface BotState : State {
override val context: IdChatIdentifier
data class WaitingName(override val context: IdChatIdentifier) : BotState
data class WaitingAge(override val context: IdChatIdentifier, val name: String) : BotState
}
// Bot with FSM
bot.buildBehaviourWithFSMAndStartLongPolling<BotState> {
onCommand("start") { message ->
startChain(BotState.WaitingName(message.chat.id))
}
strictlyOn<BotState.WaitingName> { state ->
send(state.context, "Enter your name:")
val name = waitText { it.chat.id == state.context }.first().content.text
BotState.WaitingAge(state.context, name) // transition
}
strictlyOn<BotState.WaitingAge> { state ->
send(state.context, "Enter your age:")
val age = waitText { it.chat.id == state.context }.first().content.text
send(state.context, "Name: ${state.name}, Age: $age")
null // end chain
}
}.second.join()
// Get chat info
val chat = getChat(chatId)
// Get chat member
val member = getChatMember(chatId, userId)
// Kick/ban
banChatMember(chatId, userId)
banChatMember(chatId, userId, untilDate = Instant.now() + 1.hours)
// Unban
unbanChatMember(chatId, userId)
// Restrict
restrictChatMember(chatId, userId, ChatPermissions(
canSendMessages = false,
canSendMediaMessages = false
))
// Promote
promoteChatMember(chatId, userId,
canDeleteMessages = true,
canPinMessages = true
)
// Set title
setChatAdministratorCustomTitle(chatId, userId, "Custom Title")
// Download file
val file = bot.downloadFile(fileId)
val bytes = bot.downloadFileToByteArray(fileId)
// Get file info
val fileInfo = getFile(fileId)
val filePath = fileInfo.filePath
// Get bot info
val me = getMe()
// Set commands
setMyCommands(listOf(
BotCommand("start", "Start the bot"),
BotCommand("help", "Show help")
))
// Set commands for specific scope
setMyCommands(
commands = listOf(BotCommand("admin", "Admin panel")),
scope = BotCommandScopeChat(adminChatId)
)
// Delete commands
deleteMyCommands()
// Set description
setMyDescription("Bot description")
setMyShortDescription("Short description")
bot.buildBehaviourWithLongPolling(
defaultExceptionsHandler = { throwable ->
when (throwable) {
is CommonBotException -> println("API error: ${throwable.message}")
is CancellationException -> { /* ignore */ }
else -> throwable.printStackTrace()
}
}
) {
// handlers
}
| Type | Description |
|------|-------------|
| ChatId | Chat identifier (Long wrapper) |
| UserId | User identifier |
| MessageId | Message identifier |
| FileId | File identifier |
| IdChatIdentifier | Union of ChatId/UserId |
| CommonMessage<T> | Message with content type T |
| TextContent | Text message content |
| PhotoContent | Photo message content |
| DocumentContent | Document message content |
| PrivateChat | Private chat type |
| GroupChat | Group chat type |
| SupergroupChat | Supergroup chat type |
| ChannelChat | Channel chat type |
development
Effective Go patterns — idiomatic code, testing, benchmarks, project layout. Always use Go 1.21+ patterns.
development
Go microservices — gRPC, REST, cloud-native patterns, service discovery, circuit breakers, observability, health checks, graceful shutdown.
development
Go concurrency mastery — goroutines, channels, context, sync primitives, patterns, performance.
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.