diff --git a/BusinessConnectionsBot/src/main/kotlin/BusinessConnectionsBot.kt b/BusinessConnectionsBot/src/main/kotlin/BusinessConnectionsBot.kt index 24ef4cf..7008207 100644 --- a/BusinessConnectionsBot/src/main/kotlin/BusinessConnectionsBot.kt +++ b/BusinessConnectionsBot/src/main/kotlin/BusinessConnectionsBot.kt @@ -3,6 +3,7 @@ import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog import dev.inmo.micro_utils.common.Percentage +import dev.inmo.tgbotapi.types.chat.PreviewBot import dev.inmo.tgbotapi.extensions.api.answers.answer import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.business.getBusinessAccountStarBalance @@ -27,7 +28,8 @@ import dev.inmo.tgbotapi.extensions.api.stories.deleteStory import dev.inmo.tgbotapi.extensions.api.stories.postStory import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.* -import dev.inmo.tgbotapi.extensions.utils.commonMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.chatContentMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.chatMessageOrNull import dev.inmo.tgbotapi.extensions.utils.extendedPrivateChatOrThrow import dev.inmo.tgbotapi.extensions.utils.ifAccessibleMessage import dev.inmo.tgbotapi.extensions.utils.ifBusinessContentMessage @@ -44,13 +46,15 @@ import dev.inmo.tgbotapi.types.MessageId import dev.inmo.tgbotapi.types.RawChatId import dev.inmo.tgbotapi.types.business_connection.BusinessConnectionId import dev.inmo.tgbotapi.types.chat.PrivateChat -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage +import dev.inmo.tgbotapi.types.message.content.LivePhotoContent import dev.inmo.tgbotapi.types.message.content.PhotoContent import dev.inmo.tgbotapi.types.message.content.StoryContent import dev.inmo.tgbotapi.types.message.content.TextContent import dev.inmo.tgbotapi.types.message.content.VideoContent import dev.inmo.tgbotapi.types.message.content.VisualMediaGroupPartContent import dev.inmo.tgbotapi.types.stories.InputStoryContent +import dev.inmo.tgbotapi.types.stories.InputStoryContent.* import dev.inmo.tgbotapi.types.stories.StoryArea import dev.inmo.tgbotapi.types.stories.StoryAreaPosition import dev.inmo.tgbotapi.types.stories.StoryAreaType @@ -120,6 +124,15 @@ suspend fun main(args: Array) { if (businessContentMessage.sentByBusinessConnectionOwner) { reply(sent, "You have sent this message to the ${businessContentMessage.businessConnectionId.string} related chat") } else { + // Since TG Bot API 9.0: business bots can reply to other bots in business context + // when bot-to-bot communication is enabled for both bots + if (businessContentMessage.from is PreviewBot) { + reply( + to = sent, + text = "Replying to bot ${businessContentMessage.from.firstName} in business context (bot-to-bot reply)", + ) + return@ifBusinessContentMessage + } reply( to = sent, text = "User have sent this message to you in the ${businessContentMessage.businessConnectionId.string} related chat", @@ -203,6 +216,8 @@ suspend fun main(args: Array) { } ) } + // Since TG Bot API 9.0: the following account management commands no longer require + // the connected user to have a Telegram Premium subscription. onCommandWithArgs("set_business_account_name", initialFilter = { it.chat is PrivateChat }) { it, args -> val firstName = args[0] val secondName = args.getOrNull(1) @@ -349,9 +364,9 @@ suspend fun main(args: Array) { } } } - suspend fun handleSetProfilePhoto(it: CommonMessage, isPublic: Boolean) { + suspend fun handleSetProfilePhoto(it: ChatContentMessage, isPublic: Boolean) { val businessConnectionId = chatsBusinessConnections[it.chat.id] ?: return@handleSetProfilePhoto - val replyTo = it.replyTo ?.commonMessageOrNull() ?.withContentOrNull() + val replyTo = it.replyTo ?.chatContentMessageOrNull() ?.withContentOrNull() if (replyTo == null) { reply(it) { +"Reply to photo for using of this command" @@ -411,7 +426,7 @@ suspend fun main(args: Array) { onCommand("post_story", initialFilter = { it.chat is PrivateChat }) { val businessConnectionId = chatsBusinessConnections[it.chat.id] ?: return@onCommand - val replyTo = it.replyTo ?.commonMessageOrNull() ?.withContentOrNull() + val replyTo = it.replyTo ?.chatContentMessageOrNull() ?.withContentOrNull() if (replyTo == null) { reply(it) { +"Reply to photo or video for using of this command" @@ -424,12 +439,16 @@ suspend fun main(args: Array) { postStory( businessConnectionId, when (replyTo.content) { - is PhotoContent -> InputStoryContent.Photo( + is PhotoContent -> Photo( file.multipartFile() ) - is VideoContent -> InputStoryContent.Video( + is VideoContent -> Video( file.multipartFile() ) + is LivePhotoContent -> Video( + file.multipartFile(), + isAnimation = true + ) }, activePeriod = PostStory.ACTIVE_PERIOD_6_HOURS, areas = listOf( @@ -465,7 +484,7 @@ suspend fun main(args: Array) { onCommand("delete_story", initialFilter = { it.chat is PrivateChat }) { val businessConnectionId = chatsBusinessConnections[it.chat.id] ?: return@onCommand - val replyTo = it.replyTo ?.commonMessageOrNull() ?.withContentOrNull() + val replyTo = it.replyTo ?.chatContentMessageOrNull() ?.withContentOrNull() if (replyTo == null) { reply(it) { +"Reply to photo or video for using of this command" diff --git a/ChatManagementBot/build.gradle b/ChatManagementBot/build.gradle new file mode 100644 index 0000000..10e61ab --- /dev/null +++ b/ChatManagementBot/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'kotlin' +apply plugin: 'application' + +mainClassName="ChatManagementBotKt" + + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + implementation "dev.inmo:tgbotapi:$telegram_bot_api_version" +} diff --git a/ChatManagementBot/src/main/kotlin/ChatManagementBot.kt b/ChatManagementBot/src/main/kotlin/ChatManagementBot.kt new file mode 100644 index 0000000..92502ed --- /dev/null +++ b/ChatManagementBot/src/main/kotlin/ChatManagementBot.kt @@ -0,0 +1,146 @@ +import dev.inmo.kslog.common.KSLog +import dev.inmo.kslog.common.LogLevel +import dev.inmo.kslog.common.defaultMessageFormatter +import dev.inmo.kslog.common.setDefaultKSLog +import dev.inmo.tgbotapi.extensions.api.bot.getMe +import dev.inmo.tgbotapi.extensions.api.chat.get.getChatAdministrators +import dev.inmo.tgbotapi.extensions.api.send.deleteAllUserMessageReactions +import dev.inmo.tgbotapi.extensions.api.send.deleteUserMessageReaction +import dev.inmo.tgbotapi.extensions.api.send.reply +import dev.inmo.tgbotapi.extensions.behaviour_builder.filters.chatMemberGotRestrictedFilter +import dev.inmo.tgbotapi.extensions.behaviour_builder.filters.chatMemberGotRestrictionsChangedFilter +import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onChatMemberUpdated +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.utils.plus +import dev.inmo.tgbotapi.extensions.utils.chatContentMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.contentMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.fromUserChatMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.fromUserMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.publicChatOrNull +import dev.inmo.tgbotapi.extensions.utils.restrictedMemberChatMemberOrNull +import dev.inmo.tgbotapi.types.chat.CommonBot +import dev.inmo.tgbotapi.types.chat.ChatPermissions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +/** + * This bot demonstrates Chat Management API features added in Bot API 9.x: + * + * 1. `can_react_to_messages` field in `ChatMemberRestricted` — printed when a member's + * restrictions are changed (requires the bot to be an admin in the group). + * `RestrictedMemberChatMember` also implements `ChatPermissions`, so the same field + * covers both `ChatMemberRestricted` and `ChatPermissions` from the spec. + * + * 2. `return_bots` in `getChatAdministrators` — `/admins` command lists all admins + * including other bots (retrieveOtherBots = true). + * + * 3. `deleteAllMessageReactions` — `/deleteallreactions` in reply to a message removes + * all reactions that the replied message's author has left across the entire chat. + * + * 4. `deleteMessageReaction` — `/deletereaction` in reply to a message removes the + * reaction the replied message's author placed on that specific message. + * + * 5. Seeing messages from other bots in groups — demonstrated via `canReadAllGroupMessages` + * from `getMe()`. When true (privacy mode off), the bot receives messages from other bots. + * All such messages are logged. + * + * Usage: pass the bot token as the first argument. Optional: `debug`, `testServer`. + */ +suspend fun main(vararg args: String) { + val botToken = args.first() + val isDebug = args.any { it == "debug" } + val isTestServer = args.any { it == "testServer" } + + if (isDebug) { + setDefaultKSLog( + KSLog { level: LogLevel, tag: String?, message: Any, throwable: Throwable? -> + println(defaultMessageFormatter(level, tag, message, throwable)) + } + ) + } + + telegramBotWithBehaviourAndLongPolling( + botToken, + CoroutineScope(Dispatchers.IO), + testServer = isTestServer + ) { + val me = getMe() + println("Bot: ${me.firstName} (@${me.username?.username})") + + // Feature 5: canReadAllGroupMessages (can_read_all_group_messages) from getMe() + // When true, the bot receives messages from other bots in groups (privacy mode off) + println("canReadAllGroupMessages: ${me.canReadAllGroupMessages}") + + // Feature 1: can_react_to_messages in ChatMemberRestricted and ChatPermissions + // RestrictedMemberChatMember implements ChatPermissions, so canReactToMessages + // appears in both types as required by the Telegram Bot API spec + onChatMemberUpdated( + initialFilter = chatMemberGotRestrictedFilter + chatMemberGotRestrictionsChangedFilter + ) { update -> + val restricted = update.newChatMemberState.restrictedMemberChatMemberOrNull() + ?: return@onChatMemberUpdated + println("Restriction update for ${update.member.firstName}:") + // canReactToMessages as ChatMemberRestricted field + println(" canReactToMessages (ChatMemberRestricted): ${restricted.canReactToMessages}") + // same field via ChatPermissions — RestrictedMemberChatMember : ChatPermissions + val permissions: ChatPermissions = restricted + println(" canReactToMessages (ChatPermissions): ${permissions.canReactToMessages}") + } + + // Feature 2: return_bots parameter in getChatAdministrators + // retrieveOtherBots = true corresponds to return_bots = true in the Telegram API + onCommand("admins") { message -> + val chat = message.chat.publicChatOrNull() ?: run { + reply(message) { +"This command works only in groups/supergroups/channels" } + return@onCommand + } + val admins = getChatAdministrators(chat, retrieveOtherBots = true) + reply(message) { + +"Administrators (retrieveOtherBots=true, includes bots):\n" + admins.forEach { admin -> + val kind = if (admin.user is CommonBot) "bot" else "user" + +"• ${admin.user.firstName} [$kind]\n" + } + } + } + + // Feature 4: deleteMessageReaction + // Deletes a specific reaction by the replied message's author on that message + onCommand("deletereaction") { message -> + val replied = message.replyTo ?.fromUserChatMessageOrNull() ?: run { + reply(message) { +"Reply to a message to remove that user's reaction from it" } + return@onCommand + } + deleteUserMessageReaction(replied, replied.user.id) + reply(message) { +"Deleted reaction by ${replied.user.firstName} on the replied message" } + } + + // Feature 3: deleteAllMessageReactions + // Deletes all reactions that the replied message's author has left in this chat + onCommand("deleteallreactions") { message -> + val replied = message.replyTo?.fromUserMessageOrNull() ?: run { + reply(message) { +"Reply to a message to clear all reactions of that user in this chat" } + return@onCommand + } + deleteAllUserMessageReactions(message.chat, replied.user.id) + reply(message) { +"Deleted all reactions by ${replied.user.firstName} in this chat" } + } + + // Feature 5: messages from other bots in groups + // Bots with canReadAllGroupMessages=true (privacy mode off) receive messages from other bots. + // This handler logs all such messages to demonstrate the feature. + onContentMessage( + initialFilter = { msg -> + val user = msg.fromUserMessageOrNull()?.user + user is CommonBot && user.id != me.id + } + ) { message -> + val sender = message.fromUserMessageOrNull()?.user + println("Message from other bot received (canReadAllGroupMessages=${me.canReadAllGroupMessages}):") + println(" sender: ${sender?.firstName} (@${(sender as? CommonBot)?.username?.username})") + println(" content: ${message.content}") + } + }.second.join() +} diff --git a/ChecklistsBot/src/main/kotlin/ChecklistsBot.kt b/ChecklistsBot/src/main/kotlin/ChecklistsBot.kt index bc69334..8959934 100644 --- a/ChecklistsBot/src/main/kotlin/ChecklistsBot.kt +++ b/ChecklistsBot/src/main/kotlin/ChecklistsBot.kt @@ -4,7 +4,6 @@ import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog import dev.inmo.micro_utils.coroutines.runCatchingLogging import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.bot.getMyStarBalance import dev.inmo.tgbotapi.extensions.api.chat.get.getChat @@ -33,7 +32,7 @@ import dev.inmo.tgbotapi.extensions.utils.previewChannelDirectMessagesChatOrNull import dev.inmo.tgbotapi.extensions.utils.suggestedChannelDirectMessagesContentMessageOrNull import dev.inmo.tgbotapi.types.checklists.ChecklistTaskId import dev.inmo.tgbotapi.types.message.SuggestedPostParameters -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage import dev.inmo.tgbotapi.types.message.content.ChecklistContent import dev.inmo.tgbotapi.types.message.textsources.TextSourcesList import dev.inmo.tgbotapi.types.update.abstracts.Update diff --git a/CustomBot/src/main/kotlin/CustomBot.kt b/CustomBot/src/main/kotlin/CustomBot.kt index bf493d3..d7c3b95 100644 --- a/CustomBot/src/main/kotlin/CustomBot.kt +++ b/CustomBot/src/main/kotlin/CustomBot.kt @@ -2,7 +2,7 @@ import dev.inmo.kslog.common.KSLog import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.bot.getMyStarBalance import dev.inmo.tgbotapi.extensions.api.chat.get.getChat @@ -22,7 +22,7 @@ import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPhoto import dev.inmo.tgbotapi.types.media.AudioMediaGroupMemberTelegramMedia import dev.inmo.tgbotapi.types.media.toTelegramMediaAudio import dev.inmo.tgbotapi.types.media.toTelegramPaidMediaPhoto -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage import dev.inmo.tgbotapi.types.update.abstracts.Update import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -31,8 +31,8 @@ private var BehaviourContextData.update: Update? get() = get("update") as? Update set(value) = set("update", value) -private var BehaviourContextData.commonMessage: CommonMessage<*>? - get() = get("commonMessage") as? CommonMessage<*> +private var BehaviourContextData.commonMessage: ChatContentMessage<*>? + get() = get("commonMessage") as? ChatContentMessage<*> set(value) = set("commonMessage", value) /** @@ -129,7 +129,7 @@ suspend fun main(vararg args: String) { println(it.chatEvent) } - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } }.second.join() diff --git a/DeepLinksBot/src/main/kotlin/DeepLinksBot.kt b/DeepLinksBot/src/main/kotlin/DeepLinksBot.kt index c7f1a63..f59796b 100644 --- a/DeepLinksBot/src/main/kotlin/DeepLinksBot.kt +++ b/DeepLinksBot/src/main/kotlin/DeepLinksBot.kt @@ -1,4 +1,4 @@ -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.send.reply import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitDeepLinks @@ -36,7 +36,7 @@ suspend fun main(vararg args: String) { onDeepLink { (it, deepLink) -> reply(it, "Ok, I got deep link \"${deepLink}\" in trigger") } - waitDeepLinks().subscribeSafelyWithoutExceptions(this) { (it, deepLink) -> + waitDeepLinks().subscribeLoggingDropExceptions(this) { (it, deepLink) -> reply(it, "Ok, I got deep link \"${deepLink}\" in waiter") println(triggersHolder.handleableCommandsHolder.handleable) } diff --git a/DraftsBot/src/main/kotlin/DraftsBot.kt b/DraftsBot/src/main/kotlin/DraftsBot.kt index 8da1208..cb54e3c 100644 --- a/DraftsBot/src/main/kotlin/DraftsBot.kt +++ b/DraftsBot/src/main/kotlin/DraftsBot.kt @@ -3,7 +3,6 @@ import dev.inmo.kslog.common.w import dev.inmo.micro_utils.coroutines.runCatchingLogging import dev.inmo.micro_utils.coroutines.runCatchingSafely import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions import dev.inmo.tgbotapi.bot.TelegramBot import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.bot.setMyCommands @@ -75,8 +74,29 @@ suspend fun main(vararg args: String) { send(it.chat, testText) } + // 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") { + sendMessageDraftFlowWithTexts( + it.chat.id, + flow { + emit("") // empty draft — clears / initializes typing indicator with no content + delay(1500L) + val step = 50 + var currentLength = step + while (isActive && testText.length > currentLength) { + delay(500L) + emit(testText.take(currentLength)) + currentLength += step + } + }, + ) + send(it.chat, testText) + } + 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 ) allUpdatesFlow.subscribeLoggingDropExceptions(this) { diff --git a/FSMBot/src/main/kotlin/SimpleFSMBot.kt b/FSMBot/src/main/kotlin/SimpleFSMBot.kt index 0607274..8bbb96e 100644 --- a/FSMBot/src/main/kotlin/SimpleFSMBot.kt +++ b/FSMBot/src/main/kotlin/SimpleFSMBot.kt @@ -1,5 +1,5 @@ import dev.inmo.micro_utils.coroutines.awaitFirst -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.micro_utils.fsm.common.State import dev.inmo.tgbotapi.extensions.api.send.send import dev.inmo.tgbotapi.extensions.behaviour_builder.expectations.waitAnyContentMessage @@ -13,7 +13,7 @@ import dev.inmo.tgbotapi.extensions.utils.extensions.sameThread import dev.inmo.tgbotapi.extensions.utils.textContentOrNull import dev.inmo.tgbotapi.extensions.utils.withContentOrNull import dev.inmo.tgbotapi.types.IdChatIdentifier -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage import dev.inmo.tgbotapi.types.message.content.TextContent import dev.inmo.tgbotapi.utils.botCommand import dev.inmo.tgbotapi.utils.firstOf @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map sealed interface BotState : State -data class ExpectContentOrStopState(override val context: IdChatIdentifier, val sourceMessage: CommonMessage) : BotState +data class ExpectContentOrStopState(override val context: IdChatIdentifier, val sourceMessage: ChatContentMessage) : BotState data class StopState(override val context: IdChatIdentifier) : BotState suspend fun main(args: Array) { @@ -97,7 +97,7 @@ suspend fun main(args: Array) { startChain(ExpectContentOrStopState(it.chat.id, it.withContentOrNull() ?: return@onContentMessage)) } - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } }.second.join() diff --git a/FilesLoaderBot/src/main/kotlin/FilesLoaderBot.kt b/FilesLoaderBot/src/main/kotlin/FilesLoaderBot.kt index f9df093..b97a454 100644 --- a/FilesLoaderBot/src/main/kotlin/FilesLoaderBot.kt +++ b/FilesLoaderBot/src/main/kotlin/FilesLoaderBot.kt @@ -1,4 +1,4 @@ -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.files.downloadFile import dev.inmo.tgbotapi.extensions.api.files.downloadFileToTemp import dev.inmo.tgbotapi.extensions.api.get.getFileAdditionalInfo @@ -10,6 +10,7 @@ import dev.inmo.tgbotapi.requests.abstracts.asMultipartFile import dev.inmo.tgbotapi.types.actions.* import dev.inmo.tgbotapi.types.media.TelegramMediaAudio import dev.inmo.tgbotapi.types.media.TelegramMediaDocument +import dev.inmo.tgbotapi.types.media.TelegramMediaLivePhoto import dev.inmo.tgbotapi.types.media.TelegramMediaPhoto import dev.inmo.tgbotapi.types.media.TelegramMediaVideo import dev.inmo.tgbotapi.types.message.content.* @@ -46,6 +47,7 @@ suspend fun main(args: Array) { val action = when (content) { is PhotoContent -> UploadPhotoAction is AnimationContent, + is LivePhotoContent, is VideoContent -> UploadVideoAction is StickerContent -> ChooseStickerAction is MediaGroupContent<*> -> UploadPhotoAction @@ -74,7 +76,7 @@ suspend fun main(args: Array) { ) is MediaGroupContent<*> -> replyWithMediaGroup( it, - content.group.map { + content.group.mapNotNull { when (val innerContent = it.content) { is AudioContent -> TelegramMediaAudio( downloadFileToTemp(innerContent.media).asMultipartFile() @@ -88,6 +90,10 @@ suspend fun main(args: Array) { is VideoContent -> TelegramMediaVideo( downloadFileToTemp(innerContent.media).asMultipartFile() ) + is LivePhotoContent -> TelegramMediaLivePhoto( + downloadFileToTemp(innerContent.media).asMultipartFile(), + innerContent.media.photo ?.fileId ?: return@mapNotNull null + ) } } ) @@ -107,10 +113,16 @@ suspend fun main(args: Array) { it, outFile.asMultipartFile() ) + + is LivePhotoContent -> replyWithLivePhoto( + it, + outFile.asMultipartFile(), + content.media.photo ?.fileId ?: error("Unable to resend live photo files without their photos") + ) } } } } - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { println(it) } + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } }.second.join() } diff --git a/GiftsBot/src/main/kotlin/GiftsBot.kt b/GiftsBot/src/main/kotlin/GiftsBot.kt index efb3678..0fc6d37 100644 --- a/GiftsBot/src/main/kotlin/GiftsBot.kt +++ b/GiftsBot/src/main/kotlin/GiftsBot.kt @@ -2,7 +2,6 @@ import dev.inmo.kslog.common.KSLog import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.business.getBusinessAccountGiftsFlow import dev.inmo.tgbotapi.extensions.api.gifts.getChatGiftsFlow @@ -105,7 +104,7 @@ suspend fun main(vararg args: String) { } } -// allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { +// allUpdatesFlow.subscribeLoggingDropExceptions(this) { // println(it) // } }.second.join() diff --git a/GiveawaysBot/src/main/kotlin/GiveawaysBot.kt b/GiveawaysBot/src/main/kotlin/GiveawaysBot.kt index e00fe9e..b6a12cb 100644 --- a/GiveawaysBot/src/main/kotlin/GiveawaysBot.kt +++ b/GiveawaysBot/src/main/kotlin/GiveawaysBot.kt @@ -2,7 +2,7 @@ import dev.inmo.kslog.common.KSLog import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onGiveawayCompleted @@ -50,7 +50,7 @@ suspend fun main(vararg args: String) { println(it) } -// allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { +// allUpdatesFlow.subscribeLoggingDropExceptions(this) { // println(it) // } }.second.join() diff --git a/HelloBot/src/main/kotlin/HelloBot.kt b/HelloBot/src/main/kotlin/HelloBot.kt index 9e0f995..98f53a2 100644 --- a/HelloBot/src/main/kotlin/HelloBot.kt +++ b/HelloBot/src/main/kotlin/HelloBot.kt @@ -1,4 +1,4 @@ -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions 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 @@ -77,6 +77,6 @@ suspend fun main(vararg args: String) { MarkdownV2 ) } - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { println(it) } + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } }.second.join() } diff --git a/LiveLocationsBot/src/main/kotlin/LiveLocationsBot.kt b/LiveLocationsBot/src/main/kotlin/LiveLocationsBot.kt index 8614366..99e5e29 100644 --- a/LiveLocationsBot/src/main/kotlin/LiveLocationsBot.kt +++ b/LiveLocationsBot/src/main/kotlin/LiveLocationsBot.kt @@ -1,4 +1,4 @@ -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.EditLiveLocationInfo import dev.inmo.tgbotapi.extensions.api.edit.location.live.stopLiveLocation import dev.inmo.tgbotapi.extensions.api.handleLiveLocation @@ -61,7 +61,7 @@ suspend fun main(vararg args: String) { stopLiveLocation(it, replyMarkup = null) } } - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { println(it) } + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } }.second.join() } diff --git a/LivePhotosBot/build.gradle b/LivePhotosBot/build.gradle new file mode 100644 index 0000000..8cef7ce --- /dev/null +++ b/LivePhotosBot/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +apply plugin: 'kotlin' +apply plugin: 'application' + +mainClassName="LivePhotosBotKt" + + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + implementation "dev.inmo:tgbotapi:$telegram_bot_api_version" +} diff --git a/LivePhotosBot/src/main/kotlin/LivePhotosBot.kt b/LivePhotosBot/src/main/kotlin/LivePhotosBot.kt new file mode 100644 index 0000000..3210250 --- /dev/null +++ b/LivePhotosBot/src/main/kotlin/LivePhotosBot.kt @@ -0,0 +1,166 @@ +import dev.inmo.kslog.common.KSLog +import dev.inmo.kslog.common.LogLevel +import dev.inmo.kslog.common.defaultMessageFormatter +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.send.media.sendLivePhoto +import dev.inmo.tgbotapi.extensions.api.send.media.sendMediaGroup +import dev.inmo.tgbotapi.extensions.api.send.media.sendPaidMedia +import dev.inmo.tgbotapi.extensions.api.send.reply +import dev.inmo.tgbotapi.extensions.behaviour_builder.telegramBotWithBehaviourAndLongPolling +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onEditedLivePhoto +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onLivePhoto +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onLivePhotoGallery +import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPaidMediaInfoContent +import dev.inmo.tgbotapi.extensions.utils.withContentOrNull +import dev.inmo.tgbotapi.types.message.content.LivePhotoContent +import dev.inmo.tgbotapi.types.message.payments.PaidMedia +import dev.inmo.tgbotapi.types.media.TelegramMediaLivePhoto +import dev.inmo.tgbotapi.types.media.TelegramPaidMediaLivePhoto +import dev.inmo.tgbotapi.types.media.toTelegramPaidMediaLivePhoto +import dev.inmo.tgbotapi.utils.RiskFeature +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers + +/** + * This bot demonstrates Live Photos support introduced in Telegram Bot API. + * + * Key concepts demonstrated: + * - [dev.inmo.tgbotapi.types.files.LivePhotoFile] — the LivePhoto class: a photo with an attached short video + * - [TelegramMediaLivePhoto] — InputMediaLivePhoto: used in sendMediaGroup and editMessageMedia + * - [LivePhotoContent] — the content type carried in Message.live_photo / ExternalReplyInfo.live_photo + * - [sendLivePhoto] — method to send a live photo + * - [PaidMedia.LivePhoto] — PaidMediaLivePhoto: a live photo inside paid media content + * - [TelegramPaidMediaLivePhoto] — InputPaidMediaLivePhoto: used in sendPaidMedia + * - sendMediaGroup and editMessageMedia with live photos + */ +@OptIn(RiskFeature::class) +suspend fun main(vararg args: String) { + val botToken = args.first() + val isDebug = args.any { it == "debug" } + val isTestServer = args.any { it == "testServer" } + + if (isDebug) { + setDefaultKSLog( + KSLog { level: LogLevel, tag: String?, message: Any, throwable: Throwable? -> + println(defaultMessageFormatter(level, tag, message, throwable)) + } + ) + } + + telegramBotWithBehaviourAndLongPolling( + botToken, + CoroutineScope(Dispatchers.IO), + testServer = isTestServer + ) { + + // Demonstrates: LivePhoto class (LivePhotoFile), live_photo field in Message, sendLivePhoto, + // InputMediaLivePhoto (TelegramMediaLivePhoto), InputPaidMediaLivePhoto (TelegramPaidMediaLivePhoto), + // editMessageMedia with live photo + onLivePhoto { message -> + // message.content is LivePhotoContent — this is the live_photo field of Message + val content: LivePhotoContent = message.content + + // content.media is LivePhotoFile — the LivePhoto class (photo + short video in one file) + val livePhotoFile = content.media + println("=== Live photo received ===") + println(" fileId: ${livePhotoFile.fileId}") + println(" fileUniqueId: ${livePhotoFile.fileUniqueId}") + println(" width: ${livePhotoFile.width}") + println(" height: ${livePhotoFile.height}") + println(" duration: ${livePhotoFile.duration}s") + println(" photo (thumb): ${livePhotoFile.photo?.fileId}") + println(" mimeType: ${livePhotoFile.mimeType}") + println(" fileSize: ${livePhotoFile.fileSize}") + println(" caption: ${content.text}") + + // sendLivePhoto: resend the received live photo back using LivePhotoFile overload + val sent = sendLivePhoto( + chatId = message.chat.id, + livePhoto = livePhotoFile, + text = "Resent via sendLivePhoto" + ) + 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)" + ) + + // editMessageMedia with InputMediaLivePhoto (TelegramMediaLivePhoto): + // edit the previously sent message to replace it with itself via TelegramMediaLivePhoto + val sentAsMedia = sent.withContentOrNull() + 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" + ) + ) + } + } + + // Demonstrates: sendMediaGroup with live photos, InputMediaLivePhoto (TelegramMediaLivePhoto) + onLivePhotoGallery { mediaGroupContent -> + println("=== Live photo gallery received (${mediaGroupContent.group.size} items) ===") + mediaGroupContent.group.forEach { groupMember -> + val livePhotoFile = groupMember.content.media + println(" - fileId: ${livePhotoFile.fileId}, ${livePhotoFile.width}x${livePhotoFile.height}") + } + + // sendMediaGroup with TelegramMediaLivePhoto (InputMediaLivePhoto) + sendMediaGroup( + chatId = mediaGroupContent.group.first().sourceMessage.chat.id, + media = mediaGroupContent.group.map { groupMember -> + val livePhotoFile = groupMember.content.media + // TelegramMediaLivePhoto is InputMediaLivePhoto — used here in sendMediaGroup + TelegramMediaLivePhoto( + file = livePhotoFile.fileId, + photo = livePhotoFile.photo?.fileId ?: livePhotoFile.fileId + ) + } + ) + } + + // Demonstrates: PaidMediaLivePhoto (PaidMedia.LivePhoto) in received paid media content + onPaidMediaInfoContent { message -> + val paidMedia = message.content.paidMediaInfo.media + val livePhotos = paidMedia.filterIsInstance() + if (livePhotos.isNotEmpty()) { + println("=== Paid media with live photos received ===") + livePhotos.forEach { paidLivePhoto -> + // paidLivePhoto is PaidMedia.LivePhoto — PaidMediaLivePhoto class + val livePhotoFile = paidLivePhoto.livePhoto + println(" - fileId: ${livePhotoFile.fileId}, ${livePhotoFile.width}x${livePhotoFile.height}") + println(" duration: ${livePhotoFile.duration}s") + } + reply(message, "Received ${livePhotos.size} paid live photo(s)") + } + } + + // Demonstrates: live_photo field in edited messages (EditedMessage with LivePhotoContent) + onEditedLivePhoto { message -> + println("=== Edited live photo received ===") + println(" fileId: ${message.content.media.fileId}") + println(" caption: ${message.content.text}") + } + + allUpdatesFlow.subscribeLoggingDropExceptions(scope = this) { + println(it) + } + }.second.join() +} diff --git a/ManagedBotsBot/src/main/kotlin/ManagedBotsBot.kt b/ManagedBotsBot/src/main/kotlin/ManagedBotsBot.kt index 191a850..cb71852 100644 --- a/ManagedBotsBot/src/main/kotlin/ManagedBotsBot.kt +++ b/ManagedBotsBot/src/main/kotlin/ManagedBotsBot.kt @@ -5,15 +5,20 @@ 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.bot.getMe +import dev.inmo.tgbotapi.extensions.api.getUserPersonalChatMessages import dev.inmo.tgbotapi.extensions.api.chat.get.getChat +import dev.inmo.tgbotapi.extensions.api.managed_bots.getManagedBotAccessSettings import dev.inmo.tgbotapi.extensions.api.managed_bots.getManagedBotToken import dev.inmo.tgbotapi.extensions.api.managed_bots.replaceManagedBotToken +import dev.inmo.tgbotapi.extensions.api.managed_bots.setManagedBotAccessSettings import dev.inmo.tgbotapi.extensions.api.send.reply import dev.inmo.tgbotapi.extensions.api.send.send +import dev.inmo.tgbotapi.extensions.api.send.sendMessage import dev.inmo.tgbotapi.extensions.behaviour_builder.BehaviourContextData import dev.inmo.tgbotapi.extensions.behaviour_builder.buildSubcontextInitialAction 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.onCommandWithArgs import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onManagedBotCreated import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onManagedBotUpdated import dev.inmo.tgbotapi.extensions.utils.chatEventMessageOrNull @@ -22,10 +27,12 @@ import dev.inmo.tgbotapi.extensions.utils.managedBotCreatedOrNull import dev.inmo.tgbotapi.extensions.utils.types.buttons.flatReplyKeyboard import dev.inmo.tgbotapi.extensions.utils.types.buttons.replyKeyboard import dev.inmo.tgbotapi.extensions.utils.types.buttons.requestManagedBotButton +import dev.inmo.tgbotapi.types.ChatId +import dev.inmo.tgbotapi.types.RawChatId import dev.inmo.tgbotapi.types.Username import dev.inmo.tgbotapi.types.buttons.KeyboardButtonRequestManagedBot import dev.inmo.tgbotapi.types.buttons.PreparedKeyboardButtonId -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage import dev.inmo.tgbotapi.types.request.RequestId import dev.inmo.tgbotapi.types.toChatId import dev.inmo.tgbotapi.types.update.abstracts.Update @@ -36,8 +43,8 @@ private var BehaviourContextData.update: Update? get() = get("update") as? Update set(value) = set("update", value) -private var BehaviourContextData.commonMessage: CommonMessage<*>? - get() = get("commonMessage") as? CommonMessage<*> +private var BehaviourContextData.commonMessage: ChatContentMessage<*>? + get() = get("commonMessage") as? ChatContentMessage<*> set(value) = set("commonMessage", value) /** @@ -114,18 +121,16 @@ suspend fun main(vararg args: String) { } onManagedBotCreated { - reply(it, "Managed bot created successfully: ${it.chatEvent.bot}") - val token = getManagedBotToken( - it.chatEvent.bot.id.toChatId() - ) + val botChatId = it.chatEvent.bot.id.toChatId() + reply(it, "Managed bot created successfully: ${it.chatEvent.bot}\nBot ID: ${botChatId.chatId.long}") + val token = getManagedBotToken(botChatId) reply(it, "Token: $token") } onManagedBotUpdated { - send(it.user, "Managed bot has been updated: ${it.bot}") - val token = getManagedBotToken( - it.bot.id.toChatId() - ) + val botChatId = it.bot.id.toChatId() + send(it.user, "Managed bot has been updated: ${it.bot}\nBot ID: ${botChatId.chatId.long}") + val token = getManagedBotToken(botChatId) send(it.user, "Token: $token") } @@ -136,6 +141,71 @@ suspend fun main(vararg args: String) { reply(it, "Token in replace update: ${replaceManagedBotToken(managedBotCreated.bot.id.toChatId())}") } + // getManagedBotAccessSettings — show BotAccessSettings: who can access the given managed bot + // Usage: /get_bot_access_settings + onCommandWithArgs("get_bot_access_settings") { message, args -> + val botId = args.firstOrNull()?.toLongOrNull()?.let(::RawChatId)?.toChatId() + ?: run { reply(message, "Usage: /get_bot_access_settings \n(Bot ID shown after /keyboard → create bot)"); return@onCommandWithArgs } + val settings = runCatching { getManagedBotAccessSettings(botId) }.getOrElse { + reply(message, "Error: ${it.message}"); return@onCommandWithArgs + } + reply(message, buildString { + append("Access settings for managed bot $botId:\n") + append(" isAccessRestricted: ${settings.isAccessRestricted}\n") + if (settings.addedUsers != null) { + append(" allowedUsers: ${settings.addedUsers!!.joinToString { "${it.firstName} (${it.id})" }}") + } else { + append(" allowedUsers: all (unrestricted)") + } + }) + } + + // setManagedBotAccessSettings — restrict access to a list of user IDs, or open to all + // Usage: /set_bot_access_settings [userId1 userId2 ...] + // Omit userIds to open access to all users (addedUserIds = null) + onCommandWithArgs("set_bot_access_settings") { message, args -> + val botId = args.firstOrNull()?.toLongOrNull()?.let(::RawChatId)?.toChatId() + ?: run { reply(message, "Usage: /set_bot_access_settings [userId1 userId2 ...]"); return@onCommandWithArgs } + val allowedIds = args.drop(1).mapNotNull { it.toLongOrNull()?.let(::RawChatId)?.toChatId() } + val addedUserIds: List? = allowedIds.ifEmpty { null } + runCatching { + setManagedBotAccessSettings(botId, addedUserIds) + }.onSuccess { + reply(message, if (addedUserIds == null) "Access opened to all users." else "Access restricted to ${addedUserIds.size} user(s).") + }.onFailure { + reply(message, "Error: ${it.message}") + } + } + + // getUserPersonalChatMessages — get recent messages from the user's personal channel + // Works only if the user has a personal channel linked to their account + onCommand("get_personal_messages") { + val msg = it + val userId = msg.chat.id.toChatId() + val messages = runCatching { getUserPersonalChatMessages(userId, limit = 10) }.getOrElse { e -> + reply(msg, "Error: ${e.message}"); return@onCommand + } + reply(msg, "Personal channel messages (${messages.size}):\n" + + messages.joinToString("\n") { m -> " [${m.messageId}] ${m.content::class.simpleName}" } + .ifEmpty { " (none)" } + ) + } + + // Bot-to-bot communication: send a message to another bot by @username + // Since TG Bot API 9.0: works if both bots have bot-to-bot communication enabled in BotFather + onCommandWithArgs("send_to_bot") { message, args -> + val usernameArg = args.firstOrNull() ?: run { reply(message, "Usage: /send_to_bot @username [text]"); return@onCommandWithArgs } + val targetUsername = Username.prepare(usernameArg) + val text = args.drop(1).joinToString(" ").ifEmpty { "Hello from bot-to-bot communication!" } + runCatching { + sendMessage(targetUsername, text) + }.onSuccess { + reply(message, "Message sent to $targetUsername") + }.onFailure { + reply(message, "Failed to send to $targetUsername: ${it.message}") + } + } + allUpdatesFlow.subscribeLoggingDropExceptions(this) { println(it) } diff --git a/PollsBot/src/main/kotlin/PollsBot.kt b/PollsBot/src/main/kotlin/PollsBot.kt index e4e27db..7a4cf66 100644 --- a/PollsBot/src/main/kotlin/PollsBot.kt +++ b/PollsBot/src/main/kotlin/PollsBot.kt @@ -2,7 +2,7 @@ import dev.inmo.kslog.common.KSLog import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions +import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions import dev.inmo.tgbotapi.extensions.api.bot.setMyCommands import dev.inmo.tgbotapi.extensions.api.send.polls.sendQuizPoll import dev.inmo.tgbotapi.extensions.api.send.polls.sendRegularPoll @@ -16,14 +16,18 @@ import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPollOp import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPollOptionDeleted import dev.inmo.tgbotapi.extensions.behaviour_builder.triggers_handling.onPollUpdates import dev.inmo.tgbotapi.extensions.utils.accessibleMessageOrNull +import dev.inmo.tgbotapi.extensions.utils.chatContentMessageOrNull import dev.inmo.tgbotapi.extensions.utils.customEmojiTextSourceOrNull import dev.inmo.tgbotapi.extensions.utils.extensions.parseCommandsWithArgsSources import dev.inmo.tgbotapi.types.BotCommand import dev.inmo.tgbotapi.types.IdChatIdentifier import dev.inmo.tgbotapi.types.PollId import dev.inmo.tgbotapi.types.ReplyParameters +import dev.inmo.tgbotapi.types.media.TelegramMediaLocation +import dev.inmo.tgbotapi.types.media.TelegramMediaVenue import dev.inmo.tgbotapi.types.polls.InputPollOption import dev.inmo.tgbotapi.types.polls.PollAnswer +import dev.inmo.tgbotapi.types.polls.QuizPoll import dev.inmo.tgbotapi.utils.buildEntities import dev.inmo.tgbotapi.utils.customEmoji import dev.inmo.tgbotapi.utils.regular @@ -35,11 +39,22 @@ import kotlinx.coroutines.sync.withLock import kotlin.random.Random /** - * This bot will answer with anonymous or public poll and send message on - * any update. - * - * * Use `/anonymous` to take anonymous regular poll - * * Use `/public` to take public regular poll + * This bot demonstrates poll features including the new API additions: + * + * * `/anonymous` — anonymous regular poll + * * `/public` — public regular poll with option adding + * * `/quiz` — quiz poll with random correct answer + * * `/media_poll` — poll with [TelegramMediaLocation] as poll media (InputMediaLocation), + * and [TelegramMediaVenue] as option media (InputMediaVenue / InputPollOptionMedia) + * * `/quiz_media` — quiz poll with [TelegramMediaLocation] as `media` and [TelegramMediaVenue] + * as `explanationMedia` (new [QuizPoll.explanationMedia] field) + * * `/members_only` — poll with `membersOnly = true` (new [dev.inmo.tgbotapi.types.polls.Poll.membersOnly] field) + * * `/country_codes` — poll with `countryCodes` (new [dev.inmo.tgbotapi.types.polls.Poll.countryCodes] field) + * * `/single_option` — poll with just 1 option (minimum options count decreased from 2 to 1) + * + * [onPollUpdates] prints [dev.inmo.tgbotapi.types.polls.Poll.media], [dev.inmo.tgbotapi.types.polls.Poll.membersOnly], + * [dev.inmo.tgbotapi.types.polls.Poll.countryCodes], [QuizPoll.explanationMedia], and + * [dev.inmo.tgbotapi.types.polls.PollOption.media] for each option. */ suspend fun main(vararg args: String) { val botToken = args.first() @@ -173,6 +188,120 @@ suspend fun main(vararg args: String) { } } + // TelegramMediaLocation implements InputPollMedia and InputPollOptionMedia (InputMediaLocation) + // TelegramMediaVenue implements InputPollMedia and InputPollOptionMedia (InputMediaVenue) + // Both can be used as poll question media or as option media + onCommand("media_poll") { + val sentPoll = sendRegularPoll( + it.chat.id, + buildEntities { regular("Which venue would you visit?") }, + listOf( + // InputPollOptionMedia via TelegramMediaVenue (InputMediaVenue) + InputPollOption( + media = TelegramMediaVenue( + latitude = 48.8566, + longitude = 2.3522, + title = "Eiffel Tower", + address = "Champ de Mars, Paris" + ) + ) { regular("Eiffel Tower") }, + // InputPollOptionMedia via TelegramMediaLocation (InputMediaLocation) + InputPollOption( + media = TelegramMediaLocation(latitude = 51.5007, longitude = -0.1246) + ) { regular("Big Ben") }, + InputPollOption { regular("Neither") }, + ), + isAnonymous = false, + // InputMediaLocation as InputPollMedia — poll question media + media = TelegramMediaLocation(latitude = 48.8566, longitude = 2.3522), + replyParameters = ReplyParameters(it) + ) + pollToChatMutex.withLock { + pollToChat[sentPoll.content.poll.id] = sentPoll.chat.id + } + } + + // Demonstrates InputPollMedia on quiz + new QuizPoll.explanationMedia field + onCommand("quiz_media") { + val sentPoll = sendQuizPoll( + it.chat.id, + questionEntities = buildEntities { regular("Where is the Eiffel Tower?") }, + options = listOf( + InputPollOption { regular("Paris") }, + InputPollOption { regular("London") }, + InputPollOption { regular("Berlin") }, + ), + correctOptionIds = listOf(0), + explanation = "The Eiffel Tower is in Paris, France.", + isAnonymous = false, + // InputMediaLocation as InputPollMedia — poll question media (new Poll.media field) + media = TelegramMediaLocation(latitude = 48.8566, longitude = 2.3522), + // explanationMedia is new on QuizPoll — media shown with quiz explanation + explanationMedia = TelegramMediaVenue( + latitude = 48.8566, + longitude = 2.3522, + title = "Eiffel Tower", + address = "Champ de Mars, 5 Av. Anatole France, Paris" + ), + replyParameters = ReplyParameters(it) + ) + pollToChatMutex.withLock { + pollToChat[sentPoll.content.poll.id] = sentPoll.chat.id + } + } + + // Demonstrates Poll.membersOnly and the membersOnly sendPoll parameter + onCommand("members_only") { + val sentPoll = sendRegularPoll( + it.chat.id, + buildEntities { regular("Members-only poll") }, + listOf( + InputPollOption { regular("Yes") }, + InputPollOption { regular("No") }, + ), + isAnonymous = false, + membersOnly = true, + replyParameters = ReplyParameters(it) + ) + pollToChatMutex.withLock { + pollToChat[sentPoll.content.poll.id] = sentPoll.chat.id + } + } + + // Demonstrates Poll.countryCodes and the countryCodes sendPoll parameter + onCommand("country_codes") { + val sentPoll = sendRegularPoll( + it.chat.id, + buildEntities { regular("Country-targeted poll (US, DE, JP)") }, + listOf( + InputPollOption { regular("Option A") }, + InputPollOption { regular("Option B") }, + ), + isAnonymous = false, + countryCodes = listOf("US", "DE", "JP"), + replyParameters = ReplyParameters(it) + ) + pollToChatMutex.withLock { + pollToChat[sentPoll.content.poll.id] = sentPoll.chat.id + } + } + + // Demonstrates that minimum poll options count is now 1 (was 2 before) + onCommand("single_option") { + val sentPoll = sendRegularPoll( + it.chat.id, + buildEntities { regular("Acknowledge this notice") }, + listOf( + InputPollOption { regular("Got it") }, + ), + isAnonymous = false, + replyParameters = ReplyParameters(it) + ) + pollToChatMutex.withLock { + pollToChat[sentPoll.content.poll.id] = sentPoll.chat.id + } + } + onPollAnswer { val chatId = pollToChat[it.pollId] ?: return@onPollAnswer @@ -185,14 +314,29 @@ suspend fun main(vararg args: String) { onPollUpdates { val chatId = pollToChat[it.id] ?: return@onPollUpdates - when(it.isAnonymous) { - false -> send(chatId, "[onPollUpdates] Public poll updated: ${it.options.joinToString()}") - true -> send(chatId, "[onPollUpdates] Anonymous poll updated: ${it.options.joinToString()}") + // Poll.media — PollMedia attached to the poll question (new field) + // Poll.membersOnly — whether poll is restricted to channel members (new field) + // Poll.countryCodes — country restriction list (new field) + // QuizPoll.explanationMedia — PollMedia attached to quiz explanation (new field) + // PollOption.media — PollMedia attached to each option (new field) + val pollInfo = buildString { + append("[onPollUpdates] anonymous=${it.isAnonymous}") + append(" | media=${it.media}") + append(" | membersOnly=${it.membersOnly}") + append(" | countryCodes=${it.countryCodes}") + if (it is QuizPoll) { + append(" | explanationMedia=${it.explanationMedia}") + } + append("\n options:") + it.options.forEach { option -> + append("\n ${option.text}: votes=${option.votes}, media=${option.media}") + } } + send(chatId, pollInfo) } onPollOptionAdded { - it.chatEvent.pollMessage ?.accessibleMessageOrNull() ?.let { pollMessage -> + it.chatEvent.pollMessage ?.accessibleMessageOrNull() ?.chatContentMessageOrNull() ?.let { pollMessage -> reply(pollMessage) { +"Poll option added: \n" +it.chatEvent.optionTextSources @@ -200,7 +344,7 @@ suspend fun main(vararg args: String) { } } onPollOptionDeleted { - it.chatEvent.pollMessage ?.accessibleMessageOrNull() ?.let { pollMessage -> + it.chatEvent.pollMessage ?.accessibleMessageOrNull() ?.chatContentMessageOrNull() ?.let { pollMessage -> reply(pollMessage) { +"Poll option deleted: \n" +it.chatEvent.optionTextSources @@ -210,7 +354,7 @@ suspend fun main(vararg args: String) { onContentMessage { val replyPollOptionId = it.replyInfo ?.pollOptionId ?: return@onContentMessage - it.replyTo ?.accessibleMessageOrNull() ?.let { replied -> + it.replyTo ?.accessibleMessageOrNull() ?.chatContentMessageOrNull() ?.let { replied -> reply(replied, pollOptionId = replyPollOptionId) { +"Reply to poll option" } @@ -221,8 +365,13 @@ suspend fun main(vararg args: String) { BotCommand("anonymous", "Create anonymous regular poll"), BotCommand("public", "Create non anonymous regular poll"), BotCommand("quiz", "Create quiz poll with random right answer"), + BotCommand("media_poll", "Poll with location/venue media on question and options"), + BotCommand("quiz_media", "Quiz with media and explanationMedia on question/explanation"), + BotCommand("members_only", "Poll restricted to channel members only (membersOnly)"), + BotCommand("country_codes", "Poll targeted to US, DE, JP users (countryCodes)"), + BotCommand("single_option", "Poll with 1 option (minimum is now 1, not 2)"), ) - allUpdatesFlow.subscribeSafelyWithoutExceptions(this) { println(it) } + allUpdatesFlow.subscribeLoggingDropExceptions(scope = this) { println(it) } }.second.join() } diff --git a/RightsChangerBot/src/main/kotlin/RightsChanger.kt b/RightsChangerBot/src/main/kotlin/RightsChanger.kt index a6c1ba3..d9d34fa 100644 --- a/RightsChangerBot/src/main/kotlin/RightsChanger.kt +++ b/RightsChangerBot/src/main/kotlin/RightsChanger.kt @@ -33,6 +33,7 @@ import dev.inmo.tgbotapi.types.chat.member.AdministratorChatMember import dev.inmo.tgbotapi.types.chat.member.ChatCommonAdministratorRights import dev.inmo.tgbotapi.types.commands.BotCommandScope import dev.inmo.tgbotapi.types.message.abstracts.AccessibleMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatMessage import dev.inmo.tgbotapi.types.request.RequestId import dev.inmo.tgbotapi.utils.* import kotlinx.coroutines.flow.filter @@ -208,7 +209,7 @@ suspend fun main(args: Array) { ) { val replyMessage = it.replyTo val userInReply = replyMessage?.fromUserMessageOrNull()?.user?.id ?: return@onCommand - if (replyMessage is AccessibleMessage) { + if (replyMessage is ChatMessage) { reply( replyMessage, "Manage keyboard:", @@ -229,7 +230,7 @@ suspend fun main(args: Array) { val replyMessage = it.replyTo val userInReply = replyMessage?.fromUserMessageOrNull()?.user?.id ?: return@onCommand - if (replyMessage is AccessibleMessage) { + if (replyMessage is ChatMessage) { reply( replyMessage, "Manage keyboard:", @@ -247,7 +248,7 @@ suspend fun main(args: Array) { initialFilter = { it.user.id == allowedAdmin } ) { val messageReply = - it.message.commonMessageOrNull()?.replyTo?.fromUserMessageOrNull() ?: return@onMessageDataCallbackQuery + it.message.chatContentMessageOrNull()?.replyTo?.fromUserMessageOrNull() ?: return@onMessageDataCallbackQuery val userId = messageReply.user.id val permissions = getUserChatPermissions(it.message.chat.id.toChatId(), userId) ?: return@onMessageDataCallbackQuery @@ -334,7 +335,7 @@ suspend fun main(args: Array) { initialFilter = { it.user.id == allowedAdmin } ) { val messageReply = - it.message.commonMessageOrNull()?.replyTo?.fromUserMessageOrNull() ?: return@onMessageDataCallbackQuery + it.message.chatContentMessageOrNull()?.replyTo?.fromUserMessageOrNull() ?: return@onMessageDataCallbackQuery val userId = messageReply.user.id val permissions = getUserChatPermissions(it.message.chat.id.toChatId(), userId) ?: return@onMessageDataCallbackQuery diff --git a/SuggestedPosts/src/main/kotlin/SuggestedPostsBot.kt b/SuggestedPosts/src/main/kotlin/SuggestedPostsBot.kt index 6553b2b..40a630a 100644 --- a/SuggestedPosts/src/main/kotlin/SuggestedPostsBot.kt +++ b/SuggestedPosts/src/main/kotlin/SuggestedPostsBot.kt @@ -31,7 +31,7 @@ import dev.inmo.tgbotapi.extensions.utils.previewChannelDirectMessagesChatOrNull import dev.inmo.tgbotapi.extensions.utils.suggestedChannelDirectMessagesContentMessageOrNull import dev.inmo.tgbotapi.types.message.SuggestedPostParameters import dev.inmo.tgbotapi.types.message.abstracts.ChannelPaidPost -import dev.inmo.tgbotapi.types.message.abstracts.CommonMessage +import dev.inmo.tgbotapi.types.message.abstracts.ChatContentMessage import dev.inmo.tgbotapi.types.update.abstracts.Update import dev.inmo.tgbotapi.utils.firstOf import kotlinx.coroutines.CoroutineScope diff --git a/TagsBot/src/main/kotlin/TagsBot.kt b/TagsBot/src/main/kotlin/TagsBot.kt index 7044352..e49d8e5 100644 --- a/TagsBot/src/main/kotlin/TagsBot.kt +++ b/TagsBot/src/main/kotlin/TagsBot.kt @@ -3,7 +3,6 @@ import dev.inmo.kslog.common.LogLevel import dev.inmo.kslog.common.defaultMessageFormatter import dev.inmo.kslog.common.setDefaultKSLog import dev.inmo.micro_utils.coroutines.subscribeLoggingDropExceptions -import dev.inmo.micro_utils.coroutines.subscribeSafelyWithoutExceptions import dev.inmo.tgbotapi.abstracts.FromUser import dev.inmo.tgbotapi.extensions.api.bot.getMe import dev.inmo.tgbotapi.extensions.api.business.getBusinessAccountGiftsFlow diff --git a/gradle.properties b/gradle.properties index 1461f7c..16836d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ kotlin.daemon.jvmargs=-Xmx3g -Xms500m kotlin_version=2.3.20 -telegram_bot_api_version=34.0.0-t6 +telegram_bot_api_version=34.0.0-t7 micro_utils_version=0.29.1 serialization_version=1.10.0 ktor_version=3.4.1 diff --git a/settings.gradle b/settings.gradle index 3c73f89..828a821 100644 --- a/settings.gradle +++ b/settings.gradle @@ -73,3 +73,7 @@ include ":TagsBot" include ":ManagedBotsBot" include ":GuestQueryBot" + +include ":LivePhotosBot" + +include ":ChatManagementBot"