From b3ad61e7c4817982343bc18024c2df4f31e6bd32 Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Sun, 20 Sep 2026 23:11:58 +0600 Subject: [PATCH] fix of date time format --- CHANGELOG.md | 5 +- tgbotapi.core/api/tgbotapi.core.api | 96 ++++++++--- .../types/DateTimeEntityFormatting.kt | 120 ++++++++++++++ .../inmo/tgbotapi/types/DateTimeFormatPart.kt | 148 +++++++++++++---- .../types/message/RawMessageEntity.kt | 6 +- .../message/textsources/DateTimeTextSource.kt | 11 +- .../inmo/tgbotapi/types/rich/RichTextDsl.kt | 8 +- .../tgbotapi/types/rich/RichTextEntities.kt | 16 +- .../inmo/tgbotapi/utils/EntitiesBuilder.kt | 6 +- .../utils/internal/StringFormatting.kt | 6 +- .../types/DateTimeEntityFormattingTests.kt | 155 ++++++++++++++++++ .../types/RichMessageSerializationTest.kt | 28 +++- .../textsources/DateTimeTextSourceTests.kt | 13 +- .../types/rich/RichTextFormattingTest.kt | 7 +- 14 files changed, 548 insertions(+), 77 deletions(-) create mode 100644 tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormatting.kt create mode 100644 tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormattingTests.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 43be182694..e1ba393b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ * `FlowsUpdatesFilter` gained the abstract `messageGenerationStoppedUpdatesFlow` property; direct implementations must supply the flow, while subclasses of `AbstractFlowsUpdatesFilter` inherit the filtered implementation * `RichText` gained the public `isValidRichMessageButtonText` contract and the `RichText`/`RichBlock`/`InputRichBlock` sealed hierarchies gained Bot API 10.3 variants; permitted same-module implementations and exhaustive `when` expressions must cover the new contracts and variants * `SendRichMessageDraft`, `sendRichMessageDraft`, and the corresponding generic `send` overload widened `chatId` from `ChatId` to `IdChatIdentifier`; source calls passing `ChatId` remain valid, while the JVM signatures changed +* `RichTextDateTime.dateTimeFormat`, `DateTimeTextSource.dateTimeFormat`, `RawMessageEntity.date_time_format`, and the related factories and DSL functions now use `DateTimeEntityFormatting` instead of `String`; raw format strings must be wrapped explicitly +* `TgDateTimeFormatBuilder.build()` and `buildDateTimeFormat` now return `DateTimeEntityFormatting` instead of `String`; code requiring the serialized string must read `DateTimeEntityFormatting.value` -**Migration advice**: Direct and generic `send` extensions accept either `receiverUserId = userId, callbackQueryId = callbackId, replaceCallbackQueryMessage = ...` or `ephemeralMessageParameters = EphemeralMessageParameters(receiverUserId = userId, callbackQueryId = callbackId, replaceCallbackQueryMessage = ...)`; send-request constructors and `reply` API overloads require the parameter-object form; add `canSendWelcomeMessages` and `messageGenerationStoppedUpdatesFlow` to direct interface implementations; add Bot API 10.3 branches to exhaustive rich-type `when` expressions +**Migration advice**: Direct and generic `send` extensions accept either `receiverUserId = userId, callbackQueryId = callbackId, replaceCallbackQueryMessage = ...` or `ephemeralMessageParameters = EphemeralMessageParameters(receiverUserId = userId, callbackQueryId = callbackId, replaceCallbackQueryMessage = ...)`; send-request constructors and `reply` API overloads require the parameter-object form; add `canSendWelcomeMessages` and `messageGenerationStoppedUpdatesFlow` to direct interface implementations; add Bot API 10.3 branches to exhaustive rich-type `when` expressions; replace relative date-time format strings with `DateTimeEntityFormatting.local`, build component formats with `DateTimeEntityFormatting(useWeek = ..., useDateLong = ..., useTimeLong = ...)`, and reserve the warning-marked `DateTimeEntityFormatting(customValue)` constructor for custom or forward-compatible formats **Compatibility note**: The new `text`/`entities`/`isPrivate` fields of `GiftSentOrReceivedEvent.UniqueGift.Common` and `GiftSentOrReceivedEvent.UniqueGift.ReceivedInBusinessAccount` are trailing constructor parameters; existing positional argument and `componentN` order is preserved @@ -24,6 +26,7 @@ * (`Ephemeral messages`) Added `canSendWelcomeMessages` administrator right and `promoteChatMember` parameter * (`Rich Messages`) Added rich-message button actions, rich-text/button-row entities, typed button styles/alignment, expandable block quotations, compact tables, document blocks, document media links and direct document uploads * (`Rich Messages`) Added the `RichText.isValidRichMessageButtonText` subtype contract, moved rich-button markup generation onto `RichMessageButton` implementations, and corrected expandable-quotation Rich Markdown block HTML to use `
` with HTML-formatted nested text and credit + * (`Rich Messages`) Added `DateTimeEntityFormatting`, a typed `date_time_format` value with control-key constants, `useLocal`/`useWeek`/`useDateLong`/`useTimeLong` introspection, the `local` value, a safe canonical-order factory, and typed `TgDateTimeFormatBuilder` output * (`Reply markup`) Added `DisabledButton`/`DisabledInlineKeyboardButton` support for disabled inline buttons * (`Reply markup`) Added `forceReply` support to `InlineKeyboardMarkup` and `ReplyKeyboardMarkup` * (`General`) Added `canStop`/`keepOnStop` draft controls, `MessageGenerationStopped` updates, and `CommunityChatJoined` service messages diff --git a/tgbotapi.core/api/tgbotapi.core.api b/tgbotapi.core/api/tgbotapi.core.api index 9fd3cb5189..541ea597a4 100644 --- a/tgbotapi.core/api/tgbotapi.core.api +++ b/tgbotapi.core/api/tgbotapi.core.api @@ -12320,6 +12320,49 @@ public final class dev/inmo/tgbotapi/types/CustomEmojiId$Companion { public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class dev/inmo/tgbotapi/types/DateTimeEntityFormatting { + public static final field Companion Ldev/inmo/tgbotapi/types/DateTimeEntityFormatting$Companion; + public static final field DATE_LONG_CONTROL_KEY Ljava/lang/String; + public static final field DATE_SHORT_CONTROL_KEY Ljava/lang/String; + public static final field LOCAL_CONTROL_KEY Ljava/lang/String; + public static final field TIME_LONG_CONTROL_KEY Ljava/lang/String; + public static final field TIME_SHORT_CONTROL_KEY Ljava/lang/String; + public static final field WEEK_CONTROL_KEY Ljava/lang/String; + public static final synthetic fun box-impl (Ljava/lang/String;)Ldev/inmo/tgbotapi/types/DateTimeEntityFormatting; + public static fun constructor-impl (Ljava/lang/String;)Ljava/lang/String; + public fun equals (Ljava/lang/Object;)Z + public static fun equals-impl (Ljava/lang/String;Ljava/lang/Object;)Z + public static final fun equals-impl0 (Ljava/lang/String;Ljava/lang/String;)Z + public final fun getString ()Ljava/lang/String; + public static final fun getUseDateLong-impl (Ljava/lang/String;)Ljava/lang/Boolean; + public static final fun getUseLocal-impl (Ljava/lang/String;)Z + public static final fun getUseTimeLong-impl (Ljava/lang/String;)Ljava/lang/Boolean; + public static final fun getUseWeek-impl (Ljava/lang/String;)Z + public fun hashCode ()I + public static fun hashCode-impl (Ljava/lang/String;)I + public fun toString ()Ljava/lang/String; + public static fun toString-impl (Ljava/lang/String;)Ljava/lang/String; + public final synthetic fun unbox-impl ()Ljava/lang/String; +} + +public final synthetic class dev/inmo/tgbotapi/types/DateTimeEntityFormatting$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Ldev/inmo/tgbotapi/types/DateTimeEntityFormatting$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize-LyZQe8A (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/String; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize--5smr-Y (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/String;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class dev/inmo/tgbotapi/types/DateTimeEntityFormatting$Companion { + public final fun getLocal-o04JA8I ()Ljava/lang/String; + public final fun invoke-RHa6pnI (ZLjava/lang/Boolean;Ljava/lang/Boolean;)Ljava/lang/String; + public static synthetic fun invoke-RHa6pnI$default (Ldev/inmo/tgbotapi/types/DateTimeEntityFormatting$Companion;ZLjava/lang/Boolean;Ljava/lang/Boolean;ILjava/lang/Object;)Ljava/lang/String; + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + public abstract interface class dev/inmo/tgbotapi/types/DateTimeFormatPart { public static final field Companion Ldev/inmo/tgbotapi/types/DateTimeFormatPart$Companion; public abstract fun getControlCharacter ()Ljava/lang/String; @@ -12364,6 +12407,15 @@ public final class dev/inmo/tgbotapi/types/DateTimeFormatPart$Relative : dev/inm public fun toString ()Ljava/lang/String; } +public final class dev/inmo/tgbotapi/types/DateTimeFormatPart$Serializer : kotlinx/serialization/KSerializer { + public static final field INSTANCE Ldev/inmo/tgbotapi/types/DateTimeFormatPart$Serializer; + public fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ldev/inmo/tgbotapi/types/DateTimeFormatPart; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public fun serialize (Lkotlinx/serialization/encoding/Encoder;Ldev/inmo/tgbotapi/types/DateTimeFormatPart;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V +} + public abstract interface class dev/inmo/tgbotapi/types/DateTimeFormatPart$Time : dev/inmo/tgbotapi/types/DateTimeFormatPart { public static final field Companion Ldev/inmo/tgbotapi/types/DateTimeFormatPart$Time$Companion; } @@ -15926,7 +15978,7 @@ public final class dev/inmo/tgbotapi/types/TextQuote$Companion { public final class dev/inmo/tgbotapi/types/TgDateTimeFormatBuilder { public fun ()V - public final fun build ()Ljava/lang/String; + public final fun build-o04JA8I ()Ljava/lang/String; public final fun dateLong ()Ldev/inmo/tgbotapi/types/TgDateTimeFormatBuilder; public final fun dateShort ()Ldev/inmo/tgbotapi/types/TgDateTimeFormatBuilder; public final fun relative ()Ldev/inmo/tgbotapi/types/TgDateTimeFormatBuilder; @@ -28452,12 +28504,12 @@ public final class dev/inmo/tgbotapi/types/message/RawMessageEntity { public final fun component6 ()Ljava/lang/String; public final fun component7-GbmMWyQ ()Ljava/lang/String; public final fun component8 ()Ljava/lang/Long; - public final fun component9 ()Ljava/lang/String; - public final fun copy-w5b-Tf8 (Ljava/lang/String;IILjava/lang/String;Ldev/inmo/tgbotapi/types/chat/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;)Ldev/inmo/tgbotapi/types/message/RawMessageEntity; - public static synthetic fun copy-w5b-Tf8$default (Ldev/inmo/tgbotapi/types/message/RawMessageEntity;Ljava/lang/String;IILjava/lang/String;Ldev/inmo/tgbotapi/types/chat/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/message/RawMessageEntity; + public final fun component9-t6FMrjU ()Ljava/lang/String; + public final fun copy-GaLcVnQ (Ljava/lang/String;IILjava/lang/String;Ldev/inmo/tgbotapi/types/chat/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;)Ldev/inmo/tgbotapi/types/message/RawMessageEntity; + public static synthetic fun copy-GaLcVnQ$default (Ldev/inmo/tgbotapi/types/message/RawMessageEntity;Ljava/lang/String;IILjava/lang/String;Ldev/inmo/tgbotapi/types/chat/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/message/RawMessageEntity; public fun equals (Ljava/lang/Object;)Z public final fun getCustom_emoji_id-GbmMWyQ ()Ljava/lang/String; - public final fun getDate_time_format ()Ljava/lang/String; + public final fun getDate_time_format-t6FMrjU ()Ljava/lang/String; public final fun getLanguage ()Ljava/lang/String; public final fun getLength ()I public final fun getOffset ()I @@ -30816,15 +30868,15 @@ public final class dev/inmo/tgbotapi/types/message/textsources/CustomEmojiTextSo public final class dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource : dev/inmo/tgbotapi/types/message/textsources/TextSource { public static final field Companion Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource$Companion; - public fun (Ljava/lang/String;JLjava/lang/String;)V + public synthetic fun (Ljava/lang/String;JLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()J - public final fun component3 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; - public static synthetic fun copy$default (Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource;Ljava/lang/String;JLjava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; + public final fun component3-t6FMrjU ()Ljava/lang/String; + public final fun copy-9PXU8DM (Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; + public static synthetic fun copy-9PXU8DM$default (Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource;Ljava/lang/String;JLjava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; public fun equals (Ljava/lang/Object;)Z public fun getAsText ()Ljava/lang/String; - public final fun getDateTimeFormat ()Ljava/lang/String; + public final fun getDateTimeFormat-t6FMrjU ()Ljava/lang/String; public fun getHtml ()Ljava/lang/String; public fun getMarkdown ()Ljava/lang/String; public fun getMarkdownV2 ()Ljava/lang/String; @@ -30850,7 +30902,7 @@ public final class dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourc } public final class dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourceKt { - public static final fun dateTimeTextSource (Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; + public static final fun dateTimeTextSource-9PXU8DM (Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource; } public final class dev/inmo/tgbotapi/types/message/textsources/EMailTextSource : dev/inmo/tgbotapi/types/message/textsources/MultilevelTextSource { @@ -38241,8 +38293,8 @@ public final class dev/inmo/tgbotapi/types/rich/RichTextBuilder { public final fun code (Ljava/lang/String;)V public final fun code (Lkotlin/jvm/functions/Function1;)V public final fun customEmoji-R1fjqgo (Ljava/lang/String;Ljava/lang/String;)V - public final fun dateTime (Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V - public final fun dateTime (Ljava/lang/String;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)V + public final fun dateTime-IGRHWo8 (Ljava/lang/String;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)V + public final fun dateTime-kPYTqHU (Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public final fun email (Ljava/lang/String;Ljava/lang/String;)V public final fun email (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public final fun hashtag (Ljava/lang/String;Ljava/lang/String;)V @@ -38425,14 +38477,14 @@ public final class dev/inmo/tgbotapi/types/rich/RichTextCustomEmoji$Companion { public final class dev/inmo/tgbotapi/types/rich/RichTextDateTime : dev/inmo/tgbotapi/types/rich/RichTextEntity { public static final field Companion Ldev/inmo/tgbotapi/types/rich/RichTextDateTime$Companion; public static final field TYPE Ljava/lang/String; - public fun (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)V + public synthetic fun (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ldev/inmo/tgbotapi/types/rich/RichText; public final fun component2 ()Ldev/inmo/tgbotapi/types/TelegramDate; - public final fun component3 ()Ljava/lang/String; - public final fun copy (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ldev/inmo/tgbotapi/types/rich/RichTextDateTime; - public static synthetic fun copy$default (Ldev/inmo/tgbotapi/types/rich/RichTextDateTime;Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/rich/RichTextDateTime; + public final fun component3-o04JA8I ()Ljava/lang/String; + public final fun copy-IGRHWo8 (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ldev/inmo/tgbotapi/types/rich/RichTextDateTime; + public static synthetic fun copy-IGRHWo8$default (Ldev/inmo/tgbotapi/types/rich/RichTextDateTime;Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;ILjava/lang/Object;)Ldev/inmo/tgbotapi/types/rich/RichTextDateTime; public fun equals (Ljava/lang/Object;)Z - public final fun getDateTimeFormat ()Ljava/lang/String; + public final fun getDateTimeFormat-o04JA8I ()Ljava/lang/String; public fun getHtml ()Ljava/lang/String; public fun getMarkdown ()Ljava/lang/String; public fun getRawText ()Ljava/lang/String; @@ -38456,8 +38508,8 @@ public final synthetic class dev/inmo/tgbotapi/types/rich/RichTextDateTime$$seri } public final class dev/inmo/tgbotapi/types/rich/RichTextDateTime$Companion { - public final fun html (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ljava/lang/String; - public final fun markdown (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ljava/lang/String; + public final fun html-IGRHWo8 (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ljava/lang/String; + public final fun markdown-IGRHWo8 (Ldev/inmo/tgbotapi/types/rich/RichText;Ldev/inmo/tgbotapi/types/TelegramDate;Ljava/lang/String;)Ljava/lang/String; public final fun serializer ()Lkotlinx/serialization/KSerializer; } @@ -40897,8 +40949,8 @@ public final class dev/inmo/tgbotapi/utils/EntitiesBuilderKt { public static final fun customEmojiln-fRZnA7w (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;Ljava/util/List;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; public static final fun customEmojiln-fRZnA7w (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; public static final fun customEmojiln-fRZnA7w (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;[Ldev/inmo/tgbotapi/types/message/textsources/TextSource;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; - public static final fun dateTime (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; - public static final fun dateTimeln (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; + public static final fun dateTime-QFfb21k (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; + public static final fun dateTimeln-QFfb21k (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;JLjava/lang/String;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; public static final fun email (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/lang/String;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; public static final fun email (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Ljava/util/List;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; public static final fun email (Ldev/inmo/tgbotapi/utils/EntitiesBuilder;Lkotlin/jvm/functions/Function1;)Ldev/inmo/tgbotapi/utils/EntitiesBuilder; diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormatting.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormatting.kt new file mode 100644 index 0000000000..2f94d9d00a --- /dev/null +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormatting.kt @@ -0,0 +1,120 @@ +package dev.inmo.tgbotapi.types + +import dev.inmo.micro_utils.common.Warning +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +/** + * A format string sent as the `date_time_format` wire value for a Telegram date-time entity. + * + * Telegram requires the value to match `r|w?[dD]?[tT]?`; the complete format specification is + * available in the [Telegram Bot API documentation](https://core.telegram.org/bots/api#date-time-entity-formatting). + * An empty value preserves the entity text as supplied while still allowing clients to expose the + * associated date in the user's local format. [LOCAL_CONTROL_KEY] requests a relative-time display + * and cannot be combined with another control key. The remaining keys optionally add a localized + * weekday, a short or long date, and a short or long time. + * + * Prefer [local] or [invoke] when constructing a supported value. The public constructor accepts a + * custom [string] for forward compatibility, but unsupported combinations can violate Telegram's + * strict format and can consequently be rejected or rendered unexpectedly. + * + * @property string Exact string serialized as `date_time_format`. + */ +@Serializable +@JvmInline +value class DateTimeEntityFormatting @Warning( + "Custom formatting may violate Telegram date-time format restrictions" +) constructor( + val string: String +) { + /** Whether [string] contains [LOCAL_CONTROL_KEY], which requests a relative-time display. */ + val useLocal: Boolean + get() = LOCAL_CONTROL_KEY in string + + /** Whether [string] contains [WEEK_CONTROL_KEY], which requests the localized weekday. */ + val useWeek: Boolean + get() = WEEK_CONTROL_KEY in string + + /** + * `true` when [string] contains [DATE_LONG_CONTROL_KEY], `false` when [string] contains only + * [DATE_SHORT_CONTROL_KEY], or `null` when neither date key is present. + * + * A custom invalid value containing both date keys reports `true`; the long key has precedence. + */ + val useDateLong: Boolean? + get() = when { + DATE_LONG_CONTROL_KEY in string -> true + DATE_SHORT_CONTROL_KEY in string -> false + else -> null + } + + /** + * `true` when [string] contains [TIME_LONG_CONTROL_KEY], `false` when [string] contains only + * [TIME_SHORT_CONTROL_KEY], or `null` when neither time key is present. + * + * A custom invalid value containing both time keys reports `true`; the long key has precedence. + */ + val useTimeLong: Boolean? + get() = when { + TIME_LONG_CONTROL_KEY in string -> true + TIME_SHORT_CONTROL_KEY in string -> false + else -> null + } + + companion object { + /** Control key for relative-time display; a valid format cannot combine the key with another key. */ + const val LOCAL_CONTROL_KEY: String = "r" + + /** Control key for the day of the week in the user's localized language. */ + const val WEEK_CONTROL_KEY: String = "w" + + /** Control key for a short date, for example `17.03.22`. */ + const val DATE_SHORT_CONTROL_KEY: String = "d" + + /** Control key for a long date, for example `March 17, 2022`. */ + const val DATE_LONG_CONTROL_KEY: String = "D" + + /** Control key for a short time, for example `22:45`. */ + const val TIME_SHORT_CONTROL_KEY: String = "t" + + /** Control key for a long time, for example `22:45:00`. */ + const val TIME_LONG_CONTROL_KEY: String = "T" + + /** A valid relative-time format containing only [LOCAL_CONTROL_KEY]. */ + val local: DateTimeEntityFormatting = DateTimeEntityFormatting(LOCAL_CONTROL_KEY) + + /** + * Creates a Telegram-compatible non-relative format in weekday-date-time key order. + * + * @param useWeek `true` adds [WEEK_CONTROL_KEY]; `false` leaves the weekday unset. + * @param useDateLong `true` adds [DATE_LONG_CONTROL_KEY], `false` adds + * [DATE_SHORT_CONTROL_KEY], and `null` leaves the date unset. + * @param useTimeLong `true` adds [TIME_LONG_CONTROL_KEY], `false` adds + * [TIME_SHORT_CONTROL_KEY], and `null` leaves the time unset. + * @return A value matching `w?[dD]?[tT]?`; all unset parameters produce an empty format. + */ + operator fun invoke( + useWeek: Boolean = false, + useDateLong: Boolean?, + useTimeLong: Boolean? + ): DateTimeEntityFormatting = DateTimeEntityFormatting( + buildString { + if (useWeek) { + append(WEEK_CONTROL_KEY) + } + when (useDateLong) { + true -> append(DATE_LONG_CONTROL_KEY) + false -> append(DATE_SHORT_CONTROL_KEY) + null -> Unit + } + when (useTimeLong) { + true -> append(TIME_LONG_CONTROL_KEY) + false -> append(TIME_SHORT_CONTROL_KEY) + null -> Unit + } + } + ) + } + + override fun toString(): String = string +} diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeFormatPart.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeFormatPart.kt index a770d6a9b2..6ff5afcc3a 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeFormatPart.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/DateTimeFormatPart.kt @@ -1,113 +1,201 @@ package dev.inmo.tgbotapi.types +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder /** - * Common interface for parts of date time format. Used for [dev.inmo.tgbotapi.types.message.textsources.DateTimeTextSource] + * Common interface for safe [DateTimeEntityFormatting] parts. Used for + * [dev.inmo.tgbotapi.types.message.textsources.DateTimeTextSource]. * * @see TgDateTimeFormatBuilder * @see buildDateTimeFormat */ -@Serializable +@Serializable(DateTimeFormatPart.Serializer::class) sealed interface DateTimeFormatPart { /** - * Character that represents this part in the format string. Used by [TgDateTimeFormatBuilder.build] + * Character that represents the part in [DateTimeEntityFormatting]. */ val controlCharacter: String /** * Represents relative time format (e.g. "2 hours ago"). Control character: "r" */ - @Serializable - data object Relative : DateTimeFormatPart { override val controlCharacter: String get() = "r" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object Relative : DateTimeFormatPart { + override val controlCharacter: String + get() = DateTimeEntityFormatting.LOCAL_CONTROL_KEY + } /** * Represents day of the week format (e.g. "Monday"). Control character: "w" */ - @Serializable - data object WeekDay : DateTimeFormatPart { override val controlCharacter: String get() = "w" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object WeekDay : DateTimeFormatPart { + override val controlCharacter: String + get() = DateTimeEntityFormatting.WEEK_CONTROL_KEY + } /** * Group for date-related format parts */ - @Serializable + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) sealed interface Date : DateTimeFormatPart { /** * Represents short date format (e.g. "01.01.2023"). Control character: "d" */ - @Serializable - data object Short : Date { override val controlCharacter: String get() = "d" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object Short : Date { + override val controlCharacter: String + get() = DateTimeEntityFormatting.DATE_SHORT_CONTROL_KEY + } /** * Represents long date format (e.g. "January 1, 2023"). Control character: "D" */ - @Serializable - data object Long : Date { override val controlCharacter: String get() = "D" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object Long : Date { + override val controlCharacter: String + get() = DateTimeEntityFormatting.DATE_LONG_CONTROL_KEY + } } /** * Group for time-related format parts */ - @Serializable + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) sealed interface Time : DateTimeFormatPart { /** * Represents short time format (e.g. "12:00"). Control character: "t" */ - @Serializable - data object Short : Time { override val controlCharacter: String get() = "t" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object Short : Time { + override val controlCharacter: String + get() = DateTimeEntityFormatting.TIME_SHORT_CONTROL_KEY + } /** * Represents long time format (e.g. "12:00:00"). Control character: "T" */ - @Serializable - data object Long : Time { override val controlCharacter: String get() = "T" } + @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") + @Serializable(DateTimeFormatPart.Serializer::class) + data object Long : Time { + override val controlCharacter: String + get() = DateTimeEntityFormatting.TIME_LONG_CONTROL_KEY + } + } + + object Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( + "DateTimeFormatPart", + PrimitiveKind.STRING + ) + + override fun serialize(encoder: Encoder, value: DateTimeFormatPart) { + encoder.encodeString(value.controlCharacter) + } + + override fun deserialize(decoder: Decoder): DateTimeFormatPart = when (val controlCharacter = decoder.decodeString()) { + DateTimeEntityFormatting.LOCAL_CONTROL_KEY -> Relative + DateTimeEntityFormatting.WEEK_CONTROL_KEY -> WeekDay + DateTimeEntityFormatting.DATE_SHORT_CONTROL_KEY -> Date.Short + DateTimeEntityFormatting.DATE_LONG_CONTROL_KEY -> Date.Long + DateTimeEntityFormatting.TIME_SHORT_CONTROL_KEY -> Time.Short + DateTimeEntityFormatting.TIME_LONG_CONTROL_KEY -> Time.Long + else -> throw SerializationException("Unknown date time format part: $controlCharacter") + } } } /** - * Builder for date time format string. Use [buildDateTimeFormat] for convenience + * Builder for [DateTimeEntityFormatting]. Use [buildDateTimeFormat] for convenience. + * [DateTimeFormatPart.Relative] is mutually exclusive with all component parts. Component output uses Telegram's + * required week-date-time order regardless of the order of builder calls. */ class TgDateTimeFormatBuilder { private val parts = mutableSetOf() /** - * Adds [DateTimeFormatPart.Relative] to the format + * Selects [DateTimeFormatPart.Relative] and removes all component parts. */ - fun relative() = apply { parts.add(DateTimeFormatPart.Relative) } + fun relative() = apply { + parts.clear() + parts.add(DateTimeFormatPart.Relative) + } /** - * Adds [DateTimeFormatPart.WeekDay] to the format + * Adds [DateTimeFormatPart.WeekDay] and removes [DateTimeFormatPart.Relative]. */ - fun weekDay() = apply { parts.add(DateTimeFormatPart.WeekDay) } + fun weekDay() = apply { + parts.remove(DateTimeFormatPart.Relative) + parts.add(DateTimeFormatPart.WeekDay) + } /** - * Adds [DateTimeFormatPart.Date.Short] to the format. Removes any other [DateTimeFormatPart.Date] parts + * Adds [DateTimeFormatPart.Date.Short] and removes [DateTimeFormatPart.Relative] plus any other + * [DateTimeFormatPart.Date] part. */ fun dateShort() = apply { + parts.remove(DateTimeFormatPart.Relative) parts.removeAll { it is DateTimeFormatPart.Date } parts.add(DateTimeFormatPart.Date.Short) } /** - * Adds [DateTimeFormatPart.Time.Short] to the format. Removes any other [DateTimeFormatPart.Time] parts + * Adds [DateTimeFormatPart.Time.Short] and removes [DateTimeFormatPart.Relative] plus any other + * [DateTimeFormatPart.Time] part. */ fun timeShort() = apply { + parts.remove(DateTimeFormatPart.Relative) parts.removeAll { it is DateTimeFormatPart.Time } parts.add(DateTimeFormatPart.Time.Short) } /** - * Adds [DateTimeFormatPart.Date.Long] to the format. Removes any other [DateTimeFormatPart.Date] parts + * Adds [DateTimeFormatPart.Date.Long] and removes [DateTimeFormatPart.Relative] plus any other + * [DateTimeFormatPart.Date] part. */ fun dateLong() = apply { + parts.remove(DateTimeFormatPart.Relative) parts.removeAll { it is DateTimeFormatPart.Date } parts.add(DateTimeFormatPart.Date.Long) } /** - * Adds [DateTimeFormatPart.Time.Long] to the format. Removes any other [DateTimeFormatPart.Time] parts + * Adds [DateTimeFormatPart.Time.Long] and removes [DateTimeFormatPart.Relative] plus any other + * [DateTimeFormatPart.Time] part. */ fun timeLong() = apply { + parts.remove(DateTimeFormatPart.Relative) parts.removeAll { it is DateTimeFormatPart.Time } parts.add(DateTimeFormatPart.Time.Long) } /** - * Joins all added parts into a single format string + * Builds a safe [DateTimeEntityFormatting] value in Telegram's required canonical order. */ - fun build() = parts.joinToString("") + fun build(): DateTimeEntityFormatting = if (DateTimeFormatPart.Relative in parts) { + DateTimeEntityFormatting.local + } else { + DateTimeEntityFormatting( + useWeek = DateTimeFormatPart.WeekDay in parts, + useDateLong = when { + DateTimeFormatPart.Date.Long in parts -> true + DateTimeFormatPart.Date.Short in parts -> false + else -> null + }, + useTimeLong = when { + DateTimeFormatPart.Time.Long in parts -> true + DateTimeFormatPart.Time.Short in parts -> false + else -> null + } + ) + } } /** - * Convenient way to build date time format string + * Builds [DateTimeEntityFormatting] with the safe [TgDateTimeFormatBuilder] contract. */ -fun buildDateTimeFormat(block: TgDateTimeFormatBuilder.() -> Unit): String = TgDateTimeFormatBuilder().apply(block).build() +fun buildDateTimeFormat(block: TgDateTimeFormatBuilder.() -> Unit): DateTimeEntityFormatting = + TgDateTimeFormatBuilder().apply(block).build() diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/RawMessageEntity.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/RawMessageEntity.kt index 656346ea47..75a8854652 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/RawMessageEntity.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/RawMessageEntity.kt @@ -2,11 +2,15 @@ package dev.inmo.tgbotapi.types.message import dev.inmo.micro_utils.common.Warning import dev.inmo.tgbotapi.types.CustomEmojiId +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.UnixTimeStamp import dev.inmo.tgbotapi.types.chat.User import dev.inmo.tgbotapi.types.message.textsources.* import kotlinx.serialization.Serializable +/** + * @property date_time_format Optional [DateTimeEntityFormatting] for a `date_time` entity. + */ @Serializable @Warning("This thing is subject of changes. Library do not guarantee stability of this class") data class RawMessageEntity( @@ -18,7 +22,7 @@ data class RawMessageEntity( val language: String? = null, val custom_emoji_id: CustomEmojiId? = null, val unix_time: UnixTimeStamp? = null, - val date_time_format: String? = null + val date_time_format: DateTimeEntityFormatting? = null ) { internal val range by lazy { offset until (offset + length) diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource.kt index c819fac960..e57fe8848b 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSource.kt @@ -1,26 +1,33 @@ package dev.inmo.tgbotapi.types.message.textsources +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.UnixTimeStamp import dev.inmo.tgbotapi.utils.RiskFeature import dev.inmo.tgbotapi.utils.internal.* import kotlinx.serialization.Serializable /** + * A text source containing a date-time entity with optional [DateTimeEntityFormatting]. + * + * @property dateTimeFormat Controls the rendered date-time format; `null` leaves the format unset. * @see linkTextSource */ @Serializable data class DateTimeTextSource @RiskFeature(DirectInvocationOfTextSourceConstructor) constructor ( override val source: String, val unixTimeStamp: UnixTimeStamp, - val dateTimeFormat: String? + val dateTimeFormat: DateTimeEntityFormatting? ) : TextSource { override val markdown: String by lazy { source.dateTimeMarkdown(unixTimeStamp, dateTimeFormat) } override val markdownV2: String by lazy { source.dateTimeMarkdownV2(unixTimeStamp, dateTimeFormat) } override val html: String by lazy { source.dateTimeHTML(unixTimeStamp, dateTimeFormat) } } +/** + * Creates a [DateTimeTextSource] with optional [DateTimeEntityFormatting]. + */ fun dateTimeTextSource( text: String, unixTimeStamp: UnixTimeStamp, - dateTimeFormat: String? + dateTimeFormat: DateTimeEntityFormatting? ) = DateTimeTextSource(text, unixTimeStamp, dateTimeFormat) diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextDsl.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextDsl.kt index 84ba9178fa..b9ff3e1793 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextDsl.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextDsl.kt @@ -1,6 +1,7 @@ package dev.inmo.tgbotapi.types.rich import dev.inmo.tgbotapi.types.CustomEmojiId +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.TelegramDate import dev.inmo.tgbotapi.types.chat.User @@ -57,9 +58,12 @@ class RichTextBuilder { fun code(text: String) = add(RichTextCode(RichTextPlain(text))) fun code(block: RichTextBuilder.() -> Unit) = add(RichTextCode(buildRichText(block))) - fun dateTime(text: String, unixTime: TelegramDate, dateTimeFormat: String) = + /** Appends a [RichTextDateTime] configured by [DateTimeEntityFormatting]. */ + fun dateTime(text: String, unixTime: TelegramDate, dateTimeFormat: DateTimeEntityFormatting) = add(RichTextDateTime(RichTextPlain(text), unixTime, dateTimeFormat)) - fun dateTime(unixTime: TelegramDate, dateTimeFormat: String, block: RichTextBuilder.() -> Unit) = + + /** Appends a nested [RichTextDateTime] configured by [DateTimeEntityFormatting]. */ + fun dateTime(unixTime: TelegramDate, dateTimeFormat: DateTimeEntityFormatting, block: RichTextBuilder.() -> Unit) = add(RichTextDateTime(buildRichText(block), unixTime, dateTimeFormat)) fun textMention(text: String, user: User) = add(RichTextTextMention(RichTextPlain(text), user)) diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt index 572bdfb420..48853d5e76 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/types/rich/RichTextEntities.kt @@ -1,6 +1,7 @@ package dev.inmo.tgbotapi.types.rich import dev.inmo.tgbotapi.types.CustomEmojiId +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.alternativeTextField import dev.inmo.tgbotapi.types.anchorNameField import dev.inmo.tgbotapi.types.bankCardNumberField @@ -292,6 +293,7 @@ data class RichTextCode( /** * A formatted date and time [RichTextEntity]. * + * @property dateTimeFormat Controls date-time rendering through [DateTimeEntityFormatting]. * @see RichTextDateTime */ @Serializable @@ -301,7 +303,7 @@ data class RichTextDateTime( @SerialName(unixTimeField) val unixTime: TelegramDate, @SerialName(dateTimeFormatField) - val dateTimeFormat: String + val dateTimeFormat: DateTimeEntityFormatting ) : RichTextEntity { @EncodeDefault @SerialName(typeField) @@ -314,10 +316,14 @@ data class RichTextDateTime( companion object { const val TYPE = "date_time" - fun markdown(text: RichText, unixTime: TelegramDate, dateTimeFormat: String): String = - "![${text.markdown}](tg://time?unix=${unixTime.date}&format=$dateTimeFormat)" - fun html(text: RichText, unixTime: TelegramDate, dateTimeFormat: String): String = - "${text.html}" + + /** Renders [text] as Markdown with [dateTimeFormat]. */ + fun markdown(text: RichText, unixTime: TelegramDate, dateTimeFormat: DateTimeEntityFormatting): String = + "![${text.markdown}](tg://time?unix=${unixTime.date}&format=${dateTimeFormat.string})" + + /** Renders [text] as HTML with [dateTimeFormat]. */ + fun html(text: RichText, unixTime: TelegramDate, dateTimeFormat: DateTimeEntityFormatting): String = + "${text.html}" } } diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/EntitiesBuilder.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/EntitiesBuilder.kt index 7f548e6913..9d4f2b2721 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/EntitiesBuilder.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/EntitiesBuilder.kt @@ -5,6 +5,7 @@ package dev.inmo.tgbotapi.utils import dev.inmo.micro_utils.common.joinTo import dev.inmo.tgbotapi.types.BotCommand import dev.inmo.tgbotapi.types.CustomEmojiId +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.UnixTimeStamp import dev.inmo.tgbotapi.types.UserId import dev.inmo.tgbotapi.types.chat.User @@ -657,12 +658,13 @@ inline fun EntitiesBuilder.linkln(url: String) = link(url) + newLine /** * Add [DateTimeTextSource] using [EntitiesBuilder.add] with [dev.inmo.tgbotapi.types.message.textsources.dateTimeTextSource] + * and optional [DateTimeEntityFormatting]. */ -inline fun EntitiesBuilder.dateTime(text: String, unixTimeStamp: UnixTimeStamp, dateTimeFormat: String?) = add(dev.inmo.tgbotapi.types.message.textsources.dateTimeTextSource(text, unixTimeStamp, dateTimeFormat)) +inline fun EntitiesBuilder.dateTime(text: String, unixTimeStamp: UnixTimeStamp, dateTimeFormat: DateTimeEntityFormatting?) = add(dev.inmo.tgbotapi.types.message.textsources.dateTimeTextSource(text, unixTimeStamp, dateTimeFormat)) /** * Version of [EntitiesBuilder.dateTime] with new line at the end */ -inline fun EntitiesBuilder.dateTimeln(text: String, unixTimeStamp: UnixTimeStamp, dateTimeFormat: String?) = dateTime(text, unixTimeStamp, dateTimeFormat) + newLine +inline fun EntitiesBuilder.dateTimeln(text: String, unixTimeStamp: UnixTimeStamp, dateTimeFormat: DateTimeEntityFormatting?) = dateTime(text, unixTimeStamp, dateTimeFormat) + newLine /** diff --git a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/internal/StringFormatting.kt b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/internal/StringFormatting.kt index bd6bea2a15..4b034e47d1 100644 --- a/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/internal/StringFormatting.kt +++ b/tgbotapi.core/src/commonMain/kotlin/dev/inmo/tgbotapi/utils/internal/StringFormatting.kt @@ -50,9 +50,9 @@ internal fun String.linkMarkdown(link: String): String = "[${toMarkdown()}](${li internal fun String.linkMarkdownV2(link: String): String = "[${escapeMarkdownV2Common()}](${link.escapeMarkdownV2Link()})" internal fun String.linkHTML(link: String): String = "${toHtml()}" -internal fun String.dateTimeMarkdown(unixTimeStamp: UnixTimeStamp, dateTimeFormat: String?): String = "![${toMarkdown()}](tg://time?unix=$unixTimeStamp${dateTimeFormat ?.let { "&format=${dateTimeFormat.toMarkdown()}" } ?: ""})" -internal fun String.dateTimeMarkdownV2(unixTimeStamp: UnixTimeStamp, dateTimeFormat: String?): String = "![${escapeMarkdownV2Common()}](tg://time?unix=$unixTimeStamp${dateTimeFormat?.let { "&format=${dateTimeFormat.escapeMarkdownV2Link()}" } ?: ""})" -internal fun String.dateTimeHTML(unixTimeStamp: UnixTimeStamp, dateTimeFormat: String?): String = "${toHtml()}" +internal fun String.dateTimeMarkdown(unixTimeStamp: UnixTimeStamp, dateTimeFormat: DateTimeEntityFormatting?): String = "![${toMarkdown()}](tg://time?unix=$unixTimeStamp${dateTimeFormat?.let { "&format=${dateTimeFormat.string.toMarkdown()}" } ?: ""})" +internal fun String.dateTimeMarkdownV2(unixTimeStamp: UnixTimeStamp, dateTimeFormat: DateTimeEntityFormatting?): String = "![${escapeMarkdownV2Common()}](tg://time?unix=$unixTimeStamp${dateTimeFormat?.let { "&format=${dateTimeFormat.string.escapeMarkdownV2Link()}" } ?: ""})" +internal fun String.dateTimeHTML(unixTimeStamp: UnixTimeStamp, dateTimeFormat: DateTimeEntityFormatting?): String = "${toHtml()}" internal fun String.boldMarkdown(): String = markdownDefault(markdownBoldControl) diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormattingTests.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormattingTests.kt new file mode 100644 index 0000000000..5ea9fdf4bb --- /dev/null +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/DateTimeEntityFormattingTests.kt @@ -0,0 +1,155 @@ +package dev.inmo.tgbotapi.types + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DateTimeEntityFormattingTests { + private val json = Json { } + + @Test + fun localUsesExclusiveRelativeControlKey() { + val format = DateTimeEntityFormatting.local + + assertEquals(DateTimeEntityFormatting.LOCAL_CONTROL_KEY, format.string) + assertTrue(format.useLocal) + assertFalse(format.useWeek) + assertNull(format.useDateLong) + assertNull(format.useTimeLong) + } + + @Test + fun factoryCoversEverySupportedComponentCombination() { + val dateOptions = listOf( + null to "", + false to DateTimeEntityFormatting.DATE_SHORT_CONTROL_KEY, + true to DateTimeEntityFormatting.DATE_LONG_CONTROL_KEY + ) + val timeOptions = listOf( + null to "", + false to DateTimeEntityFormatting.TIME_SHORT_CONTROL_KEY, + true to DateTimeEntityFormatting.TIME_LONG_CONTROL_KEY + ) + var checkedCombinations = 0 + + listOf(false, true).forEach { useWeek -> + dateOptions.forEach { (useDateLong, dateKey) -> + timeOptions.forEach { (useTimeLong, timeKey) -> + val expected = buildString { + if (useWeek) append(DateTimeEntityFormatting.WEEK_CONTROL_KEY) + append(dateKey) + append(timeKey) + } + val format = DateTimeEntityFormatting( + useWeek = useWeek, + useDateLong = useDateLong, + useTimeLong = useTimeLong + ) + + assertEquals(expected, format.string) + assertFalse(format.useLocal) + assertEquals(useWeek, format.useWeek) + assertEquals(useDateLong, format.useDateLong) + assertEquals(useTimeLong, format.useTimeLong) + checkedCombinations += 1 + } + } + } + + assertEquals(18, checkedCombinations) + } + + @Test + fun customConflictingKeysReportLongVariants() { + val format = DateTimeEntityFormatting("rwdDtT") + + assertTrue(format.useLocal) + assertTrue(format.useWeek) + assertEquals(true, format.useDateLong) + assertEquals(true, format.useTimeLong) + } + + @Test + fun serializesAsPrimitiveString() { + val format = DateTimeEntityFormatting(useWeek = true, useDateLong = true, useTimeLong = true) + + assertEquals("\"wDT\"", json.encodeToString(DateTimeEntityFormatting.serializer(), format)) + } + + @Test + fun customValueRoundTripsWithoutModification() { + val format = DateTimeEntityFormatting("future-format") + + val encoded = json.encodeToString(DateTimeEntityFormatting.serializer(), format) + + assertEquals(format, json.decodeFromString(DateTimeEntityFormatting.serializer(), encoded)) + } + + @Test + fun dateTimeFormatPartsRoundTripAsPrimitiveControlCharacters() { + val parts = listOf( + DateTimeFormatPart.Relative, + DateTimeFormatPart.WeekDay, + DateTimeFormatPart.Date.Short, + DateTimeFormatPart.Date.Long, + DateTimeFormatPart.Time.Short, + DateTimeFormatPart.Time.Long + ) + + parts.forEach { part -> + val encoded = json.encodeToString(DateTimeFormatPart.serializer(), part) + val encodedElement = json.parseToJsonElement(encoded) + + assertEquals("\"${part.controlCharacter}\"", encoded) + assertTrue(encodedElement is JsonPrimitive) + assertTrue(encodedElement.isString) + assertEquals(part, json.decodeFromString(DateTimeFormatPart.serializer(), encoded)) + } + } + + @Test + fun dateTimeFormatPartRejectsUnknownControlCharacter() { + assertFailsWith { + json.decodeFromString(DateTimeFormatPart.serializer(), "\"x\"") + } + } + + @Test + fun builderUsesCanonicalWeekDateTimeOrder() { + val format = buildDateTimeFormat { + timeLong() + dateLong() + weekDay() + } + + assertEquals("wDT", format.string) + } + + @Test + fun builderKeepsRelativeFormatExclusive() { + assertEquals( + DateTimeEntityFormatting.local, + buildDateTimeFormat { + weekDay() + dateLong() + timeLong() + relative() + } + ) + assertEquals( + DateTimeEntityFormatting(useWeek = true, useDateLong = false, useTimeLong = false), + buildDateTimeFormat { + relative() + timeShort() + weekDay() + dateShort() + } + ) + } +} diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt index f96f8d46a0..fe7e27f885 100644 --- a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/RichMessageSerializationTest.kt @@ -105,7 +105,11 @@ class RichMessageSerializationTest { fun validatesRichMessageButtonTextContract() { val plain = RichTextPlain("plain") val customEmoji = RichTextCustomEmoji(CustomEmojiId("emoji"), "emoji") - val dateTime = RichTextDateTime(RichTextPlain("date"), TelegramDate(1L), "wDT") + val dateTime = RichTextDateTime( + RichTextPlain("date"), + TelegramDate(1L), + DateTimeEntityFormatting(useWeek = true, useDateLong = true, useTimeLong = true) + ) val validTexts = listOf( plain, customEmoji, @@ -122,7 +126,11 @@ class RichMessageSerializationTest { RichTextBold(plain), RichTextUrl(plain, "https://example.org"), RichTextGroup(listOf(plain, RichTextBold(plain))), - RichTextDateTime(RichTextBold(plain), TelegramDate(1L), "wDT") + RichTextDateTime( + RichTextBold(plain), + TelegramDate(1L), + DateTimeEntityFormatting(useWeek = true, useDateLong = true, useTimeLong = true) + ) ) invalidTexts.forEach { text -> assertEquals(false, text.isValidRichMessageButtonText) @@ -137,6 +145,22 @@ class RichMessageSerializationTest { } } + @Test + fun serializesDateTimeFormatAsJsonString() { + val dateTime = RichTextDateTime( + RichTextPlain("date"), + TelegramDate(1L), + DateTimeEntityFormatting(useWeek = true, useDateLong = true, useTimeLong = true) + ) + + val encoded = json.encodeToString(RichTextSerializer, dateTime) + val dateTimeFormat = json.parseToJsonElement(encoded).jsonObject.getValue(dateTimeFormatField).jsonPrimitive + + assertTrue(dateTimeFormat.isString) + assertEquals("wDT", dateTimeFormat.content) + assertEquals(dateTime, json.decodeFromString(RichTextSerializer, encoded)) + } + @Test fun rejectsBotUsernameInRichMessageLoginUrlButtons() { assertFailsWith { diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourceTests.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourceTests.kt index b3d9cd97d9..c484a04f44 100644 --- a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourceTests.kt +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/message/textsources/DateTimeTextSourceTests.kt @@ -1,5 +1,6 @@ package dev.inmo.tgbotapi.types.message.textsources +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.message.RawMessageEntity import dev.inmo.tgbotapi.types.message.asTextSources import dev.inmo.tgbotapi.types.message.toRawMessageEntities @@ -11,13 +12,13 @@ class DateTimeTextSourceTests { @Test fun testDateTimeTextSourceFormatting() { val unix = 1714560000L - val format = "r" + val format = DateTimeEntityFormatting.local val text = "some date" val source = DateTimeTextSource(text, unix, format) - assertEquals("![$text](tg://time?unix=$unix&format=$format)", source.markdown) - assertEquals("![$text](tg://time?unix=$unix&format=$format)", source.markdownV2) - assertEquals("$text", source.html) + assertEquals("![$text](tg://time?unix=$unix&format=${format.string})", source.markdown) + assertEquals("![$text](tg://time?unix=$unix&format=${format.string})", source.markdownV2) + assertEquals("$text", source.html) } @Test fun testDateTimeTextSourceFormattingWithoutFormat() { @@ -35,7 +36,7 @@ class DateTimeTextSourceTests { fun testDateTimeTextSourceInRawMessageEntity() { val sourceText = "date: 2024-05-01" val unix = 1714560000L - val format = "wd" + val format = DateTimeEntityFormatting(useWeek = true, useDateLong = false, useTimeLong = null) val entities = listOf( RawMessageEntity("date_time", 6, 10, unix_time = unix, date_time_format = format) ) @@ -92,7 +93,7 @@ class DateTimeTextSourceTests { @Test fun testDateTimeInEntitiesBuilder() { val unix = 1714560000L - val format = "D" + val format = DateTimeEntityFormatting(useDateLong = true, useTimeLong = null) val sources = buildEntities { dateTime("today", unix, format) } diff --git a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt index 05d085036f..7ab1cd2c3e 100644 --- a/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt +++ b/tgbotapi.core/src/commonTest/kotlin/dev/inmo/tgbotapi/types/rich/RichTextFormattingTest.kt @@ -2,6 +2,7 @@ package dev.inmo.tgbotapi.types.rich import dev.inmo.tgbotapi.types.ChatId import dev.inmo.tgbotapi.types.CustomEmojiId +import dev.inmo.tgbotapi.types.DateTimeEntityFormatting import dev.inmo.tgbotapi.types.LoginURL import dev.inmo.tgbotapi.types.RawChatId import dev.inmo.tgbotapi.types.TelegramDate @@ -78,7 +79,11 @@ class RichTextFormattingTest { @Test fun dateTime() { - val entity = RichTextDateTime(RichTextPlain("now"), TelegramDate(1647531900L), "wDT") + val entity = RichTextDateTime( + RichTextPlain("now"), + TelegramDate(1647531900L), + DateTimeEntityFormatting(useWeek = true, useDateLong = true, useTimeLong = true) + ) assertEquals("![now](tg://time?unix=1647531900&format=wDT)", entity.markdown) assertEquals("now", entity.html) }