mirror of
https://github.com/InsanusMokrassar/TelegramBotAPI-examples.git
synced 2026-09-02 14:19:05 +00:00
Compare commits
1 Commits
renovate/m
...
37.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 447de0c3ce |
@@ -1,6 +1,6 @@
|
||||
# CommunitiesBot
|
||||
|
||||
This long-polling example demonstrates Communities support introduced in Telegram Bot API 10.2: typed service events when a chat joins or leaves a community, and inspection of a chat's current community.
|
||||
This long-polling example demonstrates Communities support introduced in Telegram Bot API 10.2 and extended in 10.3: typed service events when a chat joins or leaves a community, an event when a user joins a chat from a community, and inspection of a chat's current community.
|
||||
|
||||
## Behavior, commands, and triggers
|
||||
|
||||
@@ -10,18 +10,21 @@ At startup, the bot calls `getMe` and prints its bot information. It also prints
|
||||
| --- | --- |
|
||||
| `community_chat_added` service message | `onCommunityChatAdded` logs the chat and community name/ID, sends a join notice, then calls `getChat` and logs its nullable `community`. |
|
||||
| `community_chat_removed` service message | `onCommunityChatRemoved` logs the chat and sends a leave notice. This event is fieldless, so it has no former-community details. |
|
||||
| `community_chat_joined` service message | `onCommunityChatJoined` logs the source community and replies with a welcome message. This means a user joined the current chat from a community; it is distinct from adding the chat itself to a community. |
|
||||
| `/community` | Calls `getChat` and replies with the current community name/ID, or says the chat is not in a community. |
|
||||
| `/wait_community_added` | Waits without a timeout for the next added event in the command's chat, then replies with the community name/ID. |
|
||||
| `/wait_community_removed` | Waits without a timeout for the next removed event in the command's chat, then replies with the chat ID. Its initial waiting reply currently says "added." |
|
||||
| `/wait_community_removed` | Waits without a timeout for the next removed event in the command's chat, then replies with the chat ID. |
|
||||
| `/wait_community_joined` | Waits without a timeout for the next user-from-community join event in the command's chat, then replies with the source community name/ID. |
|
||||
|
||||
Commands use no positional arguments; other commands only appear in the generic update log. Each wait first sends a waiting reply, filters events with `sameChat`, and takes the first match.
|
||||
|
||||
## API concepts
|
||||
|
||||
- `CommunityChatAdded` carries a `Community` with a `CommunityId` and name; `CommunityChatRemoved` carries no fields.
|
||||
- `onCommunityChatAdded` and `onCommunityChatRemoved` provide typed handlers for the service events.
|
||||
- `CommunityChatJoined` carries the community through which a user joined the current chat.
|
||||
- `onCommunityChatAdded`, `onCommunityChatRemoved`, and `onCommunityChatJoined` provide typed handlers for the service events.
|
||||
- `getChat(...).community` exposes the nullable community on `ExtendedChat` without a subtype cast.
|
||||
- `waitCommunityChatAddedEventsMessages` and `waitCommunityChatRemovedEventsMessages` expose typed event-message flows.
|
||||
- `waitCommunityChatAddedEventsMessages`, `waitCommunityChatRemovedEventsMessages`, and `waitCommunityChatJoinedEventsMessages` expose typed event-message flows.
|
||||
|
||||
## Telegram setup and permissions
|
||||
|
||||
|
||||
@@ -7,13 +7,13 @@ import dev.inmo.tgbotapi.extensions.api.bot.getMe
|
||||
import dev.inmo.tgbotapi.extensions.api.chat.get.getChat
|
||||
import dev.inmo.tgbotapi.extensions.api.send.reply
|
||||
import dev.inmo.tgbotapi.extensions.api.send.send
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitCommunityChatAdded
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitCommunityChatAddedEventsMessages
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitCommunityChatRemoved
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitCommunityChatJoinedEventsMessages
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitCommunityChatRemovedEventsMessages
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommunityChatAdded
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommunityChatJoined
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommunityChatRemoved
|
||||
import dev.inmo.tgbotapi.extensions.utils.extensions.sameChat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -25,10 +25,11 @@ import kotlinx.coroutines.flow.first
|
||||
* Starts a long-polling bot that demonstrates Telegram Communities.
|
||||
*
|
||||
* [onCommunityChatAdded] receives the joined [dev.inmo.tgbotapi.types.communities.Community], while
|
||||
* [onCommunityChatRemoved] receives a fieldless removal event. `/community` reads
|
||||
* [dev.inmo.tgbotapi.types.chat.ExtendedChat.community] with [getChat]. The two wait commands use
|
||||
* [waitCommunityChatAddedEventsMessages] and [waitCommunityChatRemovedEventsMessages] to take the first same-chat
|
||||
* event without a timeout. The bot prints its [getMe] result and every received update.
|
||||
* [onCommunityChatRemoved] receives a fieldless removal event, while [onCommunityChatJoined] reports a user joining
|
||||
* the chat from a community. `/community` reads
|
||||
* [dev.inmo.tgbotapi.types.chat.ExtendedChat.community] with [getChat]. The three wait commands use typed event-message
|
||||
* expectations to take the first same-chat event without a timeout. The bot prints its [getMe] result and every
|
||||
* received update.
|
||||
*
|
||||
* @param args the bot token followed by the optional, case-sensitive `debug` and `testServer` flags; unknown trailing
|
||||
* arguments are ignored
|
||||
@@ -72,6 +73,13 @@ suspend fun main(vararg args: String) {
|
||||
send(message.chat.id, "This chat has left its community")
|
||||
}
|
||||
|
||||
// community_chat_joined: a user joined this chat through a community
|
||||
onCommunityChatJoined { message ->
|
||||
val community = message.chatEvent.community
|
||||
println("A user joined chat ${message.chat.id} from community '${community.name}' (id=${community.id.long})")
|
||||
reply(message, "Welcome! You joined from the ${community.name} community.")
|
||||
}
|
||||
|
||||
// Inspect the current chat's community on demand
|
||||
onCommand("community") {
|
||||
val community = getChat(it.chat.id).community
|
||||
@@ -94,11 +102,18 @@ suspend fun main(vararg args: String) {
|
||||
|
||||
// Suspend until the next community-removed event message from this chat.
|
||||
onCommand("wait_community_removed") { origin ->
|
||||
reply(origin, "Waiting for this chat to be added to a community...")
|
||||
reply(origin, "Waiting for this chat to be removed from a community...")
|
||||
waitCommunityChatRemovedEventsMessages().filter { it.sameChat(origin) }.first()
|
||||
reply(origin, "Chat removed from its community (${origin.chat.id})")
|
||||
}
|
||||
|
||||
// Suspend until a user joins this chat from a community.
|
||||
onCommand("wait_community_joined") { origin ->
|
||||
reply(origin, "Waiting for somebody to join this chat from a community...")
|
||||
val event = waitCommunityChatJoinedEventsMessages().filter { it.sameChat(origin) }.first().chatEvent
|
||||
reply(origin, "A user joined from ${event.community.name} (id=${event.community.id.long})")
|
||||
}
|
||||
|
||||
allUpdatesFlow.subscribeSafelyWithoutExceptions(this) {
|
||||
println(it)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
DraftsBot demonstrates streaming a message draft before sending the finished
|
||||
message. It receives updates through long polling and uses the same built-in
|
||||
Lorem ipsum text for both examples.
|
||||
Lorem ipsum text for all examples. Bot API 10.3's stoppable generation controls
|
||||
and `stopped_message_generation` updates are included.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -10,18 +11,32 @@ Lorem ipsum text for both examples.
|
||||
500 ms, then sends the complete text as a normal message.
|
||||
- `/test_empty_draft` first publishes an empty draft, waits 1.5 seconds, streams
|
||||
the same prefixes, and then sends the complete text.
|
||||
- `/test_stoppable_draft` continuously streams progressively longer revisions
|
||||
with `canStop = true` and `keepOnStop = true`. Telegram displays a stop control;
|
||||
stopping keeps the most recent draft revision and causes the flow helper to
|
||||
return `false`. Before streaming, the handler subscribes to
|
||||
`waitMessageGenerationStopped` and filters the expectation by chat and draft
|
||||
ID, then logs the matched stop event. It deliberately sends no confirmation
|
||||
message, because sending one would immediately remove the draft retained by
|
||||
`keepOnStop`. If streaming ends because of another request failure and no
|
||||
matching update arrives within five seconds, the bot logs that distinction
|
||||
instead of waiting forever.
|
||||
|
||||
The bot advertises both commands in Telegram's command menu for all group chats.
|
||||
The handlers themselves are not restricted by chat type, so either command can
|
||||
also be entered manually in a private chat.
|
||||
The bot advertises all three commands in Telegram's command menu for private
|
||||
chats and filters each handler to private chats, as required by Telegram's draft
|
||||
methods.
|
||||
|
||||
The typed `onMessageGenerationStopped` handler logs the chat, optional topic ID,
|
||||
and draft ID from every generation-stopped update, independently of the scoped
|
||||
expectation used by `/test_stoppable_draft`. Both apply to text and rich-message
|
||||
drafts sent by this bot token.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Obtain a bot token and keep it out of source control.
|
||||
2. Start a private chat with the bot, or add it to a group where you want to run
|
||||
the example.
|
||||
3. In groups, allow the bot to send messages. No administrator rights are
|
||||
otherwise required by this example.
|
||||
2. Start a private chat with the bot. Telegram doesn't accept a group or channel
|
||||
ID for `sendMessageDraft`.
|
||||
3. No administrator rights are required by this example.
|
||||
|
||||
The first program argument is required and must be the bot token. Omitting it
|
||||
causes startup to fail; any later arguments are ignored.
|
||||
@@ -34,8 +49,6 @@ From the repository root, run:
|
||||
./gradlew :DraftsBot:run --args="<BOT_TOKEN>"
|
||||
```
|
||||
|
||||
> **Known issue:** `DraftsBot/build.gradle` currently declares `TopicsHandlingKt` as the main class, while this bot's entry point is `DraftsBotKt`. The `run` task cannot start until that Gradle setting is corrected; it is left unchanged by this documentation-only update.
|
||||
|
||||
Every received update is printed to standard output. Unhandled polling errors
|
||||
are printed with their stack traces, and HTTP request, socket, and connection
|
||||
timeouts are each configured to 30 seconds.
|
||||
|
||||
@@ -11,7 +11,7 @@ buildscript {
|
||||
apply plugin: 'kotlin'
|
||||
apply plugin: 'application'
|
||||
|
||||
mainClassName="TopicsHandlingKt"
|
||||
mainClassName="DraftsBotKt"
|
||||
|
||||
|
||||
dependencies {
|
||||
|
||||
@@ -12,7 +12,9 @@ import dev.inmo.tgbotapi.extensions.api.send.send
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendMessageDraftFlow
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendMessageDraftFlowWithTexts
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitMessageGenerationStopped
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageGenerationStopped
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onForumTopicClosed
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onForumTopicCreated
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onForumTopicEdited
|
||||
@@ -30,18 +32,26 @@ import dev.inmo.tgbotapi.types.BotCommand
|
||||
import dev.inmo.tgbotapi.types.ForumTopic
|
||||
import dev.inmo.tgbotapi.types.chat.PrivateChat
|
||||
import dev.inmo.tgbotapi.types.commands.BotCommandScope
|
||||
import dev.inmo.tgbotapi.utils.DraftIdAllocator
|
||||
import io.ktor.client.plugins.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Sample text streamed as a draft and then sent as the completed message. */
|
||||
const val testText = """
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
"""
|
||||
|
||||
private val stoppableDraftIds = DraftIdAllocator()
|
||||
|
||||
/**
|
||||
* Starts DraftsBot with long polling and registers the draft demonstration commands.
|
||||
*
|
||||
@@ -67,7 +77,7 @@ suspend fun main(vararg args: String) {
|
||||
}
|
||||
}
|
||||
) {
|
||||
onCommand("test_draft_flow") {
|
||||
onCommand("test_draft_flow", initialFilter = { it.chat is PrivateChat }) {
|
||||
sendMessageDraftFlowWithTexts(
|
||||
it.chat.id,
|
||||
flow<String> {
|
||||
@@ -85,7 +95,7 @@ suspend fun main(vararg args: String) {
|
||||
|
||||
// sendMessageDraft now accepts empty text (length 0 is valid since TG Bot API 9.0)
|
||||
// Useful to show a typing indicator without any text yet
|
||||
onCommand("test_empty_draft") {
|
||||
onCommand("test_empty_draft", initialFilter = { it.chat is PrivateChat }) {
|
||||
sendMessageDraftFlowWithTexts(
|
||||
it.chat.id,
|
||||
flow<String> {
|
||||
@@ -103,10 +113,62 @@ suspend fun main(vararg args: String) {
|
||||
send(it.chat, testText)
|
||||
}
|
||||
|
||||
// Bot API 10.3 lets the user stop generation. The expectation is subscribed before streaming starts so it
|
||||
// cannot miss a fast stop update; matching both chat and draft ID also avoids consuming another draft's event.
|
||||
onCommand("test_stoppable_draft", initialFilter = { it.chat is PrivateChat }) { origin ->
|
||||
val draftId = stoppableDraftIds.allocate()
|
||||
val stoppedUpdate = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
waitMessageGenerationStopped()
|
||||
.filter { it.chat.id == origin.chat.id && it.draftId == draftId }
|
||||
.first()
|
||||
}
|
||||
try {
|
||||
val completed = sendMessageDraftFlowWithTexts(
|
||||
origin.chat.id,
|
||||
flow<String> {
|
||||
val step = 20
|
||||
var currentLength = step
|
||||
while (isActive) {
|
||||
delay(500L)
|
||||
emit(testText.take(currentLength.coerceAtMost(testText.length)))
|
||||
currentLength = (currentLength + step).coerceAtMost(testText.length)
|
||||
}
|
||||
},
|
||||
draftId = draftId,
|
||||
canStop = true,
|
||||
keepOnStop = true,
|
||||
)
|
||||
if (!completed) {
|
||||
val stopped = withTimeoutOrNull(5_000L) { stoppedUpdate.await() }
|
||||
if (stopped == null) {
|
||||
println("Draft streaming ended without a matching stopped_message_generation update")
|
||||
} else {
|
||||
// A normal message here would immediately remove the kept draft, so report only to stdout.
|
||||
println(
|
||||
"Expectation matched stopped draft ${stopped.draftId.long} in ${stopped.chat.id}; " +
|
||||
"Telegram kept its last revision temporarily."
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
stoppedUpdate.cancel()
|
||||
stoppableDraftIds.free(draftId)
|
||||
}
|
||||
}
|
||||
|
||||
// The stopped_message_generation update includes the chat, optional topic and stopped draft ID.
|
||||
onMessageGenerationStopped { stopped ->
|
||||
println(
|
||||
"Message generation stopped in ${stopped.chat.id}; " +
|
||||
"thread=${stopped.messageThreadId}, draft=${stopped.draftId}"
|
||||
)
|
||||
}
|
||||
|
||||
setMyCommands(
|
||||
BotCommand("test_draft_flow", "Start draft testing with flow"),
|
||||
BotCommand("test_empty_draft", "Draft starting from empty text (TG Bot API 9.0)"),
|
||||
scope = BotCommandScope.AllGroupChats
|
||||
BotCommand("test_stoppable_draft", "Stream a draft that the user can stop"),
|
||||
scope = BotCommandScope.AllPrivateChats
|
||||
)
|
||||
allUpdatesFlow.subscribeLoggingDropExceptions(this) {
|
||||
println(it)
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
# EphemeralMessagesBot
|
||||
|
||||
Demonstrates ephemeral messages: group messages that Telegram shows only to one receiver.
|
||||
Demonstrates Telegram Bot API 10.2 and 10.3 ephemeral messages: group messages that Telegram shows only to one receiver.
|
||||
|
||||
## Behavior
|
||||
|
||||
- `/ephemeral` replies with a **Reveal a secret** inline button. The command is registered with Telegram as
|
||||
an ephemeral command.
|
||||
- Pressing the button (`reveal` callback data) sends a personal message visible only to the user who
|
||||
pressed it. After three seconds the bot edits the message, then deletes it three seconds later.
|
||||
- Pressing the button (`reveal` callback data) uses `EphemeralMessageParameters` to replace the callback-query
|
||||
message with a personal rich message visible only to the user who pressed it. After three seconds the bot replaces
|
||||
its text with typed rich blocks, then deletes it three seconds later.
|
||||
- `/ephemeral_photo` waits for a photo from the same user and chat, sends it back ephemerally by its Telegram file ID,
|
||||
downloads it, and edits the ephemeral media using a new multipart upload. A second edit sets
|
||||
`showCaptionAboveMedia = true`.
|
||||
- `/ephemeral_live_photo` waits for a Live Photo, sends it ephemerally by existing file IDs, then downloads and
|
||||
re-uploads both its main file and secondary `photo` file in one `editEphemeralMessageMedia` request. This exercises
|
||||
ktgbotapi 37.0.0's secondary multipart attachment collection.
|
||||
- When the bot receives an ephemeral content message, it sends two ephemeral replies: one through the
|
||||
general `reply` API and one through the explicit `replyToEphemeral` API.
|
||||
general `reply` API and one through the explicit `replyToEphemeral` API. The explicit form also uses
|
||||
`EphemeralMessageParameters`.
|
||||
- Updates and basic bot information are printed to standard output.
|
||||
|
||||
## Setup
|
||||
|
||||
Create a bot token, keep it secret, and add the bot to a group. The bot must be allowed to send messages
|
||||
there; this example does not request or validate group permissions itself. Use a Telegram environment that
|
||||
supports ephemeral messages.
|
||||
supports ephemeral messages. The photo demonstrations download all selected media into memory before uploading it
|
||||
again.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -6,29 +6,47 @@ import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
|
||||
import dev.inmo.tgbotapi.extensions.api.bot.getMe
|
||||
import dev.inmo.tgbotapi.extensions.api.bot.setMyCommands
|
||||
import dev.inmo.tgbotapi.extensions.api.deleteEphemeralMessage
|
||||
import dev.inmo.tgbotapi.extensions.api.edit.text.editEphemeralMessageText
|
||||
import dev.inmo.tgbotapi.extensions.api.edit.caption.editEphemeralMessageCaption
|
||||
import dev.inmo.tgbotapi.extensions.api.edit.media.editEphemeralMessageMedia
|
||||
import dev.inmo.tgbotapi.extensions.api.edit.text.editEphemeralMessageRichText
|
||||
import dev.inmo.tgbotapi.extensions.api.files.downloadFile
|
||||
import dev.inmo.tgbotapi.extensions.api.send.reply
|
||||
import dev.inmo.tgbotapi.extensions.api.send.replyToEphemeral
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendTextMessage
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendRichMessage
|
||||
import dev.inmo.tgbotapi.extensions.api.send.media.sendLivePhoto
|
||||
import dev.inmo.tgbotapi.extensions.api.send.media.sendPhoto
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitLivePhotoMessage
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitPhotoMessage
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onContentMessage
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageDataCallbackQuery
|
||||
import dev.inmo.tgbotapi.extensions.utils.fromUserMessageOrNull
|
||||
import dev.inmo.tgbotapi.extensions.utils.extensions.sameChat
|
||||
import dev.inmo.tgbotapi.extensions.utils.types.buttons.dataButton
|
||||
import dev.inmo.tgbotapi.extensions.utils.types.buttons.flatInlineKeyboard
|
||||
import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile
|
||||
import dev.inmo.tgbotapi.types.BotCommand
|
||||
import dev.inmo.tgbotapi.types.EphemeralMessageParameters
|
||||
import dev.inmo.tgbotapi.types.ephemeralReplyReceiverUserIdOrNull
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaLivePhoto
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
|
||||
import dev.inmo.tgbotapi.types.message.abstracts.PossiblyEphemeralMessage
|
||||
import dev.inmo.tgbotapi.types.rich.InputRichMessageBlocks
|
||||
import korlibs.time.seconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/**
|
||||
* Runs the ephemeral-messages example bot using long polling.
|
||||
*
|
||||
* `/ephemeral` posts an inline button whose callback sends, edits, and deletes a message visible only to
|
||||
* the user who pressed it. Incoming [PossiblyEphemeralMessage] instances receive both an automatic
|
||||
* `/ephemeral` posts an inline button whose callback replaces it with a rich message visible only to
|
||||
* the user who pressed it, edits that message, and deletes it. `/ephemeral_photo` demonstrates uploading new media
|
||||
* to an ephemeral edit and moving its caption above the media. `/ephemeral_live_photo` demonstrates collecting both
|
||||
* multipart files of a Live Photo edit. Incoming [PossiblyEphemeralMessage] instances receive both an automatic
|
||||
* ephemeral [reply] and an explicit [replyToEphemeral].
|
||||
*
|
||||
* [args] must start with the bot token. The optional exact values `debug` and `testServer` respectively
|
||||
@@ -68,18 +86,25 @@ suspend fun main(vararg args: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// Send an ephemeral message in response to a callback query. `receiverUserId` + `callbackQueryId`
|
||||
// make the outgoing message ephemeral — Telegram shows it only to the querying user (and this also
|
||||
// serves as the answer to the callback query).
|
||||
// Bot API 10.3 groups the recipient/callback fields in EphemeralMessageParameters. Setting
|
||||
// replaceCallbackQueryMessage replaces the button message only for the user who pressed it.
|
||||
onMessageDataCallbackQuery(Regex("reveal")) { query ->
|
||||
val chatId = query.message.chat.id
|
||||
val receiverUserId = query.from.id
|
||||
|
||||
val sent = sendTextMessage(
|
||||
val sent = sendRichMessage(
|
||||
chatId,
|
||||
"🔒 ${query.from.firstName}, here is your personal secret: 42",
|
||||
InputRichMessageBlocks {
|
||||
paragraph {
|
||||
plain("🔒 ${query.from.firstName}, here is your personal secret: ")
|
||||
code("42")
|
||||
}
|
||||
},
|
||||
ephemeralMessageParameters = EphemeralMessageParameters(
|
||||
receiverUserId = receiverUserId,
|
||||
callbackQueryId = query.id,
|
||||
replaceCallbackQueryMessage = true,
|
||||
),
|
||||
)
|
||||
|
||||
// Only the group-family Common*ContentMessage types implement PossiblyEphemeralMessage, so the
|
||||
@@ -87,14 +112,91 @@ suspend fun main(vararg args: String) {
|
||||
val ephemeralMessageId = (sent as? PossiblyEphemeralMessage)?.ephemeralMessageId
|
||||
if (ephemeralMessageId != null) {
|
||||
delay(3.seconds)
|
||||
// editEphemeralMessageText: address the ephemeral message by chatId + receiverUserId + ephemeralMessageId
|
||||
editEphemeralMessageText(chatId, receiverUserId, ephemeralMessageId, "🔓 Revealed: the answer is 42")
|
||||
// editEphemeralMessageText now accepts rich_message; the typed extension exposes that as
|
||||
// editEphemeralMessageRichText.
|
||||
editEphemeralMessageRichText(
|
||||
chatId,
|
||||
receiverUserId,
|
||||
ephemeralMessageId,
|
||||
InputRichMessageBlocks {
|
||||
h2("Secret revealed")
|
||||
paragraph { plain("The answer is "); code("42") }
|
||||
},
|
||||
)
|
||||
delay(3.seconds)
|
||||
// deleteEphemeralMessage: same addressing (there is also a PossiblyEphemeralMessage overload)
|
||||
deleteEphemeralMessage(chatId, receiverUserId, ephemeralMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload a received photo again as a brand-new multipart file while editing an ephemeral media message.
|
||||
onCommand("ephemeral_photo") { origin ->
|
||||
val receiverUserId = origin.fromUserMessageOrNull()?.user?.id ?: return@onCommand
|
||||
reply(origin, "Send a photo. I will return it as ephemeral media and then re-upload it in an edit.")
|
||||
val photoMessage = waitPhotoMessage().filter {
|
||||
it.sameChat(origin) && it.fromUserMessageOrNull()?.user?.id == receiverUserId
|
||||
}.first()
|
||||
|
||||
val sent = sendPhoto(
|
||||
photoMessage.chat.id,
|
||||
photoMessage.content.media.fileId,
|
||||
text = "Ephemeral photo using its existing Telegram file ID",
|
||||
ephemeralMessageParameters = EphemeralMessageParameters(receiverUserId),
|
||||
)
|
||||
val ephemeralMessageId = (sent as? PossiblyEphemeralMessage)?.ephemeralMessageId
|
||||
?: return@onCommand
|
||||
|
||||
val photoBytes = downloadFile(photoMessage.content)
|
||||
editEphemeralMessageMedia(
|
||||
photoMessage.chat.id,
|
||||
receiverUserId,
|
||||
ephemeralMessageId,
|
||||
TelegramMediaPhoto(photoBytes.asMultipartFile("ephemeral-photo.jpg")),
|
||||
)
|
||||
editEphemeralMessageCaption(
|
||||
photoMessage.chat.id,
|
||||
receiverUserId,
|
||||
ephemeralMessageId,
|
||||
caption = "This caption is above newly uploaded media",
|
||||
showCaptionAboveMedia = true,
|
||||
)
|
||||
}
|
||||
|
||||
// A Live Photo has a main file plus a secondary `photo` file. ktgbotapi 37.0.0 includes both multipart
|
||||
// attachments when EditEphemeralMessageMedia builds its request.
|
||||
onCommand("ephemeral_live_photo") { origin ->
|
||||
val receiverUserId = origin.fromUserMessageOrNull()?.user?.id ?: return@onCommand
|
||||
reply(origin, "Send a Live Photo. I will send it ephemerally and edit it using two new uploads.")
|
||||
val livePhotoMessage = waitLivePhotoMessage().filter {
|
||||
it.sameChat(origin) && it.fromUserMessageOrNull()?.user?.id == receiverUserId
|
||||
}.first()
|
||||
val livePhoto = livePhotoMessage.content.media
|
||||
|
||||
val sent = sendLivePhoto(
|
||||
chatId = livePhotoMessage.chat.id,
|
||||
livePhoto = livePhoto,
|
||||
text = "Ephemeral Live Photo using existing Telegram file IDs",
|
||||
ephemeralMessageParameters = EphemeralMessageParameters(receiverUserId),
|
||||
)
|
||||
val ephemeralMessageId = (sent as? PossiblyEphemeralMessage)?.ephemeralMessageId
|
||||
?: return@onCommand
|
||||
|
||||
val livePhotoBytes = downloadFile(livePhoto)
|
||||
val coverPhotoBytes = livePhoto.photo?.let { downloadFile(it) }
|
||||
editEphemeralMessageMedia(
|
||||
livePhotoMessage.chat.id,
|
||||
receiverUserId,
|
||||
ephemeralMessageId,
|
||||
TelegramMediaLivePhoto(
|
||||
file = livePhotoBytes.asMultipartFile("ephemeral-live-photo.mp4"),
|
||||
photo = coverPhotoBytes?.asMultipartFile("ephemeral-live-photo-cover.jpg")
|
||||
?: livePhoto.photo?.fileId
|
||||
?: livePhoto.fileId,
|
||||
text = "Edited with newly uploaded main and cover files",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Incoming ephemeral messages: detect them via PossiblyEphemeralMessage, then answer them.
|
||||
onContentMessage { message ->
|
||||
val ephemeral = (message as? PossiblyEphemeralMessage)?.takeIf { it.ephemeralMessageId != null }
|
||||
@@ -109,7 +211,7 @@ suspend fun main(vararg args: String) {
|
||||
if (receiverUserId != null) {
|
||||
replyToEphemeral(
|
||||
message.chat.id,
|
||||
receiverUserId,
|
||||
EphemeralMessageParameters(receiverUserId),
|
||||
ephemeral.ephemeralMessageId!!,
|
||||
"Explicit ephemeral reply via replyToEphemeral",
|
||||
)
|
||||
@@ -119,6 +221,8 @@ suspend fun main(vararg args: String) {
|
||||
setMyCommands(
|
||||
// isEphemeral marks a command whose response is an ephemeral (personal) message
|
||||
BotCommand("ephemeral", "Post a button that reveals an ephemeral (personal) message", isEphemeral = true),
|
||||
BotCommand("ephemeral_photo", "Re-upload a photo through an ephemeral media edit", isEphemeral = true),
|
||||
BotCommand("ephemeral_live_photo", "Re-upload both files of an ephemeral Live Photo", isEphemeral = true),
|
||||
)
|
||||
|
||||
allUpdatesFlow.subscribeSafelyWithoutExceptions(this) {
|
||||
|
||||
@@ -15,12 +15,16 @@ Regular gifts are shown with their ID, optional text, and Stars cost. Unique gif
|
||||
name, model, and number. Long results are split into multiple Telegram messages; an empty result produces
|
||||
`This chat have no any gifts`.
|
||||
|
||||
The bot also handles `UniqueGiftInfo` service messages with `onUniqueGiftSentOrReceived`. It logs and replies with
|
||||
Bot API 10.3's `text`, parsed `textSources` (`entities`), and `isPrivate` fields. When entities are present, the reply
|
||||
reuses them so the gift text keeps its formatting.
|
||||
|
||||
## Command
|
||||
|
||||
- `/start` — lists the owned gifts selected by the current chat type. It must be the only command in the message and
|
||||
takes no arguments.
|
||||
|
||||
Other commands and non-command messages are ignored.
|
||||
Other commands and ordinary non-command messages are ignored; unique-gift service messages are handled separately.
|
||||
|
||||
## Setup and permissions
|
||||
|
||||
|
||||
@@ -10,10 +10,7 @@ import dev.inmo.tgbotapi.extensions.api.send.reply
|
||||
import dev.inmo.tgbotapi.extensions.api.send.withTypingAction
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGiveawayCompleted
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGiveawayContent
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGiveawayCreated
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGiveawayWinners
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onUniqueGiftSentOrReceived
|
||||
import dev.inmo.tgbotapi.types.chat.BusinessChat
|
||||
import dev.inmo.tgbotapi.types.chat.PrivateChat
|
||||
import dev.inmo.tgbotapi.types.chat.PublicChat
|
||||
@@ -22,6 +19,7 @@ import dev.inmo.tgbotapi.types.gifts.OwnedGift
|
||||
import dev.inmo.tgbotapi.types.message.textsources.splitForText
|
||||
import dev.inmo.tgbotapi.utils.bold
|
||||
import dev.inmo.tgbotapi.utils.buildEntities
|
||||
import dev.inmo.tgbotapi.utils.regular
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
@@ -30,7 +28,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
*
|
||||
* Business chats are queried through their business connection, private chats through their user ID, and public or
|
||||
* unknown chat types through their chat ID. Regular and unique gifts are rendered as formatted text and long results
|
||||
* are split across replies. The bot also prints its [getMe] result at startup.
|
||||
* are split across replies. Unique-gift service messages also expose the Bot API 10.3 text, entities and privacy flag.
|
||||
* The bot prints its [getMe] result at startup.
|
||||
*
|
||||
* @param args the bot token followed by optional, case-sensitive `debug` and `testServer` flags; unknown trailing
|
||||
* arguments are ignored
|
||||
@@ -55,6 +54,28 @@ suspend fun main(vararg args: String) {
|
||||
val me = getMe()
|
||||
println(me)
|
||||
|
||||
onUniqueGiftSentOrReceived { message ->
|
||||
val uniqueGiftInfo = message.chatEvent
|
||||
println(
|
||||
"Unique gift ${uniqueGiftInfo.gift.name.value}: " +
|
||||
"text=${uniqueGiftInfo.text}, textSources=${uniqueGiftInfo.textSources}, " +
|
||||
"isPrivate=${uniqueGiftInfo.isPrivate}"
|
||||
)
|
||||
reply(
|
||||
message,
|
||||
buildEntities {
|
||||
bold("Unique gift") + ": ${uniqueGiftInfo.gift.name.value}\n"
|
||||
bold("Private") + ": ${uniqueGiftInfo.isPrivate}\n"
|
||||
bold("Text") + ": "
|
||||
if (uniqueGiftInfo.textSources.isEmpty()) {
|
||||
regular(uniqueGiftInfo.text ?: "(None)")
|
||||
} else {
|
||||
+uniqueGiftInfo.textSources
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onCommand("start") {
|
||||
val giftsFlow = when (val chat = it.chat) {
|
||||
is BusinessChat -> {
|
||||
|
||||
@@ -57,10 +57,14 @@ fun InlineKeyboardBuilder.includePageButtons(page: Int, count: Int) {
|
||||
val numbersRange = 1 .. count
|
||||
numericButtons.forEach {
|
||||
if (it in numbersRange) {
|
||||
if (it == page) {
|
||||
disabledButton(it.toString(), style = KeyboardButtonStyle.Primary)
|
||||
} else {
|
||||
dataButton(it.toString(), "$it $count")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row {
|
||||
copyTextButton("Command copy button", "/inline $page $count")
|
||||
}
|
||||
@@ -117,7 +121,7 @@ suspend fun activateKeyboardsBot(
|
||||
val page = numberArgs.firstOrNull()?.takeIf { numberArgs.size > 1 }?.coerceAtLeast(1) ?: 1
|
||||
reply(
|
||||
message,
|
||||
replyMarkup = inlineKeyboard {
|
||||
replyMarkup = inlineKeyboard(forceReply = true) {
|
||||
includePageButtons(page, numberOfPages)
|
||||
}
|
||||
) {
|
||||
@@ -136,7 +140,7 @@ suspend fun activateKeyboardsBot(
|
||||
answer(it, "Unsupported message type :(")
|
||||
return@onMessageDataCallbackQuery
|
||||
},
|
||||
replyMarkup = inlineKeyboard {
|
||||
replyMarkup = inlineKeyboard(forceReply = true) {
|
||||
includePageButtons(page, count)
|
||||
}
|
||||
) {
|
||||
@@ -152,7 +156,7 @@ suspend fun activateKeyboardsBot(
|
||||
|
||||
editMessageText(
|
||||
it.inlineMessageId,
|
||||
replyMarkup = inlineKeyboard {
|
||||
replyMarkup = inlineKeyboard(forceReply = true) {
|
||||
includePageButtons(page, count)
|
||||
}
|
||||
) {
|
||||
@@ -173,7 +177,7 @@ suspend fun activateKeyboardsBot(
|
||||
InlineQueryId(it.query),
|
||||
"Send buttons",
|
||||
InputTextMessageContent("It is sent via inline mode inline buttons"),
|
||||
replyMarkup = inlineKeyboard {
|
||||
replyMarkup = inlineKeyboard(forceReply = true) {
|
||||
includePageButtons(page, count)
|
||||
}
|
||||
)
|
||||
@@ -184,7 +188,11 @@ suspend fun activateKeyboardsBot(
|
||||
onUnhandledCommand {
|
||||
reply(
|
||||
it,
|
||||
replyMarkup = replyKeyboard(resizeKeyboard = true, oneTimeKeyboard = true) {
|
||||
replyMarkup = replyKeyboard(
|
||||
resizeKeyboard = true,
|
||||
oneTimeKeyboard = true,
|
||||
forceReply = true,
|
||||
) {
|
||||
row {
|
||||
simpleButton("/inline", style = KeyboardButtonStyle.Primary)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# KeyboardsBot
|
||||
|
||||
A multiplatform long-polling example that demonstrates Telegram reply keyboards, inline keyboards, callback queries, copy-text buttons, inline-mode buttons, and keyboard button styles. The shared bot behavior lives in `KeyboardsBotLib`; the project provides a browser/JS entry point and a separate JVM launcher.
|
||||
A multiplatform long-polling example that demonstrates Telegram reply keyboards, inline keyboards, callback queries, copy-text buttons, inline-mode buttons, keyboard button styles, disabled buttons, and forced reply interfaces. The shared bot behavior lives in `KeyboardsBotLib`; the project provides a browser/JS entry point and a separate JVM launcher.
|
||||
|
||||
## Bot behavior
|
||||
|
||||
@@ -16,16 +16,16 @@ At startup, the bot calls `getMe`, reports the returned bot information through
|
||||
|
||||
Only numeric command arguments are considered. Use positive integers with `page <= count`; the example does not validate the count or clamp the page to the upper bound.
|
||||
|
||||
The generated inline keyboard contains:
|
||||
The generated inline keyboard sets Bot API 10.3's `force_reply` field and contains:
|
||||
|
||||
- numbered buttons for the current page and any adjacent pages that are within `1..count`;
|
||||
- a disabled button for the current page and callback buttons for adjacent pages within `1..count`;
|
||||
- styled jump buttons for moving toward the first or last page when applicable;
|
||||
- a **Command copy button** that copies `/inline <page> <count>`;
|
||||
- a **Send somebody page** button that starts inline mode and lets the user choose a user, bot, group, or channel.
|
||||
|
||||
Pagination callbacks edit the original message and replace its text with `This is <page> of <count>`. This works for both ordinary bot messages and messages sent through inline mode. Unsupported callback data or an unsupported message type is answered with a callback notification instead.
|
||||
|
||||
Any command not handled above, including `/start`, receives a one-time reply keyboard containing a styled `/inline` button. Ordinary non-command messages are ignored.
|
||||
Any command not handled above, including `/start`, receives a one-time reply keyboard containing a styled `/inline` button. That reply keyboard also sets `force_reply`, demonstrating the field on both markup types. Ordinary non-command messages are ignored.
|
||||
|
||||
### Inline mode
|
||||
|
||||
|
||||
@@ -8,17 +8,23 @@ The bot defines no commands. It prints every incoming update to standard output
|
||||
|
||||
| Trigger | Behavior |
|
||||
| --- | --- |
|
||||
| Standalone Live Photo | Logs its file identifiers, dimensions, duration, thumbnail, MIME type, size, and caption. It resends the Live Photo, sends the same media as paid content costing 1 Star, and then edits the resent message with `TelegramMediaLivePhoto`. |
|
||||
| Live Photo gallery | Logs every item and resends the gallery with `sendMediaGroup`. |
|
||||
| Standalone Live Photo | Logs its file identifiers, dimensions, duration, thumbnail, MIME type, size, and caption. It resends the Live Photo by file ID, downloads both components, edits the resent message with new multipart files, and uploads the files again as paid content costing 1 Star. |
|
||||
| Live Photo gallery | Logs every item, downloads each main and cover file, and re-uploads the gallery with `sendMediaGroup`. |
|
||||
| Paid-media message containing Live Photos | Logs each Live Photo and replies with the number found. Paid-media messages without a Live Photo get no reply from this handler. |
|
||||
| Edited Live Photo | Logs the file ID and updated caption. |
|
||||
| Media group containing at least one regular photo and one regular video | Uses the first photo as the cover and the first video as the motion part, then replies with a Live Photo. Albums missing either type are ignored by this handler. |
|
||||
|
||||
## Live Photo handling
|
||||
|
||||
The bot does not download or transform media. It reuses Telegram file IDs: the received Live Photo is passed directly to `sendLivePhoto`, while its main file ID and thumbnail file ID are used to construct `TelegramMediaLivePhoto` and `TelegramPaidMediaLivePhoto`. If Telegram supplies no thumbnail, the code falls back to the main file ID for the photo field.
|
||||
The initial `sendLivePhoto` call reuses Telegram file IDs. The regular-media edit,
|
||||
paid-media send, and gallery resend then demonstrate ktgbotapi 37.0.0 collecting
|
||||
the secondary Live Photo `photo` attachment as well as the main multipart file.
|
||||
If Telegram supplies no cover photo, the code falls back to the main file ID for
|
||||
the `photo` field. Downloaded files are held in memory and are not transformed.
|
||||
|
||||
The standalone handler performs its requests in order: resend, send paid media, then edit the resent message. Consequently, a failure while sending paid media prevents the edit for that update.
|
||||
The standalone handler performs its requests in order: resend, download, edit,
|
||||
then send paid media. The edit can therefore succeed in a private or group chat
|
||||
before the channel-only paid-media request fails.
|
||||
|
||||
## Telegram setup and permissions
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import dev.inmo.kslog.common.setDefaultKSLog
|
||||
import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions
|
||||
import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions
|
||||
import dev.inmo.tgbotapi.extensions.api.edit.media.editMessageMedia
|
||||
import dev.inmo.tgbotapi.extensions.api.files.downloadFile
|
||||
import dev.inmo.tgbotapi.extensions.api.send.media.sendLivePhoto
|
||||
import dev.inmo.tgbotapi.extensions.api.send.media.sendMediaGroup
|
||||
import dev.inmo.tgbotapi.extensions.api.send.media.sendPaidMedia
|
||||
@@ -23,6 +24,7 @@ import dev.inmo.tgbotapi.extensions.utils.photoFileOrNull
|
||||
import dev.inmo.tgbotapi.extensions.utils.videoContentOrNull
|
||||
import dev.inmo.tgbotapi.extensions.utils.videoFileOrNull
|
||||
import dev.inmo.tgbotapi.extensions.utils.withContentOrNull
|
||||
import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile
|
||||
import dev.inmo.tgbotapi.types.message.content.LivePhotoContent
|
||||
import dev.inmo.tgbotapi.types.message.payments.PaidMedia
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaLivePhoto
|
||||
@@ -90,34 +92,42 @@ suspend fun main(vararg args: String) {
|
||||
)
|
||||
println(" sent message id: ${sent.messageId}")
|
||||
|
||||
// InputPaidMediaLivePhoto (TelegramPaidMediaLivePhoto): send the live photo as paid media (1 star)
|
||||
sendPaidMedia(
|
||||
chatId = message.chat.id,
|
||||
starCount = 1,
|
||||
media = listOf(
|
||||
// TelegramPaidMediaLivePhoto is InputPaidMediaLivePhoto
|
||||
TelegramPaidMediaLivePhoto(
|
||||
file = livePhotoFile.fileId,
|
||||
photo = livePhotoFile.photo?.fileId ?: livePhotoFile.fileId
|
||||
)
|
||||
),
|
||||
text = "Paid live photo (1 star)"
|
||||
)
|
||||
// Download both Live Photo components once. ktgbotapi 37.0.0 collects the secondary `photo`
|
||||
// MultipartFile alongside the main file for edits, media groups, and paid-media requests.
|
||||
val livePhotoBytes = downloadFile(livePhotoFile)
|
||||
val coverPhotoBytes = livePhotoFile.photo?.let { downloadFile(it) }
|
||||
|
||||
// editMessageMedia with InputMediaLivePhoto (TelegramMediaLivePhoto):
|
||||
// edit the previously sent message to replace it with itself via TelegramMediaLivePhoto
|
||||
// edit the previously sent message using newly uploaded main and cover files.
|
||||
val sentAsMedia = sent.withContentOrNull<LivePhotoContent>()
|
||||
if (sentAsMedia != null) {
|
||||
editMessageMedia(
|
||||
message = sentAsMedia,
|
||||
// TelegramMediaLivePhoto is InputMediaLivePhoto
|
||||
media = TelegramMediaLivePhoto(
|
||||
file = livePhotoFile.fileId,
|
||||
photo = livePhotoFile.photo?.fileId ?: livePhotoFile.fileId,
|
||||
text = "Edited via editMessageMedia with TelegramMediaLivePhoto"
|
||||
file = livePhotoBytes.asMultipartFile("edited-live-photo.mp4"),
|
||||
photo = coverPhotoBytes?.asMultipartFile("edited-live-photo-cover.jpg")
|
||||
?: livePhotoFile.photo?.fileId
|
||||
?: livePhotoFile.fileId,
|
||||
text = "Edited with newly uploaded Live Photo files"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// InputPaidMediaLivePhoto (TelegramPaidMediaLivePhoto): upload both files as paid media (1 star).
|
||||
// Telegram currently restricts sendPaidMedia to channel chats.
|
||||
sendPaidMedia(
|
||||
chatId = message.chat.id,
|
||||
starCount = 1,
|
||||
media = listOf(
|
||||
TelegramPaidMediaLivePhoto(
|
||||
file = livePhotoBytes.asMultipartFile("paid-live-photo.mp4"),
|
||||
photo = coverPhotoBytes?.asMultipartFile("paid-live-photo-cover.jpg")
|
||||
?: livePhotoFile.photo?.fileId
|
||||
?: livePhotoFile.fileId
|
||||
)
|
||||
),
|
||||
text = "Paid live photo uploaded as new files (1 star)"
|
||||
)
|
||||
}
|
||||
|
||||
// Demonstrates: sendMediaGroup with live photos, InputMediaLivePhoto (TelegramMediaLivePhoto)
|
||||
@@ -128,15 +138,17 @@ suspend fun main(vararg args: String) {
|
||||
println(" - fileId: ${livePhotoFile.fileId}, ${livePhotoFile.width}x${livePhotoFile.height}")
|
||||
}
|
||||
|
||||
// sendMediaGroup with TelegramMediaLivePhoto (InputMediaLivePhoto)
|
||||
// sendMediaGroup with newly uploaded main and cover files for every TelegramMediaLivePhoto.
|
||||
sendMediaGroup(
|
||||
chatId = mediaGroupContent.group.first().sourceMessage.chat.id,
|
||||
media = mediaGroupContent.group.map { groupMember ->
|
||||
media = mediaGroupContent.group.mapIndexed { index, groupMember ->
|
||||
val livePhotoFile = groupMember.content.media
|
||||
// TelegramMediaLivePhoto is InputMediaLivePhoto — used here in sendMediaGroup
|
||||
val coverPhoto = livePhotoFile.photo
|
||||
TelegramMediaLivePhoto(
|
||||
file = livePhotoFile.fileId,
|
||||
photo = livePhotoFile.photo?.fileId ?: livePhotoFile.fileId
|
||||
file = downloadFile(livePhotoFile).asMultipartFile("gallery-live-photo-$index.mp4"),
|
||||
photo = coverPhoto?.let {
|
||||
downloadFile(it).asMultipartFile("gallery-live-photo-cover-$index.jpg")
|
||||
} ?: livePhotoFile.fileId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
18
README.md
18
README.md
@@ -1,6 +1,6 @@
|
||||
# TelegramBotAPI examples
|
||||
|
||||
Runnable examples for [TelegramBotAPI](https://github.com/InsanusMokrassar/TelegramBotAPI). Each module focuses on a small Telegram Bot API feature and has its own README with detailed behavior, setup, permissions, and optional arguments.
|
||||
Runnable examples for [TelegramBotAPI](https://github.com/InsanusMokrassar/TelegramBotAPI), currently targeting tgbotapi 37.0.0 and Telegram Bot API 10.3. Each module focuses on a small Telegram Bot API feature and has its own README with detailed behavior, setup, permissions, and optional arguments.
|
||||
|
||||
## Running an example
|
||||
|
||||
@@ -24,24 +24,24 @@ sudo apt install libcurl4-gnutls-dev
|
||||
| [ChatAvatarSetter](ChatAvatarSetter/) | Sets a chat photo from an image sent to the bot. | `./gradlew :ChatAvatarSetter:run --args="<BOT_TOKEN>"` |
|
||||
| [ChatManagementBot](ChatManagementBot/) | Exercises member permissions, administrator queries, reaction deletion, and bot-to-bot messages. | `./gradlew :ChatManagementBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [ChecklistsBot](ChecklistsBot/) | Receives and renders checklist messages and checklist service events. | `./gradlew :ChecklistsBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [CommunitiesBot](CommunitiesBot/) | Handles community join/leave events and inspects a chat's current community. | `./gradlew :CommunitiesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [CommunitiesBot](CommunitiesBot/) | Handles chat/community membership and user-from-community join events. | `./gradlew :CommunitiesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [CustomBot](CustomBot/) | Provides a diagnostics playground for contexts, request logging, profile audio, and Stars balance. | `./gradlew :CustomBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [DeepLinksBot](DeepLinksBot/) | Generates bot deep links and consumes their start payloads. | `./gradlew :DeepLinksBot:run --args="<BOT_TOKEN>"` |
|
||||
| [DraftsBot](DraftsBot/) † | Streams message drafts before sending the completed message. | `./gradlew :DraftsBot:run --args="<BOT_TOKEN>"` |
|
||||
| [EphemeralMessagesBot](EphemeralMessagesBot/) | Sends ephemeral messages revealed through an inline button. | `./gradlew :EphemeralMessagesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [DraftsBot](DraftsBot/) | Streams empty or stoppable message drafts and handles generation-stopped updates. | `./gradlew :DraftsBot:run --args="<BOT_TOKEN>"` |
|
||||
| [EphemeralMessagesBot](EphemeralMessagesBot/) | Sends, replaces, and edits rich, photo, or Live Photo ephemeral messages. | `./gradlew :EphemeralMessagesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [FSMBot](FSMBot/) | Implements a conversational finite-state machine with chat-scoped in-memory state. | `./gradlew :FSMBot:run --args="<BOT_TOKEN>"` |
|
||||
| [FilesLoaderBot](FilesLoaderBot/) | Downloads incoming media to disk and sends it back to the chat. | `./gradlew :FilesLoaderBot:run --args="<BOT_TOKEN> <OUTPUT_DIRECTORY>"` |
|
||||
| [ForwardInfoSenderBot](ForwardInfoSenderBot/) | Reports the forward-origin metadata of received content. | `./gradlew :ForwardInfoSenderBot:run --args="<BOT_TOKEN>"` |
|
||||
| [GiftsBot](GiftsBot/) | Paginates and lists gifts owned by a user or chat. | `./gradlew :GiftsBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [GiftsBot](GiftsBot/) | Lists owned gifts and renders unique-gift service-message metadata. | `./gradlew :GiftsBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [GiveawaysBot](GiveawaysBot/) | Logs giveaway creation, completion, and winner events. | `./gradlew :GiveawaysBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [GuestQueryBot](GuestQueryBot/) | Handles guest queries in chats where the bot is not a member. | `./gradlew :GuestQueryBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [HelloBot](HelloBot/) | Greets users, groups, channels, or business chats when mentioned. | `./gradlew :HelloBot:run --args="<BOT_TOKEN>"` |
|
||||
| [InlineQueriesBot](InlineQueriesBot/) | Answers inline queries and supplies a deep-link result. | `./gradlew :InlineQueriesBot:runJvm --args="<BOT_TOKEN>"` |
|
||||
| [JoinRequestQueriesBot](JoinRequestQueriesBot/) | Queues or approves join requests, optionally delegating the decision to a Web App. | `./gradlew :JoinRequestQueriesBot:run --args="<BOT_TOKEN> https://example.com/verify debug testServer"` |
|
||||
| [KeyboardsBot](KeyboardsBot/) | Demonstrates reply, inline, callback, paged, copy-text, and inline-mode keyboards. | `./gradlew :KeyboardsBot:jvm_launcher:run --args="<BOT_TOKEN> debug"` |
|
||||
| [KeyboardsBot](KeyboardsBot/) | Demonstrates reply, inline, disabled, forced-reply, paged, and inline-mode keyboards. | `./gradlew :KeyboardsBot:jvm_launcher:run --args="<BOT_TOKEN> debug"` |
|
||||
| [LinkPreviewsBot](LinkPreviewsBot/) | Sends the same link using multiple link-preview configurations. | `./gradlew :LinkPreviewsBot:run --args="<BOT_TOKEN> debug"` |
|
||||
| [LiveLocationsBot](LiveLocationsBot/) | Sends, updates, cancels, and stops a live-location message. | `./gradlew :LiveLocationsBot:run --args="<BOT_TOKEN>"` |
|
||||
| [LivePhotosBot](LivePhotosBot/) | Receives, sends, groups, edits, and sells Telegram Live Photos. | `./gradlew :LivePhotosBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [LivePhotosBot](LivePhotosBot/) | Receives, uploads, groups, edits, and sells Telegram Live Photos. | `./gradlew :LivePhotosBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [ManagedBotsBot](ManagedBotsBot/) † | Creates and administers managed bots and inspects personal-channel messages. | `./gradlew :ManagedBotsBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [MemberUpdatedWatcherBot](MemberUpdatedWatcherBot/) | Logs and reports bot/member status transitions in chats. | `./gradlew :MemberUpdatedWatcherBot:run --args="<BOT_TOKEN> debug"` |
|
||||
| [MyBot](MyBot/) † | Replaces or removes the bot's global profile photo and prints diagnostics. | `./gradlew :MyBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
@@ -49,8 +49,8 @@ sudo apt install libcurl4-gnutls-dev
|
||||
| [RandomFileSenderBot](RandomFileSenderBot/) | Picks random local files and sends them individually or as media groups. | `./gradlew :RandomFileSenderBot:runJvm --args="<BOT_TOKEN> <FILES_DIRECTORY>"` |
|
||||
| [ReactionsInfoBot](ReactionsInfoBot/) | Handles per-user reaction changes and anonymous reaction-count updates. | `./gradlew :ReactionsInfoBot:run --args="<BOT_TOKEN> debug"` |
|
||||
| [ResenderBot](ResenderBot/) | Recreates received content while preserving reply, quote, effect, and business context. | `./gradlew :ResenderBot:jvm_launcher:run --args="<BOT_TOKEN> debug"` |
|
||||
| [RichMessagesBot](RichMessagesBot/) | Demonstrates rich HTML/Markdown/blocks, streaming drafts, inline results, and media. | `./gradlew :RichMessagesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [RightsChangerBot](RightsChangerBot/) | Uses an FSM and inline keyboards to change member and administrator rights. | `./gradlew :RightsChangerBot:run --args="<BOT_TOKEN> <ALLOWED_USER_ID> debug"` |
|
||||
| [RichMessagesBot](RichMessagesBot/) | Demonstrates rich markup/blocks, buttons, documents, drafts, queries, and media. | `./gradlew :RichMessagesBot:run --args="<BOT_TOKEN> debug testServer"` |
|
||||
| [RightsChangerBot](RightsChangerBot/) | Uses an FSM and inline keyboards to change member and administrator rights, including welcome messages. | `./gradlew :RightsChangerBot:run --args="<BOT_TOKEN> <ALLOWED_USER_ID> debug"` |
|
||||
| [SlotMachineDetectorBot](SlotMachineDetectorBot/) | Detects slot-machine dice and decodes their reel values. | `./gradlew :SlotMachineDetectorBot:run --args="<BOT_TOKEN>"` |
|
||||
| [StarTransactionsBot](StarTransactionsBot/) | Demonstrates Stars invoices, transaction history, paid media, and refunds. | `./gradlew :StarTransactionsBot:run --args="<BOT_TOKEN> <ADMIN_USER_ID> debug testServer"` |
|
||||
| [StickerInfoBot](StickerInfoBot/) † | Looks up sticker-set metadata for stickers and custom emoji. | `./gradlew :StickerInfoBot:jvm_launcher:run --args="<BOT_TOKEN>"` |
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
RichMessagesBot is a long-polling showcase of Telegram rich messages. It sends
|
||||
rich content from HTML, Markdown, and the typed `InputRichMessageBlocks` DSL;
|
||||
streams drafts; edits rich text; handles incoming rich messages; and supplies
|
||||
rich content from inline and guest queries.
|
||||
rich content from inline and guest queries. Bot API 10.3 coverage includes rich
|
||||
buttons, compact tables, expandable quotations, document blocks, document
|
||||
references, direct document uploads, and stoppable drafts.
|
||||
|
||||
## Commands
|
||||
|
||||
The bot installs seven commands in Telegram's default command menu. Three
|
||||
The bot installs eight commands in Telegram's default command menu. Three
|
||||
additional handlers can be invoked by typing their commands manually.
|
||||
|
||||
| Command | In menu | Demonstration |
|
||||
@@ -18,21 +20,24 @@ additional handlers can be invoked by typing their commands manually.
|
||||
| `/rich_markdown_blocks` | No | Sends and logs the full fixture as a typed block tree, including first-class media blocks, captions, collages, and slideshows. |
|
||||
| `/rich_markdown_medialess_blocks` | No | Sends and logs the same typed block tree without its media section. |
|
||||
| `/rich_blocks` | Yes | Sends a smaller, directly constructed block tree with headings, formatted paragraphs, ordered and unordered checkbox lists, a divider, preformatted Kotlin, and a quotation. |
|
||||
| `/rich_draft` | Yes | Streams three Markdown revisions under draft ID `1`, one second apart, then sends a normal final rich message. |
|
||||
| `/rich_blocks_draft` | Yes | Streams two draft-only `thinking()` blocks under draft ID `2`, one second apart, then sends a normal typed-block answer. |
|
||||
| `/rich_10_3` | Yes | Sends an inline `RichTextButton`, three aligned `InputRichBlockButtons` rows with URL, callback, inline-query, copy-text, and disabled actions, a compact table, an expandable quotation, and a document block. |
|
||||
| `/rich_draft` | Yes | In a private chat, streams three Markdown revisions with `canStop` and `keepOnStop`, one second apart, then sends a normal final rich message unless generation is stopped. |
|
||||
| `/rich_blocks_draft` | Yes | In a private chat, streams two draft-only `thinking()` blocks with `canStop` and `keepOnStop`, then sends a normal typed-block answer unless generation is stopped. |
|
||||
| `/rich_edit` | Yes | Sends a Markdown rich message, waits two seconds, and replaces its rich content with `EditChatMessageRichText`. |
|
||||
| `/wait_rich` | Yes | Prompts for a rich message, waits for the next matching content, and reports its block count. |
|
||||
|
||||
The two draft examples finalize by sending a new normal rich message; they do not
|
||||
turn the draft itself into the final message. They use distinct fixed IDs (`1`
|
||||
and `2`); concurrent runs of the same command in one chat reuse that command's
|
||||
ID.
|
||||
The two draft examples allocate a unique draft ID and subscribe to
|
||||
`waitMessageGenerationStopped` before sending the first revision. They finalize
|
||||
by sending a new normal rich message only if no matching stop update arrives;
|
||||
when stopped, they leave the retained draft untouched and log the event.
|
||||
|
||||
## Other triggers
|
||||
|
||||
| Trigger | Behavior |
|
||||
| --- | --- |
|
||||
| Any photo | Reuses the received Telegram file ID without downloading it. The bot first sends HTML whose `tg://photo?id=userphoto` reference is resolved by `InputRichMessageMedia`, then sends the same photo as a typed `photo()` block. |
|
||||
| Any document | Reuses the received file ID through a `tg://document?id=userdocument` HTML reference, then downloads the file and sends it again as a multipart upload nested in a typed `document()` block. Large documents are therefore held in memory. |
|
||||
| A `rich_10_3_callback` rich button | Answers the callback with a notification confirming that the rich-message button was received. |
|
||||
| Any incoming rich message | Logs right-to-left state and every parsed block, replies with the block count, and resends the rich message with `createResend`. The `onlyRichMessageContentMessages` flow also logs its block count. |
|
||||
| Any inline query | Returns uncached HTML and Markdown articles whose selected messages use `InputRichMessageContent`. |
|
||||
| A text guest request containing `/rich_guest` | Returns one inline article containing the full Markdown fixture. This is a substring check, not a registered bot command. Non-text guest requests and text without that exact case-sensitive substring are ignored by this handler. |
|
||||
@@ -50,9 +55,9 @@ Telegram file ID and assigning an alias for a `tg://photo?id=...` reference.
|
||||
|
||||
The source also shows the two library shapes used for rich media: an
|
||||
`InputRichMessageMedia` mapping for markup references and first-class photo,
|
||||
video, audio, voice-note, animation, collage, and slideshow blocks. The library
|
||||
can collect multipart files nested in a rich-message tree as `attach://` uploads,
|
||||
although this example's running handlers use URLs or an existing file ID.
|
||||
video, audio, voice-note, animation, document, collage, and slideshow blocks.
|
||||
The document trigger demonstrates the library collecting a multipart file nested
|
||||
in a rich-message tree as an `attach://` upload.
|
||||
|
||||
## Telegram setup and permissions
|
||||
|
||||
@@ -60,7 +65,7 @@ although this example's running handlers use URLs or an existing file ID.
|
||||
control.
|
||||
2. Start a private chat with the bot, or add it to a chat and allow it to send
|
||||
messages and media.
|
||||
3. To test ordinary photo and rich-message triggers in a group, ensure Telegram
|
||||
3. To test ordinary photo, document, and rich-message triggers in a group, ensure Telegram
|
||||
delivers non-command messages to the bot, for example by disabling Group
|
||||
Privacy Mode or making the bot an administrator.
|
||||
4. Enable Inline Mode in BotFather to exercise the inline-query results.
|
||||
|
||||
@@ -5,14 +5,18 @@ import dev.inmo.kslog.common.setDefaultKSLog
|
||||
import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions
|
||||
import dev.inmo.tgbotapi.extensions.api.answers.answer
|
||||
import dev.inmo.tgbotapi.extensions.api.bot.setMyCommands
|
||||
import dev.inmo.tgbotapi.extensions.api.files.downloadFile
|
||||
import dev.inmo.tgbotapi.extensions.api.send.reply
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendRichMessage
|
||||
import dev.inmo.tgbotapi.extensions.api.send.sendRichMessageDraft
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitMessageGenerationStopped
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitRichMessage
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onBaseInlineQuery
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onCommand
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onDocument
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGuestRequestMessage
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onMessageDataCallbackQuery
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPhoto
|
||||
import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onRichMessage
|
||||
import dev.inmo.tgbotapi.extensions.utils.baseSentMessageUpdateOrNull
|
||||
@@ -21,16 +25,19 @@ import dev.inmo.tgbotapi.extensions.utils.onlyRichMessageContentMessages
|
||||
import dev.inmo.tgbotapi.extensions.utils.withContentOrNull
|
||||
import dev.inmo.tgbotapi.requests.edit.text.EditChatMessageRichText
|
||||
import dev.inmo.tgbotapi.requests.abstracts.InputFile
|
||||
import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile
|
||||
import dev.inmo.tgbotapi.types.BotCommand
|
||||
import dev.inmo.tgbotapi.types.CustomEmojiId
|
||||
import dev.inmo.tgbotapi.types.InlineQueries.InlineQueryResult.InlineQueryResultArticle
|
||||
import dev.inmo.tgbotapi.types.InlineQueries.InputMessageContent.InputRichMessageContent
|
||||
import dev.inmo.tgbotapi.types.InlineQueryId
|
||||
import dev.inmo.tgbotapi.types.TelegramDate
|
||||
import dev.inmo.tgbotapi.types.chat.PrivateChat
|
||||
import dev.inmo.tgbotapi.types.message.content.TextContent
|
||||
import dev.inmo.tgbotapi.types.message.textsources.BotCommandTextSource
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaAnimation
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaAudio
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaDocument
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaVideo
|
||||
import dev.inmo.tgbotapi.types.media.TelegramMediaVoiceNote
|
||||
@@ -40,19 +47,31 @@ import dev.inmo.tgbotapi.types.rich.InputRichMessageHTML
|
||||
import dev.inmo.tgbotapi.types.rich.InputRichMessageMarkdown
|
||||
import dev.inmo.tgbotapi.types.rich.InputRichMessageMedia
|
||||
import dev.inmo.tgbotapi.types.rich.RichBlockCaption
|
||||
import dev.inmo.tgbotapi.types.rich.RichBlockButtonAlignment
|
||||
import dev.inmo.tgbotapi.types.rich.RichBlockTableCellAlign
|
||||
import dev.inmo.tgbotapi.types.rich.RichBlockTableCellVAlign
|
||||
import dev.inmo.tgbotapi.types.rich.RichMessageButton
|
||||
import dev.inmo.tgbotapi.types.rich.RichMessageButtonStyle
|
||||
import dev.inmo.tgbotapi.types.rich.RichTextPlain
|
||||
import dev.inmo.tgbotapi.types.rich.buildRichText
|
||||
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.CopyTextButtonData
|
||||
import dev.inmo.tgbotapi.types.buttons.InlineKeyboardButtons.SwitchInlineQueryChosenChat
|
||||
import dev.inmo.tgbotapi.types.toChatId
|
||||
import dev.inmo.tgbotapi.utils.DraftIdAllocator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
private val richDraftIds = DraftIdAllocator()
|
||||
|
||||
/**
|
||||
* Runs a long-polling showcase of the rich-message APIs introduced in Telegram Bot API 10.1 and 10.2.
|
||||
* Runs a long-polling showcase of the rich-message APIs introduced in Telegram Bot API 10.1 through 10.3.
|
||||
*
|
||||
* Outgoing [dev.inmo.tgbotapi.types.rich.InputRichMessage] values use one of three representations:
|
||||
* [InputRichMessageHTML], [InputRichMessageMarkdown], or a typed [InputRichMessageBlocks] tree of
|
||||
@@ -60,7 +79,9 @@ import kotlinx.coroutines.flow.mapNotNull
|
||||
* [sendRichMessageDraft] revisions sharing a draft ID (including draft-only `thinking()` blocks), and edits
|
||||
* through [EditChatMessageRichText]. Media is shown both as [InputRichMessageMedia] references such as
|
||||
* `tg://photo?id=...` and as typed blocks; [dev.inmo.tgbotapi.requests.send.SendRichMessage] also turns
|
||||
* multipart files inside an input tree into `attach://` uploads.
|
||||
* multipart files inside an input tree into `attach://` uploads. Bot API 10.3 button rows, inline rich-text buttons,
|
||||
* compact tables, expandable quotations and document blocks are demonstrated by `/rich_10_3` and the document
|
||||
* trigger.
|
||||
*
|
||||
* Incoming [dev.inmo.tgbotapi.types.message.content.RichMessageContent] and user-selected content covers
|
||||
* [onRichMessage], [waitRichMessage], [onlyRichMessageContentMessages], photo reuse, and
|
||||
@@ -692,20 +713,40 @@ suspend fun main(vararg args: String) {
|
||||
|
||||
// sendRichMessageDraft: stream partial rich messages sharing one draftId, then finalize
|
||||
// with a full sendRichMessage. Emulates streaming of an AI-generated reply.
|
||||
onCommand("rich_draft") {
|
||||
val chatId = it.chat.id.toChatId()
|
||||
val draftId = 1L
|
||||
onCommand("rich_draft", initialFilter = { it.chat is PrivateChat }) { origin ->
|
||||
val chatId = origin.chat.id.toChatId()
|
||||
val draftId = richDraftIds.allocate()
|
||||
val stoppedUpdate = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
waitMessageGenerationStopped()
|
||||
.filter { it.chat.id == origin.chat.id && it.draftId == draftId }
|
||||
.first()
|
||||
}
|
||||
val parts = listOf(
|
||||
"Thinking",
|
||||
"Thinking about *rich* messages",
|
||||
"Thinking about *rich* messages and how to _stream_ them"
|
||||
)
|
||||
try {
|
||||
parts.forEach { part ->
|
||||
sendRichMessageDraft(chatId, draftId, InputRichMessageMarkdown(part))
|
||||
delay(1000)
|
||||
sendRichMessageDraft(
|
||||
chatId,
|
||||
draftId.long,
|
||||
InputRichMessageMarkdown(part),
|
||||
canStop = true,
|
||||
keepOnStop = true,
|
||||
)
|
||||
val stopped = withTimeoutOrNull(1000L) { stoppedUpdate.await() }
|
||||
if (stopped != null) {
|
||||
println("Stopped rich draft ${stopped.draftId.long} in ${stopped.chat.id}")
|
||||
return@onCommand
|
||||
}
|
||||
// finalize the streamed draft with the real message
|
||||
}
|
||||
// Finalize only if the user did not stop generation; a normal message removes a retained draft.
|
||||
sendRichMessage(chatId, InputRichMessageMarkdown("Done! Here is the *final* rich message."))
|
||||
} finally {
|
||||
stoppedUpdate.cancel()
|
||||
richDraftIds.free(draftId)
|
||||
}
|
||||
}
|
||||
|
||||
// EditChatMessageRichText: send a rich message, then edit it with new rich content
|
||||
@@ -772,20 +813,135 @@ suspend fun main(vararg args: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// === Bot API 10.3 additions: buttons, compact tables, expandable quotes and documents ===
|
||||
onCommand("rich_10_3") {
|
||||
val callbackButton = RichMessageButton.CallbackData(
|
||||
RichTextPlain("Callback"),
|
||||
callbackData = "rich_10_3_callback",
|
||||
style = RichMessageButtonStyle.Link,
|
||||
)
|
||||
sendRichMessage(
|
||||
it.chat.id,
|
||||
InputRichMessageBlocks {
|
||||
h1("Bot API 10.3 rich blocks")
|
||||
paragraph {
|
||||
plain("A RichTextButton can live inline with text: ")
|
||||
button(
|
||||
RichMessageButton.CopyText(
|
||||
RichTextPlain("copy 42"),
|
||||
CopyTextButtonData("42"),
|
||||
style = RichMessageButtonStyle.Primary,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
expandableBlockQuotation(credit = RichTextPlain("Expandable quotation credit")) {
|
||||
plain("This quotation starts collapsed and can be expanded by the reader. ")
|
||||
dateTime("Bot API 10.3", TelegramDate(1787518800L), "d MMMM yyyy")
|
||||
}
|
||||
|
||||
table(
|
||||
isBordered = true,
|
||||
isStriped = true,
|
||||
isCompact = true,
|
||||
caption = RichTextPlain("A compact table"),
|
||||
) {
|
||||
row {
|
||||
headerCell(RichBlockTableCellAlign.Left, RichBlockTableCellVAlign.Middle) { plain("Feature") }
|
||||
headerCell(RichBlockTableCellAlign.Right, RichBlockTableCellVAlign.Middle) { plain("Version") }
|
||||
}
|
||||
row {
|
||||
cell(RichBlockTableCellAlign.Left, RichBlockTableCellVAlign.Middle) { plain("Compact tables") }
|
||||
cell(RichBlockTableCellAlign.Right, RichBlockTableCellVAlign.Middle) { plain("10.3") }
|
||||
}
|
||||
}
|
||||
|
||||
buttons(
|
||||
listOf(
|
||||
RichMessageButton.Url(
|
||||
RichTextPlain("Telegram"),
|
||||
"https://telegram.org",
|
||||
RichMessageButtonStyle.Primary,
|
||||
),
|
||||
callbackButton,
|
||||
),
|
||||
align = RichBlockButtonAlignment.Left,
|
||||
)
|
||||
buttons(
|
||||
listOf(
|
||||
RichMessageButton.SwitchInlineQuery(
|
||||
RichTextPlain("Choose chat"),
|
||||
"rich 10.3",
|
||||
RichMessageButtonStyle.Success,
|
||||
),
|
||||
RichMessageButton.SwitchInlineQueryCurrentChat(
|
||||
RichTextPlain("Current chat"),
|
||||
"rich 10.3",
|
||||
),
|
||||
RichMessageButton.SwitchInlineQueryChosenChat(
|
||||
RichTextPlain("Groups only"),
|
||||
SwitchInlineQueryChosenChat(
|
||||
query = "rich 10.3",
|
||||
allowGroups = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
align = RichBlockButtonAlignment.Center,
|
||||
)
|
||||
buttons(
|
||||
listOf(
|
||||
RichMessageButton.CopyText(
|
||||
RichTextPlain("Copy value"),
|
||||
CopyTextButtonData("Bot API 10.3"),
|
||||
),
|
||||
RichMessageButton.Disabled(
|
||||
RichTextPlain("Disabled"),
|
||||
RichMessageButtonStyle.Danger,
|
||||
),
|
||||
),
|
||||
align = RichBlockButtonAlignment.Right,
|
||||
)
|
||||
|
||||
document(
|
||||
TelegramMediaDocument(
|
||||
InputFile.fromUrl("https://telegram.org/example/document.pdf")
|
||||
),
|
||||
RichBlockCaption(RichTextPlain("A general-file document block")),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onMessageDataCallbackQuery(Regex("rich_10_3_callback")) { query ->
|
||||
answer(query, "Rich-message callback received")
|
||||
}
|
||||
|
||||
// sendRichMessageDraft with blocks: the thinking() block is only valid inside a draft and is used
|
||||
// to stream a model's reasoning before the finalized rich message is sent via sendRichMessage.
|
||||
onCommand("rich_blocks_draft") {
|
||||
val chatId = it.chat.id.toChatId()
|
||||
val draftId = 2L
|
||||
onCommand("rich_blocks_draft", initialFilter = { it.chat is PrivateChat }) { origin ->
|
||||
val chatId = origin.chat.id.toChatId()
|
||||
val draftId = richDraftIds.allocate()
|
||||
val stoppedUpdate = async(start = CoroutineStart.UNDISPATCHED) {
|
||||
waitMessageGenerationStopped()
|
||||
.filter { it.chat.id == origin.chat.id && it.draftId == draftId }
|
||||
.first()
|
||||
}
|
||||
try {
|
||||
listOf("Analyzing your request", "Composing a structured answer").forEach { step ->
|
||||
sendRichMessageDraft(
|
||||
chatId,
|
||||
draftId,
|
||||
InputRichMessageBlocks { thinking(step) }
|
||||
draftId.long,
|
||||
InputRichMessageBlocks { thinking(step) },
|
||||
canStop = true,
|
||||
keepOnStop = true,
|
||||
)
|
||||
delay(1000)
|
||||
val stopped = withTimeoutOrNull(1000L) { stoppedUpdate.await() }
|
||||
if (stopped != null) {
|
||||
println("Stopped rich block draft ${stopped.draftId.long} in ${stopped.chat.id}")
|
||||
return@onCommand
|
||||
}
|
||||
// finalize the streamed draft with the real (non-thinking) blocks
|
||||
}
|
||||
// Finalize only if the user did not stop generation; a normal message removes a retained draft.
|
||||
sendRichMessage(
|
||||
chatId,
|
||||
InputRichMessageBlocks {
|
||||
@@ -793,6 +949,10 @@ suspend fun main(vararg args: String) {
|
||||
paragraph("Here is the finalized, structured reply.")
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
stoppedUpdate.cancel()
|
||||
richDraftIds.free(draftId)
|
||||
}
|
||||
}
|
||||
|
||||
// Rich message media: send me a photo and it gets embedded into a rich message two ways.
|
||||
@@ -828,6 +988,40 @@ suspend fun main(vararg args: String) {
|
||||
)
|
||||
}
|
||||
|
||||
// A received document demonstrates both tg://document?id= references and a direct multipart upload.
|
||||
onDocument { message ->
|
||||
val reusedDocument = TelegramMediaDocument(message.content.media.fileId)
|
||||
sendRichMessage(
|
||||
message.chat.id,
|
||||
InputRichMessageHTML(
|
||||
"""
|
||||
<h2>Your document, referenced from HTML</h2>
|
||||
<tg-document src="tg://document?id=userdocument"></tg-document>
|
||||
""".trimIndent(),
|
||||
media = listOf(
|
||||
InputRichMessageMedia(id = "userdocument", media = reusedDocument)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val uploadedDocument = TelegramMediaDocument(
|
||||
downloadFile(message.content).asMultipartFile(
|
||||
message.content.media.fileName ?: "document.bin"
|
||||
)
|
||||
)
|
||||
sendRichMessage(
|
||||
message.chat.id,
|
||||
InputRichMessageBlocks {
|
||||
h2("Your document, uploaded as a new file")
|
||||
paragraph("SendRichMessage collects the MultipartFile nested in this document block.")
|
||||
document(
|
||||
uploadedDocument,
|
||||
RichBlockCaption(RichTextPlain("Direct attach:// document upload")),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// waitRichMessage expectation: wait for the user to send a rich message
|
||||
onCommand("wait_rich") {
|
||||
reply(it, "Send me a rich message now")
|
||||
@@ -913,6 +1107,7 @@ suspend fun main(vararg args: String) {
|
||||
BotCommand("rich_html", "Send a rich message described with HTML"),
|
||||
BotCommand("rich_markdown", "Send a rich message described with Markdown"),
|
||||
BotCommand("rich_blocks", "Send a rich message built from the InputRichBlocks DSL"),
|
||||
BotCommand("rich_10_3", "Show Bot API 10.3 rich blocks and buttons"),
|
||||
BotCommand("rich_draft", "Stream a rich message draft, then finalize it"),
|
||||
BotCommand("rich_blocks_draft", "Stream a blocks draft with thinking(), then finalize it"),
|
||||
BotCommand("rich_edit", "Send a rich message and edit it with new rich content"),
|
||||
|
||||
@@ -19,7 +19,7 @@ Send `/rights_in_channel` in a private chat with the bot. Telegram's chat/user r
|
||||
|
||||
1. Select a channel where the bot is already a member. The picker requests `can_promote_members` and `can_restrict_members` for both the bot and the selecting user.
|
||||
2. Select a user, or send `/cancel` during either selection step.
|
||||
3. If the selected user is currently a channel administrator, the bot sends a keyboard for refreshing or toggling post-message, edit-message, delete-message, post-story, edit-story, and delete-story rights.
|
||||
3. If the selected user is currently a channel administrator, the bot sends a keyboard for refreshing or toggling post-message, edit-message, delete-message, post-story, edit-story, delete-story, and Bot API 10.3's send-welcome-messages rights.
|
||||
|
||||
Selecting a non-administrator ends the flow without a message. A stale keyboard whose target is later demoted can display **Promote to admin**; that action promotes the target with the post-messages right enabled. Telegram allows the bot to change an administrator's rights only when the bot promoted that administrator itself.
|
||||
|
||||
@@ -29,7 +29,7 @@ Callbacks are processed only when clicked by the configured allowed user. They a
|
||||
|
||||
- Create a bot with [@BotFather](https://t.me/BotFather) and obtain its token.
|
||||
- Choose one trusted Telegram numeric user ID as `ALLOWED_USER_ID`. `/simple` and all permission-changing callbacks are restricted to this ID.
|
||||
- Promote the bot in managed groups/channels. It needs `can_restrict_members` for member permissions and `can_promote_members` for channel administrator rights, plus enough rights to read member state and send/edit its keyboard messages. It cannot grant rights it does not possess.
|
||||
- Promote the bot in managed groups/channels. It needs `can_restrict_members` for member permissions and `can_promote_members` for channel administrator rights, plus enough rights to read member state and send/edit its keyboard messages. To grant `can_send_welcome_messages`, the bot must possess that right itself. It cannot grant rights it does not possess.
|
||||
- BotFather privacy mode can remain enabled because the group workflows use commands, replies, and callbacks.
|
||||
- Treat console output as sensitive: the example prints every raw update, and handler failures print stack traces.
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ suspend fun main(args: Array<String>) {
|
||||
val editStoriesToggleAdminRightsData = "${adminRightsDataPrefix}_edit_stories"
|
||||
val deleteStoriesToggleAdminRightsData = "${adminRightsDataPrefix}_delete_stories"
|
||||
val postStoriesToggleAdminRightsData = "${adminRightsDataPrefix}_post_stories"
|
||||
val sendWelcomeMessagesToggleAdminRightsData = "${adminRightsDataPrefix}_send_welcome_messages"
|
||||
|
||||
suspend fun BehaviourContext.getUserChatPermissions(chatId: ChatId, userId: UserId): ChatPermissions? {
|
||||
val chatMember = getChatMember(chatId, userId)
|
||||
@@ -182,6 +183,12 @@ suspend fun main(args: Array<String>) {
|
||||
row {
|
||||
dataButton("Post stories${permissions.canPostStories.allowedSymbol()}", "$postStoriesToggleAdminRightsData ${channelId.chatId} ${userId.chatId}")
|
||||
}
|
||||
row {
|
||||
dataButton(
|
||||
"Send welcome messages${permissions.canSendWelcomeMessages.allowedSymbol()}",
|
||||
"$sendWelcomeMessagesToggleAdminRightsData ${channelId.chatId} ${userId.chatId}"
|
||||
)
|
||||
}
|
||||
} ?: row {
|
||||
dataButton("Promote to admin", "$postMessagesToggleAdminRightsData ${channelId.chatId} ${userId.chatId}")
|
||||
}
|
||||
@@ -415,6 +422,7 @@ suspend fun main(args: Array<String>) {
|
||||
canEditStories = asAdmin ?.canEditStories.toggleIfData(editStoriesToggleAdminRightsData),
|
||||
canDeleteStories = asAdmin ?.canDeleteStories.toggleIfData(deleteStoriesToggleAdminRightsData),
|
||||
canPostStories = asAdmin ?.canPostStories.toggleIfData(postStoriesToggleAdminRightsData),
|
||||
canSendWelcomeMessages = asAdmin ?.canSendWelcomeMessages.toggleIfData(sendWelcomeMessagesToggleAdminRightsData),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ kotlin.daemon.jvmargs=-Xmx3g -Xms500m
|
||||
|
||||
|
||||
kotlin_version=2.3.20
|
||||
telegram_bot_api_version=36.0.0
|
||||
telegram_bot_api_version=37.0.0
|
||||
micro_utils_version=0.29.1
|
||||
serialization_version=1.10.0
|
||||
ktor_version=3.4.1
|
||||
|
||||
Reference in New Issue
Block a user