diff --git a/crates/teloxide-core/src/types.rs b/crates/teloxide-core/src/types.rs index 0b09f7c0..533f7afc 100644 --- a/crates/teloxide-core/src/types.rs +++ b/crates/teloxide-core/src/types.rs @@ -27,6 +27,10 @@ pub use encrypted_credentials::*; pub use encrypted_passport_element::*; pub use file::*; pub use force_reply::*; +pub use forum_topic::*; +pub use forum_topic_closed::*; +pub use forum_topic_created::*; +pub use forum_topic_reopened::*; pub use game::*; pub use game_high_score::*; pub use inline_keyboard_button::*; @@ -86,7 +90,6 @@ pub use reply_keyboard_remove::*; pub use reply_markup::*; pub use response_parameters::*; pub use sent_web_app_message::*; -use serde::Serialize; pub use shipping_address::*; pub use shipping_option::*; pub use shipping_query::*; @@ -136,6 +139,10 @@ mod dice_emoji; mod document; mod file; mod force_reply; +mod forum_topic; +mod forum_topic_closed; +mod forum_topic_created; +mod forum_topic_reopened; mod game; mod game_high_score; mod inline_keyboard_button; @@ -239,6 +246,8 @@ pub use chat_id::*; pub use recipient::*; pub use user_id::*; +use serde::Serialize; + /// Converts an `i64` timestump to a `choro::DateTime`, producing serde error /// for invalid timestumps pub(crate) fn serde_timestamp( @@ -420,3 +429,49 @@ where { this.map(|MessageId(id)| id).serialize(serializer) } + +pub(crate) mod serde_rgb { + use serde::{de::Visitor, Deserializer, Serializer}; + + pub fn serialize(&this: &[u8; 3], s: S) -> Result { + s.serialize_u32(to_u32(this)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 3], D::Error> { + struct V; + + impl Visitor<'_> for V { + type Value = [u8; 3]; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an integer represeting an RGB color") + } + + fn visit_u32(self, v: u32) -> Result + where + E: serde::de::Error, + { + Ok(from_u32(v)) + } + } + d.deserialize_u32(V) + } + + fn to_u32([r, g, b]: [u8; 3]) -> u32 { + u32::from_be_bytes([0, r, g, b]) + } + + fn from_u32(rgb: u32) -> [u8; 3] { + let [_, r, g, b] = rgb.to_be_bytes(); + [r, g, b] + } + + #[test] + fn bytes() { + assert_eq!(to_u32([0xAA, 0xBB, 0xCC]), 0x00AABBCC); + assert_eq!(from_u32(0x00AABBCC), [0xAA, 0xBB, 0xCC]); + } + + #[test] + fn json() {} +} diff --git a/crates/teloxide-core/src/types/chat.rs b/crates/teloxide-core/src/types/chat.rs index 96d470ae..9c9e31e6 100644 --- a/crates/teloxide-core/src/types/chat.rs +++ b/crates/teloxide-core/src/types/chat.rs @@ -88,6 +88,13 @@ pub struct ChatPrivate { /// A last name of the other party in a private chat. pub last_name: Option, + /// Custom emoji identifier of emoji status of the other party in a private + /// chat. Returned only in [`GetChat`]. + /// + /// [`GetChat`]: crate::payloads::GetChat + // FIXME: CustomEmojiId + pub emoji_status_custom_emoji_id: Option, + /// Bio of the other party in a private chat. Returned only in [`GetChat`]. /// /// [`GetChat`]: crate::payloads::GetChat @@ -148,6 +155,16 @@ pub struct PublicChatSupergroup { /// available. pub username: Option, + /// If non-empty, the list of all active chat usernames; for private chats, + /// supergroups and channels. Returned only from [`GetChat`]. + /// + /// [`GetChat`]: crate::payloads::GetChat + pub active_usernames: Option>, + + /// `true`, if the supergroup chat is a forum (has topics enabled). + #[serde(default)] + pub is_forum: bool, + /// For supergroups, name of group sticker set. Returned only from /// [`GetChat`]. /// @@ -485,6 +502,7 @@ mod serde_helper { bio: Option, has_private_forwards: Option, has_restricted_voice_and_video_messages: Option, + emoji_status_custom_emoji_id: Option, } impl From for super::ChatPrivate { @@ -497,6 +515,7 @@ mod serde_helper { bio, has_private_forwards, has_restricted_voice_and_video_messages, + emoji_status_custom_emoji_id, }: ChatPrivate, ) -> Self { Self { @@ -506,6 +525,7 @@ mod serde_helper { bio, has_private_forwards, has_restricted_voice_and_video_messages, + emoji_status_custom_emoji_id, } } } @@ -519,6 +539,7 @@ mod serde_helper { bio, has_private_forwards, has_restricted_voice_and_video_messages, + emoji_status_custom_emoji_id, }: super::ChatPrivate, ) -> Self { Self { @@ -529,6 +550,7 @@ mod serde_helper { bio, has_private_forwards, has_restricted_voice_and_video_messages, + emoji_status_custom_emoji_id, } } } @@ -574,6 +596,7 @@ mod tests { bio: None, has_private_forwards: None, has_restricted_voice_and_video_messages: None, + emoji_status_custom_emoji_id: None }), photo: None, pinned_message: None, @@ -595,6 +618,7 @@ mod tests { bio: None, has_private_forwards: None, has_restricted_voice_and_video_messages: None, + emoji_status_custom_emoji_id: None, }), photo: None, pinned_message: None, diff --git a/crates/teloxide-core/src/types/chat_administrator_rights.rs b/crates/teloxide-core/src/types/chat_administrator_rights.rs index 047f70f5..b00da3d7 100644 --- a/crates/teloxide-core/src/types/chat_administrator_rights.rs +++ b/crates/teloxide-core/src/types/chat_administrator_rights.rs @@ -45,4 +45,8 @@ pub struct ChatAdministratorRights { /// `true`, if the user is allowed to pin messages; groups and /// supergroups only pub can_pin_messages: Option, + + /// `true`, if the user is allowed to create, rename, close, and reopen + /// forum topics; supergroups only + pub can_manage_topics: Option, } diff --git a/crates/teloxide-core/src/types/chat_member.rs b/crates/teloxide-core/src/types/chat_member.rs index d4af17ea..59cb4f5b 100644 --- a/crates/teloxide-core/src/types/chat_member.rs +++ b/crates/teloxide-core/src/types/chat_member.rs @@ -87,6 +87,10 @@ pub struct Administrator { /// `true` if the administrator can pin messages, supergroups only. pub can_pin_messages: Option, + /// `true`, if the user is allowed to create, rename, close, and reopen + /// forum topics; supergroups only + pub can_manage_topics: Option, + /// `true` if the administrator can add new administrators with a subset of /// his own privileges or demote administrators that he has promoted, /// directly or indirectly (promoted by administrators that were appointed @@ -130,6 +134,10 @@ pub struct Restricted { /// `true` if the user is allowed to pin messages. pub can_pin_messages: bool, + /// `true`, if the user is allowed to create, rename, close, and reopen + /// forum topics + pub can_manage_topics: bool, + /// `true` if the user is allowed to send polls. pub can_send_polls: bool, } @@ -514,6 +522,27 @@ impl ChatMemberKind { } } + /// Returns `true` if the user is allowed to manage topics. + /// + /// I.e. returns `true` if the user + /// - is the owner of the chat (even if the chat is not a supergroup) + /// - is an administrator in the given chat and has the + /// [`Administrator::can_manage_topics`] privilege. + /// - is restricted, but does have [`Restricted::can_manage_topics`] + /// privilege + /// Returns `false` otherwise. + #[must_use] + pub fn can_manage_topics(&self) -> bool { + match self { + ChatMemberKind::Owner(_) => true, + ChatMemberKind::Administrator(Administrator { can_manage_topics, .. }) => { + can_manage_topics.unwrap_or_default() + } + ChatMemberKind::Restricted(Restricted { can_manage_topics, .. }) => *can_manage_topics, + ChatMemberKind::Member | ChatMemberKind::Left | ChatMemberKind::Banned(_) => false, + } + } + /// Returns `true` if the user can add new administrators with a subset of /// his own privileges or demote administrators that he has promoted, /// directly or indirectly (promoted by administrators that were appointed @@ -780,6 +809,7 @@ mod tests { can_restrict_members: true, can_pin_messages: Some(true), can_promote_members: true, + can_manage_topics: None, }), }; let actual = serde_json::from_str::(json).unwrap(); diff --git a/crates/teloxide-core/src/types/chat_permissions.rs b/crates/teloxide-core/src/types/chat_permissions.rs index 77585d70..2fe560c4 100644 --- a/crates/teloxide-core/src/types/chat_permissions.rs +++ b/crates/teloxide-core/src/types/chat_permissions.rs @@ -46,7 +46,7 @@ bitflags::bitflags! { /// ``` #[derive(Serialize, Deserialize)] #[serde(from = "ChatPermissionsRaw", into = "ChatPermissionsRaw")] - pub struct ChatPermissions: u8 { + pub struct ChatPermissions: u16 { /// Set if the user is allowed to send text messages, contacts, /// locations and venues. const SEND_MESSAGES = 1; @@ -78,9 +78,14 @@ bitflags::bitflags! { /// Set if the user is allowed to pin messages. Ignored in public /// supergroups. const PIN_MESSAGES = (1 << 7); + + /// Set if the user is allowed to create, rename, close, and reopen forum topics. + const MANAGE_TOPICS = (1 << 8); } } +// FIXME: add `can_*` methods for convinience + /// Helper for (de)serialization #[derive(Serialize, Deserialize)] struct ChatPermissionsRaw { @@ -107,6 +112,13 @@ struct ChatPermissionsRaw { #[serde(default, skip_serializing_if = "Not::not")] can_pin_messages: bool, + + // HACK: do not `skip_serializing_if = "Not::not"`, from tg docs: + // > If omitted defaults to the value of `can_pin_messages` + // but we don't have two different values for "absent" and "false"... + // or did they mean that `can_pin_messages` implies `can_manage_topics`?.. + #[serde(default)] + can_manage_topics: bool, } impl From for ChatPermissionsRaw { @@ -120,6 +132,7 @@ impl From for ChatPermissionsRaw { can_change_info: this.contains(ChatPermissions::CHANGE_INFO), can_invite_users: this.contains(ChatPermissions::INVITE_USERS), can_pin_messages: this.contains(ChatPermissions::PIN_MESSAGES), + can_manage_topics: this.contains(ChatPermissions::MANAGE_TOPICS), } } } @@ -135,6 +148,7 @@ impl From for ChatPermissions { can_change_info, can_invite_users, can_pin_messages, + can_manage_topics, }: ChatPermissionsRaw, ) -> Self { let mut this = Self::empty(); @@ -163,6 +177,10 @@ impl From for ChatPermissions { if can_pin_messages { this |= Self::PIN_MESSAGES; } + // FIXME: should we do `|| can_pin_messages` here? (the same tg doc weirdness) + if can_manage_topics { + this |= Self::MANAGE_TOPICS + } this } @@ -175,8 +193,7 @@ mod tests { #[test] fn serialization() { let permissions = ChatPermissions::SEND_MEDIA_MESSAGES | ChatPermissions::PIN_MESSAGES; - let expected = - r#"{"can_send_messages":true,"can_send_media_messages":true,"can_pin_messages":true}"#; + let expected = r#"{"can_send_messages":true,"can_send_media_messages":true,"can_pin_messages":true,"can_manage_topics":false}"#; let actual = serde_json::to_string(&permissions).unwrap(); assert_eq!(expected, actual); } diff --git a/crates/teloxide-core/src/types/forum_topic.rs b/crates/teloxide-core/src/types/forum_topic.rs new file mode 100644 index 00000000..60c651b7 --- /dev/null +++ b/crates/teloxide-core/src/types/forum_topic.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a forum topic. +/// +/// [The official docs](https://core.telegram.org/bots/api#forumtopiccreated). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ForumTopic { + /// Unique identifier of the forum topic + // FIXME: MessageThreadId or something + pub message_thread_id: i32, + + /// Name of the topic. + pub name: String, + + /// Color of the topic icon in RGB format. + // FIXME: use/add a specialized rgb color type? + #[serde(with = "crate::types::serde_rgb")] + pub icon_color: [u8; 3], + + /// Unique identifier of the custom emoji shown as the topic icon. + // FIXME: CustomEmojiId + pub icon_custom_emoji_id: Option, +} diff --git a/crates/teloxide-core/src/types/forum_topic_closed.rs b/crates/teloxide-core/src/types/forum_topic_closed.rs new file mode 100644 index 00000000..b3d45cf5 --- /dev/null +++ b/crates/teloxide-core/src/types/forum_topic_closed.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a service message about a forum topic closed in the +/// chat. Currently holds no information. +/// +/// [The official docs](https://core.telegram.org/bots/api#forumtopiccreated). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ForumTopicClosed; diff --git a/crates/teloxide-core/src/types/forum_topic_created.rs b/crates/teloxide-core/src/types/forum_topic_created.rs new file mode 100644 index 00000000..cf4a91b6 --- /dev/null +++ b/crates/teloxide-core/src/types/forum_topic_created.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a service message about a new forum topic created in +/// the chat. +/// +/// [The official docs](https://core.telegram.org/bots/api#forumtopiccreated). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ForumTopicCreated { + /// Name of the topic. + pub name: String, + + /// Color of the topic icon in RGB format. + // FIXME: use/add a specialized rgb color type? + #[serde(with = "crate::types::serde_rgb")] + pub icon_color: [u8; 3], + + /// Unique identifier of the custom emoji shown as the topic icon. + // FIXME: CustomEmojiId + pub icon_custom_emoji_id: Option, +} diff --git a/crates/teloxide-core/src/types/forum_topic_reopened.rs b/crates/teloxide-core/src/types/forum_topic_reopened.rs new file mode 100644 index 00000000..88107186 --- /dev/null +++ b/crates/teloxide-core/src/types/forum_topic_reopened.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +/// This object represents a service message about a forum topic reopened in the +/// chat. Currently holds no information. +/// +/// [The official docs](https://core.telegram.org/bots/api#forumtopiccreated). +#[serde_with_macros::skip_serializing_none] +#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub struct ForumTopicReopened; diff --git a/crates/teloxide-core/src/types/message.rs b/crates/teloxide-core/src/types/message.rs index d0ff1510..00927091 100644 --- a/crates/teloxide-core/src/types/message.rs +++ b/crates/teloxide-core/src/types/message.rs @@ -5,11 +5,12 @@ use serde::{Deserialize, Serialize}; use url::Url; use crate::types::{ - Animation, Audio, BareChatId, Chat, ChatId, Contact, Dice, Document, Game, - InlineKeyboardMarkup, Invoice, Location, MessageAutoDeleteTimerChanged, MessageEntity, - MessageEntityRef, MessageId, PassportData, PhotoSize, Poll, ProximityAlertTriggered, Sticker, - SuccessfulPayment, True, User, Venue, Video, VideoChatEnded, VideoChatParticipantsInvited, - VideoChatScheduled, VideoChatStarted, VideoNote, Voice, WebAppData, + Animation, Audio, BareChatId, Chat, ChatId, Contact, Dice, Document, ForumTopicClosed, + ForumTopicCreated, ForumTopicReopened, Game, InlineKeyboardMarkup, Invoice, Location, + MessageAutoDeleteTimerChanged, MessageEntity, MessageEntityRef, MessageId, PassportData, + PhotoSize, Poll, ProximityAlertTriggered, Sticker, SuccessfulPayment, True, User, Venue, Video, + VideoChatEnded, VideoChatParticipantsInvited, VideoChatScheduled, VideoChatStarted, VideoNote, + Voice, WebAppData, }; /// This object represents a message. @@ -21,6 +22,11 @@ pub struct Message { #[serde(flatten)] pub id: MessageId, + /// Unique identifier of a message thread to which the message belongs; for + /// supergroups only. + // FIXME: MessageThreadId or such + pub thread_id: Option, + /// Date the message was sent in Unix time. #[serde(with = "crate::types::serde_date_from_unix_timestamp")] pub date: DateTime, @@ -55,6 +61,9 @@ pub enum MessageKind { PassportData(MessagePassportData), Dice(MessageDice), ProximityAlertTriggered(MessageProximityAlertTriggered), + ForumTopicCreated(ForumTopicCreated), + ForumTopicClosed(ForumTopicClosed), + ForumTopicReopened(ForumTopicReopened), VideoChatScheduled(MessageVideoChatScheduled), VideoChatStarted(MessageVideoChatStarted), VideoChatEnded(MessageVideoChatEnded), @@ -98,6 +107,10 @@ pub struct MessageCommon { /// represented as ordinary `url` buttons. pub reply_markup: Option, + /// `true`, if the message is sent to a forum topic. + #[serde(default)] + pub is_topic_message: bool, + /// `true`, if the message is a channel post that was automatically /// forwarded to the connected discussion group. #[serde(default)] @@ -493,6 +506,24 @@ pub struct MessageProximityAlertTriggered { pub proximity_alert_triggered: ProximityAlertTriggered, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MessageForumTopicCreated { + /// Service message: forum topic created. + pub forum_topic_created: ForumTopicCreated, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MessageForumTopicClosed { + /// Service message: forum topic closed. + pub forum_topic_closed: ForumTopicClosed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MessageForumTopicReopened { + /// Service message: forum topic reopened. + pub forum_topic_reopened: ForumTopicReopened, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct MessageVideoChatScheduled { /// Service message: video chat scheduled @@ -1543,6 +1574,8 @@ mod tests { location: None, join_by_request: None, join_to_send_messages: None, + active_usernames: None, + is_forum: false, }), description: None, invite_link: None, diff --git a/crates/teloxide-core/src/types/update.rs b/crates/teloxide-core/src/types/update.rs index d34c3798..df8f7cd6 100644 --- a/crates/teloxide-core/src/types/update.rs +++ b/crates/teloxide-core/src/types/update.rs @@ -332,6 +332,7 @@ mod test { kind: UpdateKind::Message(Message { via_bot: None, id: MessageId(6557), + thread_id: None, date, chat: Chat { id: ChatId(218_485_655), @@ -342,6 +343,7 @@ mod test { bio: None, has_private_forwards: None, has_restricted_voice_and_video_messages: None, + emoji_status_custom_emoji_id: None, }), photo: None, pinned_message: None, @@ -368,6 +370,7 @@ mod test { reply_markup: None, sender_chat: None, author_signature: None, + is_topic_message: false, is_automatic_forward: false, has_protected_content: false, }),