diff --git a/include/dpp/cluster.h b/include/dpp/cluster.h index d7b7e27469..b27d068e9d 100644 --- a/include/dpp/cluster.h +++ b/include/dpp/cluster.h @@ -992,6 +992,24 @@ class DPP_EXPORT cluster { */ event_router_t on_message_create; + /** + * @brief Called when a vote is added to a message poll. + * + * @see https://discord.com/developers/docs/topics/gateway-events#message-poll-vote-add + * @note Use operator() to attach a lambda to this event, and the detach method to detach the listener using the returned ID. + * The function signature for this event takes a single `const` reference of type message_poll_vote_add_t&, and returns void. + */ + event_router_t on_message_poll_vote_add; + + /** + * @brief Called when a vote is removed from a message poll. + * + * @see https://discord.com/developers/docs/topics/gateway-events#message-poll-vote-remove + * @note Use operator() to attach a lambda to this event, and the detach method to detach the listener using the returned ID. + * The function signature for this event takes a single `const` reference of type message_poll_vote_remove_t&, and returns void. + */ + event_router_t on_message_poll_vote_remove; + /** * @brief Called when a guild audit log entry is created. * @@ -1948,6 +1966,54 @@ class DPP_EXPORT cluster { */ void message_delete_bulk(const std::vector &message_ids, snowflake channel_id, command_completion_event_t callback = utility::log_error()); + /** + * @brief Get a list of users that voted for this specific answer. + * + * @param m Message that contains the poll to retrieve the answers from + * @param answer_id ID of the answer to retrieve votes from (see poll_answer::answer_id) + * @param after Users after this ID should be retrieved if this is set to non-zero + * @param limit This number of users maximum should be returned, up to 100 + * @param callback Function to call when the API call completes. + * @see https://discord.com/developers/docs/resources/poll#get-answer-voters + * On success the callback will contain a dpp::user_map object in confirmation_callback_t::value. On failure, the value is undefined and confirmation_callback_t::is_error() method will return true. You can obtain full error details with confirmation_callback_t::get_error(). + */ + void poll_get_answer_voters(const message& m, uint32_t answer_id, snowflake after, uint64_t limit, command_completion_event_t callback = utility::log_error()); + + /** + * @brief Get a list of users that voted for this specific answer. + * + * @param message_id ID of the message with the poll to retrieve the answers from + * @param channel_id ID of the channel with the poll to retrieve the answers from + * @param answer_id ID of the answer to retrieve votes from (see poll_answer::answer_id) + * @param after Users after this ID should be retrieved if this is set to non-zero + * @param limit This number of users maximum should be returned, up to 100 + * @param callback Function to call when the API call completes. + * @see https://discord.com/developers/docs/resources/poll#get-answer-voters + * On success the callback will contain a dpp::user_map object in confirmation_callback_t::value. On failure, the value is undefined and confirmation_callback_t::is_error() method will return true. You can obtain full error details with confirmation_callback_t::get_error(). + */ + void poll_get_answer_voters(snowflake message_id, snowflake channel_id, uint32_t answer_id, snowflake after, uint64_t limit, command_completion_event_t callback = utility::log_error()); + + /** + * @brief Immediately end a poll. + * + * @param m Message that contains the poll + * @param callback Function to call when the API call completes. + * @see https://discord.com/developers/docs/resources/poll#end-poll + * On success the callback will contain a dpp::message object representing the message containing the poll in confirmation_callback_t::value. On failure, the value is undefined and confirmation_callback_t::is_error() method will return true. You can obtain full error details with confirmation_callback_t::get_error(). + */ + void poll_end(const message &m, command_completion_event_t callback = utility::log_error()); + + /** + * @brief Immediately end a poll. + * + * @param message_id ID of the message with the poll to end + * @param channel_id ID of the channel with the poll to end + * @param callback Function to call when the API call completes. + * @see https://discord.com/developers/docs/resources/poll#end-poll + * On success the callback will contain a dpp::message object representing the message containing the poll in confirmation_callback_t::value. On failure, the value is undefined and confirmation_callback_t::is_error() method will return true. You can obtain full error details with confirmation_callback_t::get_error(). + */ + void poll_end(snowflake message_id, snowflake channel_id, command_completion_event_t callback = utility::log_error()); + /** * @brief Get a channel * diff --git a/include/dpp/cluster_coro_calls.h b/include/dpp/cluster_coro_calls.h index f5a41fa1ea..1e4af10fe5 100644 --- a/include/dpp/cluster_coro_calls.h +++ b/include/dpp/cluster_coro_calls.h @@ -1621,6 +1621,58 @@ */ [[nodiscard]] async co_message_unpin(snowflake channel_id, snowflake message_id); +/** + * @brief Get a list of users that voted for this specific answer. + * + * @param m Message that contains the poll to retrieve the answers from + * @param answer_id ID of the answer to retrieve votes from (see poll_answer::answer_id) + * @param after Users after this ID should be retrieved if this is set to non-zero + * @param limit This number of users maximum should be returned, up to 100 + * @return user_map returned object on completion + * @see dpp::cluster::poll_get_answer_voters + * @see https://discord.com/developers/docs/resources/poll#get-answer-voters + * \memberof dpp::cluster + */ +[[nodiscard]] async co_poll_get_answer_voters(const message& m, uint32_t answer_id, snowflake after, uint64_t limit); + +/** + * @brief Get a list of users that voted for this specific answer. + * + * @param message_id ID of the message with the poll to retrieve the answers from + * @param channel_id ID of the channel with the poll to retrieve the answers from + * @param answer_id ID of the answer to retrieve votes from (see poll_answer::answer_id) + * @param after Users after this ID should be retrieved if this is set to non-zero + * @param limit This number of users maximum should be returned, up to 100 + * @return user_map returned object on completion + * @see dpp::cluster::poll_get_answer_voters + * @see https://discord.com/developers/docs/resources/poll#get-answer-voters + * \memberof dpp::cluster + */ +[[nodiscard]] async co_poll_get_answer_voters(snowflake message_id, snowflake channel_id, uint32_t answer_id, snowflake after, uint64_t limit); + +/** + * @brief Immediately end a poll. + * + * @param m Message that contains the poll + * @return message returned object on completion + * @see dpp::cluster::poll_end + * @see https://discord.com/developers/docs/resources/poll#end-poll + * \memberof dpp::cluster + */ +[[nodiscard]] async co_poll_end(const message &m); + +/** + * @brief Immediately end a poll. + * + * @param message_id ID of the message with the poll to end + * @param channel_id ID of the channel with the poll to end + * @return message returned object on completion + * @see dpp::cluster::poll_end + * @see https://discord.com/developers/docs/resources/poll#end-poll + * \memberof dpp::cluster + */ +[[nodiscard]] async co_poll_end(snowflake message_id, snowflake channel_id); + /** * @brief Get a channel's pins * @see dpp::cluster::channel_pins_get diff --git a/include/dpp/dispatcher.h b/include/dpp/dispatcher.h index fef838fba7..6310aef3d4 100644 --- a/include/dpp/dispatcher.h +++ b/include/dpp/dispatcher.h @@ -1687,6 +1687,72 @@ struct DPP_EXPORT message_create_t : public event_dispatch_t { void reply(message&& msg, bool mention_replied_user = false, command_completion_event_t callback = utility::log_error()) const; }; +/** + * @brief Message poll vote add + */ +struct DPP_EXPORT message_poll_vote_add_t : public event_dispatch_t { + using event_dispatch_t::event_dispatch_t; + using event_dispatch_t::operator=; + + /** + * @brief ID of the user who added the vote + */ + snowflake user_id; + + /** + * @brief ID of the channel containing the vote + */ + snowflake channel_id; + + /** + * @brief ID of the message containing the vote + */ + snowflake message_id; + + /** + * @brief ID of the guild containing the vote or 0 for DMs + */ + snowflake guild_id; + + /** + * @brief ID of the answer in the message poll object + */ + uint32_t answer_id; +}; + +/** + * @brief Message poll vote remove + */ +struct DPP_EXPORT message_poll_vote_remove_t : public event_dispatch_t { + using event_dispatch_t::event_dispatch_t; + using event_dispatch_t::operator=; + + /** + * @brief ID of the user who added the vote + */ + snowflake user_id; + + /** + * @brief ID of the channel containing the vote + */ + snowflake channel_id; + + /** + * @brief ID of the message containing the vote + */ + snowflake message_id; + + /** + * @brief ID of the guild containing the vote or 0 for DMs + */ + snowflake guild_id; + + /** + * @brief ID of the answer in the message poll object + */ + uint32_t answer_id; +}; + /** * @brief Guild audit log entry create */ diff --git a/include/dpp/event.h b/include/dpp/event.h index fe6df9bb45..6921586755 100644 --- a/include/dpp/event.h +++ b/include/dpp/event.h @@ -100,6 +100,8 @@ event_decl(message_create,MESSAGE_CREATE); event_decl(message_update,MESSAGE_UPDATE); event_decl(message_delete,MESSAGE_DELETE); event_decl(message_delete_bulk,MESSAGE_DELETE_BULK); +event_decl(message_poll_vote_add,MESSAGE_POLL_VOTE_ADD); +event_decl(message_poll_vote_remove,MESSAGE_POLL_VOTE_REMOVE); /* Presence/typing */ event_decl(presence_update,PRESENCE_UPDATE); diff --git a/include/dpp/message.h b/include/dpp/message.h index 6455540e62..d64b481e82 100644 --- a/include/dpp/message.h +++ b/include/dpp/message.h @@ -79,14 +79,14 @@ enum component_type : uint8_t { }; /** - * @brief An emoji for a component (select menus included). + * @brief An emoji reference for a component (select menus included) or a poll. * - * To set an emoji on your button, you must set one of either the name or id fields. - * The easiest way is to use the dpp::component::set_emoji method. + * To set an emoji on your button or poll answer, you must set one of either the name or id fields. + * The easiest way for buttons is to use the dpp::component::set_emoji method. * * @note This is a **very** scaled down version of dpp::emoji, we advise that you refrain from using this. */ -struct component_emoji { +struct partial_emoji { /** * @brief The name of the emoji. * @@ -94,7 +94,7 @@ struct component_emoji { * actual unicode value of the emoji e.g. "😄" * and not for example ":smile:" */ - std::string name{""}; + std::string name{}; /** * @brief The emoji ID value for emojis that are custom @@ -115,6 +115,13 @@ struct component_emoji { bool animated{false}; }; +/** + * @brief An emoji for a component. Alias to partial_emoji, for backwards compatibility. + * + * @see partial_emoji + */ +using component_emoji = partial_emoji; + /** * @brief The data for a file attached to a message. * @@ -247,7 +254,7 @@ struct DPP_EXPORT select_option : public json_interface { /** * @brief The emoji for the select option. */ - component_emoji emoji; + partial_emoji emoji; /** * @brief Construct a new select option object @@ -449,7 +456,7 @@ class DPP_EXPORT component : public json_interface { /** * @brief The emoji for this component. */ - component_emoji emoji; + partial_emoji emoji; /** * @brief Constructor @@ -1365,39 +1372,267 @@ struct DPP_EXPORT sticker_pack : public managed, public json_interface stickers; + std::map stickers{}; /** * @brief Name of the sticker pack. */ - std::string name; + std::string name{}; /** * @brief ID of the pack's SKU. */ - snowflake sku_id; + snowflake sku_id{0}; /** * @brief Optional: ID of a sticker in the pack which is shown as the pack's icon. */ - snowflake cover_sticker_id; + snowflake cover_sticker_id{0}; /** * @brief Description of the sticker pack. */ - std::string description; + std::string description{}; /** * @brief ID of the sticker pack's banner image. */ - snowflake banner_asset_id; + snowflake banner_asset_id{}; +}; + +/** + * @brief Poll layout types + * + * @note At the time of writing Discord only has 1, "The, uhm, default layout type." + * @see https://discord.com/developers/docs/resources/poll#layout-type + */ +enum poll_layout_type { + /** + * @brief According to Discord, quote, "The, uhm, default layout type." + */ + pl_default = 1 +}; + +/** + * @brief Structure representing a poll media, for example the poll question or a possible poll answer. + * + * @see https://discord.com/developers/docs/resources/poll#poll-media-object-poll-media-object-structure + */ +struct poll_media { + /** + * @brief Text of the media + */ + std::string text{}; + + /** + * @brief Emoji of the media. + */ + partial_emoji emoji{}; +}; + +/** + * @brief Represents an answer in a poll. + * + * @see https://discord.com/developers/docs/resources/poll#poll-answer-object-poll-answer-object-structure + */ +struct poll_answer { + /** + * @brief ID of the answer. Only sent by the Discord API, this is a dead field when creating a poll. + * + * @warn At the time of writing the Discord API warns users not to rely on anything regarding sequence or "first value" of this field. + */ + uint32_t id{0}; + + /** + * @brief Data of the answer. + * + * @see poll_media + */ + poll_media media{}; +}; + +/** + * @brief Represents the results of a poll + * + * @see https://discord.com/developers/docs/resources/poll#poll-results-object-poll-results-object-structure + */ +struct poll_results { + /** + * @brief Represents a reference to an answer and its count of votes + * + * @see https://discord.com/developers/docs/resources/poll#poll-results-object-poll-answer-count-object-structure + */ + struct answer_count { + /** + * @brief ID of the answer. Relates to an answer in the answers field + * + * @see poll_answer::answer_id + */ + uint32_t answer_id{0}; + + /** + * @brief Number of votes for this answer + */ + uint32_t count{0}; + + /** + * @brief Whether the current user voted + */ + bool me_voted{false}; + }; + + /** + * @brief Whether the poll has finalized, and the answers are precisely counted + * + * @note Discord states that due to the way they count and cache answers, + * while a poll is running the count of answers might not be accurate. + */ + bool is_finalized{false}; + + /** + * @brief Count of votes for each answer. If an answer is not present in this list, + * then its vote count is 0 + */ + std::map answer_counts; +}; + +/** + * @brief Represents a poll. + * + * @see https://discord.com/developers/docs/resources/poll + */ +struct DPP_EXPORT poll { + /** + * @brief Poll question. At the time of writing only the text field is supported by Discord + * + * @see media + */ + poll_media question{}; + + /** + * @brief List of answers of the poll. + * + * @note At the time of writing this can contain up to 10 answers + * @see answer + */ + std::map answers{}; + + /** + * @brief When retriving a poll from the API, this is the timestamp at which the poll will expire. + * When creating a poll, this is the number of hours the poll should be up for, up to 7 days (168 hours), and this field will be rounded. + */ + double expiry{24.0}; + + /** + * @brief Whether a user can select multiple answers + */ + bool allow_multiselect{false}; + + /** + * @brief Layout type of the poll. Defaults to, well, pl_default + * + * @see poll_layout_type + */ + poll_layout_type layout_type{pl_default}; + + /** + * @brief The (optional) results of the poll. This field may or may not be present, and its absence means "unknown results", not "no results". + * + * @note Quote from Discord: "The results field may be not present in certain responses where, as an implementation detail, + * we do not fetch the poll results in our backend. This should be treated as "unknown results", + * as opposed to "no results". You can keep using the results if you have previously received them through other means." + * + * @see https://discord.com/developers/docs/resources/poll#poll-results-object + */ + std::optional results{std::nullopt}; /** - * @brief Construct a new sticker pack object + * @brief Set the question for this poll + * + * @param text Text for the question + * @return self for method chaining + */ + poll& set_question(const std::string& text); + + /** + * @brief Set the duration of the poll in hours + * + * @param hours Duration of the poll in hours, max 7 days (168 hours) at the time of writing + * @return self for method chaining + */ + poll& set_duration(uint32_t hours) noexcept; + + /** + * @brief Set the duration of the poll in hours + * + * @param hours Duration of the poll in hours + * @return self for method chaining + */ + poll& set_allow_multiselect(bool allow) noexcept; + + /** + * @brief Add an answer to this poll + * + * @note At the time of writing this, a poll can have up to 10 answers + * @param media Data of the answer + * @return self for method chaining + */ + poll& add_answer(const poll_media& media); + + /** + * @brief Add an answer to this poll + * + * @note At the time of writing this, a poll can have up to 10 answers + * @param text Text for the answer + * @param emoji_id Optional emoji + * @param is_animated Whether the emoji is animated + * @return self for method chaining */ - sticker_pack(); + poll& add_answer(const std::string& text, snowflake emoji_id = 0, bool is_animated = false); - virtual ~sticker_pack() = default; + /** + * @brief Add an answer to this poll + * + * @note At the time of writing this, a poll can have up to 10 answers + * @param text Text for the answer + * @param emoji Optional emoji + * @return self for method chaining + */ + poll& add_answer(const std::string& text, const std::string& emoji); + + /** + * @brief Add an answer to this poll + * + * @note At the time of writing this, a poll can have up to 10 answers + * @param text Text for the answer + * @param e Optional emoji + * @return self for method chaining + */ + poll& add_answer(const std::string& text, const emoji& e); + + /** + * @brief Helper to get the question text + * + * @return question.text + */ + [[nodiscard]] const std::string& get_question_text() const noexcept; + + /** + * @brief Helper to find an answer by ID + * + * @param id ID to find + * @return Pointer to the answer with the matching ID, or nullptr if not found + */ + [[nodiscard]] const poll_media* find_answer(uint32_t id) const noexcept; + + /** + * @brief Helper to find the vote count in the results + * + * @param answer_id ID of the answer to find + * @return std::optional Optional count of votes. An empty optional means Discord did not send the results, it does not mean 0. It can also mean the poll does not have an answer with this ID + * @see https://discord.com/developers/docs/resources/poll#poll-results-object + */ + [[nodiscard]] std::optional get_vote_count(uint32_t answer_id) const noexcept; }; /** @@ -1989,6 +2224,11 @@ struct DPP_EXPORT message : public managed, json_interface { */ bool mention_everyone; + /** + * @brief Optional poll attached to this message + */ + std::optional attached_poll; + /** * @brief Construct a new message object */ @@ -2316,6 +2556,28 @@ struct DPP_EXPORT message : public managed, json_interface { * @return string of URL to message */ std::string get_url() const; + + /** + * @brief Convenience method to set the poll + * + * @return message& Self reference for method chaining + */ + message& set_poll(const poll& p); + + /** + * @brief Convenience method to get the poll attached to this message + * + * @throw std::bad_optional_access if has_poll() == false + * @return const poll& Poll attached to this object + */ + [[nodiscard]] const poll& get_poll() const; + + /** + * @brief Method to check if the message has a poll + * + * @return bool Whether the message has a poll + */ + [[nodiscard]] bool has_poll() const noexcept; }; /** diff --git a/include/dpp/restrequest.h b/include/dpp/restrequest.h index e948713b17..93fdd27071 100644 --- a/include/dpp/restrequest.h +++ b/include/dpp/restrequest.h @@ -144,6 +144,7 @@ template<> inline void rest_request_list(dpp::cluster* c, const char* ba } }); } + /** * @brief Templated REST request helper to save on typing (for returned lists, specialised for voiceregions) * @@ -172,6 +173,7 @@ template<> inline void rest_request_list(dpp::cluster* c, const cha } }); } + /** * @brief Templated REST request helper to save on typing (for returned lists, specialised for bans) * @@ -201,6 +203,7 @@ template<> inline void rest_request_list(dpp::cluster* c, const char* basep } }); } + /** * @brief Templated REST request helper to save on typing (for returned lists, specialised for sticker packs) * @@ -232,6 +235,36 @@ template<> inline void rest_request_list(dpp::cluster* c, const ch }); } +/** + * @brief Templated REST request helper to save on typing (for returned lists) + * + * @tparam T singular type to return in lambda callback + * @tparam T map type to return in lambda callback + * @param c calling cluster + * @param basepath base path for API call + * @param major major API function + * @param minor minor API function + * @param method HTTP method + * @param postdata Post data or empty string + * @param key Key name of elements in the json list + * @param root Root element to look for + * @param callback Callback lambda + */ +template inline void rest_request_list(dpp::cluster* c, const char* basepath, const std::string &major, const std::string &minor, http_method method, const std::string& postdata, command_completion_event_t callback, const std::string& key, const std::string& root) { + c->post_rest(basepath, major, minor, method, postdata, [c, root, key, callback](json &j, const http_request_completion_t& http) { + std::unordered_map list; + confirmation_callback_t e(c, confirmation(), http); + if (!e.is_error()) { + for (auto & curr_item : j[root]) { + list[snowflake_not_null(&curr_item, key.c_str())] = T().fill_from_json(&curr_item); + } + } + if (callback) { + callback(confirmation_callback_t(c, list, http)); + } + }); +} + /** * @brief Templated REST request helper to save on typing (for returned lists, specialised for objects which doesn't have ids) * diff --git a/src/dpp/cluster/message.cpp b/src/dpp/cluster/message.cpp index 06fc23faba..824060c3e0 100644 --- a/src/dpp/cluster/message.cpp +++ b/src/dpp/cluster/message.cpp @@ -172,6 +172,38 @@ void cluster::message_unpin(snowflake channel_id, snowflake message_id, command_ } +void cluster::poll_get_answer_voters(const message& m, uint32_t answer_id, snowflake after, uint64_t limit, command_completion_event_t callback) { + std::map parameters { + {"limit", std::to_string(limit > 100 ? 100 : limit)} + }; + + if (after > 0) { + parameters["after"] = after; + } + rest_request_list(this, API_PATH "/channels", std::to_string(m.channel_id), "polls/" + std::to_string(m.id) + "/answers/" + std::to_string(answer_id) + utility::make_url_parameters(parameters), m_get, "", std::move(callback), "id", "users"); +} + +void cluster::poll_get_answer_voters(snowflake message_id, snowflake channel_id, uint32_t answer_id, snowflake after, uint64_t limit, command_completion_event_t callback) { + std::map parameters { + {"limit", std::to_string(limit > 100 ? 100 : limit)} + }; + + if (after > 0) { + parameters["after"] = after; + } + rest_request_list(this, API_PATH "/channels", std::to_string(channel_id), "polls/" + std::to_string(message_id) + "/answers/" + std::to_string(answer_id) + utility::make_url_parameters(parameters), m_get, "", std::move(callback), "id", "users"); +} + + +void cluster::poll_end(const message &m, command_completion_event_t callback) { + rest_request(this, API_PATH "/channels", std::to_string(m.channel_id), "polls/" + std::to_string(m.id) + "/expire", m_post, "", std::move(callback)); +} + +void cluster::poll_end(snowflake message_id, snowflake channel_id, command_completion_event_t callback) { + rest_request(this, API_PATH "/channels", std::to_string(channel_id), "polls/" + std::to_string(message_id) + "/expire", m_post, "", std::move(callback)); +} + + void cluster::channel_pins_get(snowflake channel_id, command_completion_event_t callback) { rest_request_list(this, API_PATH "/channels", std::to_string(channel_id), "pins", m_get, "", callback); } diff --git a/src/dpp/cluster_coro_calls.cpp b/src/dpp/cluster_coro_calls.cpp index dbf3da3757..dee4daef50 100644 --- a/src/dpp/cluster_coro_calls.cpp +++ b/src/dpp/cluster_coro_calls.cpp @@ -531,6 +531,22 @@ async cluster::co_message_unpin(snowflake channel_id, s return async{ this, static_cast(&cluster::message_unpin), channel_id, message_id }; } +async cluster::co_poll_get_answer_voters(const message& m, uint32_t answer_id, snowflake after, uint64_t limit) { + return async{ this, static_cast(&cluster::poll_get_answer_voters), m, answer_id, after, limit }; +} + +async cluster::co_poll_get_answer_voters(snowflake message_id, snowflake channel_id, uint32_t answer_id, snowflake after, uint64_t limit) { + return async{ this, static_cast(&cluster::poll_get_answer_voters), message_id, channel_id, answer_id, after, limit }; +} + +async cluster::co_poll_end(const message &m) { + return async{ this, static_cast(&cluster::poll_end), m }; +} + +async cluster::co_poll_end(snowflake message_id, snowflake channel_id) { + return async{ this, static_cast(&cluster::poll_end), message_id, channel_id }; +} + async cluster::co_channel_pins_get(snowflake channel_id) { return async{ this, static_cast(&cluster::channel_pins_get), channel_id }; } diff --git a/src/dpp/discordevents.cpp b/src/dpp/discordevents.cpp index 72c9596b77..bdaa51afe4 100644 --- a/src/dpp/discordevents.cpp +++ b/src/dpp/discordevents.cpp @@ -363,6 +363,8 @@ static const std::map event_map = { { "MESSAGE_REACTION_REMOVE", make_static_event() }, { "MESSAGE_REACTION_REMOVE_ALL", make_static_event() }, { "MESSAGE_REACTION_REMOVE_EMOJI", make_static_event() }, + { "MESSAGE_POLL_VOTE_ADD", make_static_event() }, + { "MESSAGE_POLL_VOTE_REMOVE", make_static_event() }, { "CHANNEL_PINS_UPDATE", make_static_event() }, { "GUILD_BAN_ADD", make_static_event() }, { "GUILD_BAN_REMOVE", make_static_event() }, diff --git a/src/dpp/events/message_poll_vote_add.cpp b/src/dpp/events/message_poll_vote_add.cpp new file mode 100644 index 0000000000..319d809e9c --- /dev/null +++ b/src/dpp/events/message_poll_vote_add.cpp @@ -0,0 +1,53 @@ +/************************************************************************************ + * + * D++, A Lightweight C++ library for Discord + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2021 Craig Edwards and D++ contributors + * (https://github.com/brainboxdotcc/DPP/graphs/contributors) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ************************************************************************************/ +#include +#include +#include +#include +#include + + +namespace dpp::events { + + +/** + * @brief Handle event + * + * @param client Websocket client (current shard) + * @param j JSON data for the event + * @param raw Raw JSON string + */ +void message_poll_vote_add::handle(discord_client* client, json &j, const std::string &raw) { + + if (!client->creator->on_message_poll_vote_add.empty()) { + json d = j["d"]; + dpp::message_poll_vote_add_t vote(client, raw); + vote.user_id = snowflake_not_null(&j, "user_id"); + vote.message_id = snowflake_not_null(&j, "message_id"); + vote.channel_id = snowflake_not_null(&j, "channel_id"); + vote.guild_id = snowflake_not_null(&j, "guild_id"); + vote.answer_id = int32_not_null(&j, "answer_id"); + client->creator->on_message_poll_vote_add.call(vote); + } +} + +}; diff --git a/src/dpp/events/message_poll_vote_remove.cpp b/src/dpp/events/message_poll_vote_remove.cpp new file mode 100644 index 0000000000..55c243e767 --- /dev/null +++ b/src/dpp/events/message_poll_vote_remove.cpp @@ -0,0 +1,53 @@ +/************************************************************************************ + * + * D++, A Lightweight C++ library for Discord + * + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2021 Craig Edwards and D++ contributors + * (https://github.com/brainboxdotcc/DPP/graphs/contributors) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + ************************************************************************************/ +#include +#include +#include +#include +#include + + +namespace dpp::events { + + +/** + * @brief Handle event + * + * @param client Websocket client (current shard) + * @param j JSON data for the event + * @param raw Raw JSON string + */ +void message_poll_vote_remove::handle(discord_client* client, json &j, const std::string &raw) { + + if (!client->creator->on_message_poll_vote_add.empty()) { + json d = j["d"]; + dpp::message_poll_vote_remove_t vote(client, raw); + vote.user_id = snowflake_not_null(&j, "user_id"); + vote.message_id = snowflake_not_null(&j, "message_id"); + vote.channel_id = snowflake_not_null(&j, "channel_id"); + vote.guild_id = snowflake_not_null(&j, "guild_id"); + vote.answer_id = int32_not_null(&j, "answer_id"); + client->creator->on_message_poll_vote_remove.call(vote); + } +} + +}; diff --git a/src/dpp/message.cpp b/src/dpp/message.cpp index 31e53ea3fb..ed7709a413 100644 --- a/src/dpp/message.cpp +++ b/src/dpp/message.cpp @@ -485,6 +485,155 @@ component &component::add_default_value(const snowflake id, const component_defa return *this; } +namespace { + +poll_media get_poll_media(const nlohmann::json& obj, std::string_view key) { + poll_media retval{}; + + if (auto it = obj.find(key); it != obj.end()) { + const json& media_json = *it; + + retval.text = string_not_null(&media_json, "text"); + if (it = media_json.find("emoji"); it != media_json.end()) { + const json& emoji_json = *it; + + retval.emoji.animated = bool_not_null(&emoji_json, "animated"); + retval.emoji.name = string_not_null(&emoji_json, "name"); + retval.emoji.id = snowflake_not_null(&emoji_json, "id"); + } + } + return retval; +}; + +json make_json(const poll_media &media) { + json retval{}; + + if (media.emoji.id != 0) { + json& emoji_json = retval["emoji"]; + emoji_json["id"] = media.emoji.id; + emoji_json["animated"] = media.emoji.animated; + } else if (!media.emoji.name.empty()) { + json& emoji_json = retval["emoji"]; + emoji_json["name"] = media.emoji.name; + emoji_json["animated"] = media.emoji.animated; + } + retval["text"] = media.text; + return retval; +} + +} + +void from_json(const nlohmann::json& j, poll& p) { + p.question = get_poll_media(j, "question"); + if (auto it = j.find("answers"); it != j.end() && it->is_array()) { + for (const json& element : *it) { + auto id = int32_not_null(&element, "answer_id"); + p.answers.emplace(id, poll_answer{ + id, + get_poll_media(element, "poll_media") + }); + } + } + p.expiry = double_not_null(&j, "expiry"); + p.allow_multiselect = bool_not_null(&j, "allow_multiselect"); + p.layout_type = static_cast(int32_not_null(&j, "layout_type")); + if (auto it = j.find("results"); it != j.end()) { + const json& results_json = *it; + poll_results p_results{}; + + p_results.is_finalized = bool_not_null(&results_json, "is_finalized"); + if (it = results_json.find("answer_counts"); it != results_json.end() && it->is_array()) { + for (const json& answer_count_json : *it) { + auto id = int32_not_null(&answer_count_json, "id"); + p_results.answer_counts.emplace(id, poll_results::answer_count{ + id, + int32_not_null(&answer_count_json, "count"), + bool_not_null(&answer_count_json, "me_voted") + }); + } + } + p.results = std::move(p_results); + } +} + +void to_json(json& j, const poll &p) { + j["question"] = make_json(p.question); + + json& answers_json = j["answers"]; + for (const auto& [_, answer] : p.answers) { + answers_json.emplace_back()["poll_media"] = make_json(answer.media); + } + /* When sending a poll object expiry is a duration in hours so we clamp it to positive and round */ + j["duration"] = (p.expiry < 0.0 ? uint32_t{0} : static_cast(p.expiry + 0.5)); + j["allow_multiselect"] = p.allow_multiselect; + j["layout_type"] = static_cast(p.layout_type); +} + +poll& poll::set_question(const std::string& text) { + question.text = text; + return *this; +} + +poll& poll::set_duration(uint32_t hours) noexcept { + expiry = static_cast(hours); + return *this; +} + +poll& poll::set_allow_multiselect(bool allow) noexcept { + allow_multiselect = allow; + return *this; +} + +poll& poll::add_answer(const poll_media& media) { + uint32_t max = 0; + for (const auto &pair : answers) { + if (pair.first > max) { + max = pair.first; + } + } + answers.emplace(max + 1, poll_answer{max + 1, media}); + return *this; +} + +poll& poll::add_answer(const std::string& text, snowflake emoji_id, bool is_animated) { + return add_answer(poll_media{text, partial_emoji{{}, emoji_id, is_animated}}); +} + +poll& poll::add_answer(const std::string& text, const std::string& emoji) { + return add_answer(poll_media{text, partial_emoji{emoji, {}, false}}); +} + +poll& poll::add_answer(const std::string& text, const emoji& e) { + return add_answer(poll_media{text, partial_emoji{e.name, e.id, e.is_animated()}}); +} + +const std::string& poll::get_question_text() const noexcept { + return question.text; +} + +const poll_media *poll::find_answer(uint32_t id) const noexcept { + if (auto it = answers.find(id); it != answers.end()) { + return &it->second.media; + } + return nullptr; +} + +std::optional poll::get_vote_count(uint32_t answer_id) const noexcept { + if (!results.has_value()) { + return std::nullopt; + } + if (auto it = results->answer_counts.find(answer_id); it != results->answer_counts.end()) { + return it->second.count; + } + /* Answers not present can mean 0 */ + if (find_answer(answer_id) == nullptr) { + return std::nullopt; + } + return 0; +} + + + embed::~embed() = default; embed::embed() : timestamp(0) { @@ -617,6 +766,19 @@ message& message::set_guild_id(snowflake _guild_id) { return *this; } +message& message::set_poll(const poll& p) { + attached_poll = p; + return *this; +} + +const poll &message::get_poll() const { + return attached_poll.value(); +} + +bool message::has_poll() const noexcept { + return attached_poll.has_value(); +} + message::message(const std::string &_content, message_type t) : message() { content = utility::utf8substr(_content, 0, 4000); type = t; @@ -1052,6 +1214,10 @@ json message::to_json(bool with_id, bool is_interaction_response) const { j["embeds"].push_back(e); } + if (attached_poll.has_value()) { + dpp::to_json(j["poll"], *attached_poll); + } + return j; } @@ -1230,6 +1396,9 @@ message& message::fill_from_json(json* d, cache_policy_t cp) { message_reference.message_id = snowflake_not_null(&mr, "message_id"); message_reference.fail_if_not_exists = bool_not_null(&mr, "fail_if_not_exists"); } + if (auto it = d->find("poll"); it != d->end()) { + from_json(*it, attached_poll.emplace()); + } return *this; } @@ -1294,9 +1463,6 @@ json sticker::to_json_impl(bool with_id) const { return j; } -sticker_pack::sticker_pack() : managed(0), sku_id(0), cover_sticker_id(0), banner_asset_id(0) { -} - sticker_pack& sticker_pack::fill_from_json_impl(nlohmann::json* j) { this->id = snowflake_not_null(j, "id"); this->sku_id = snowflake_not_null(j, "sku_id"); diff --git a/src/unittest/test.cpp b/src/unittest/test.cpp index f1a64d2c0e..a4e64f1222 100644 --- a/src/unittest/test.cpp +++ b/src/unittest/test.cpp @@ -2076,7 +2076,7 @@ Markdown lol \\|\\|spoiler\\|\\| \\~\\~strikethrough\\~\\~ \\`small \\*code\\* b } }); } - + set_test(THREAD_CREATE, false); if (!offline) { bot.thread_create("thread test", TEST_TEXT_CHANNEL_ID, 60, dpp::channel_type::CHANNEL_PUBLIC_THREAD, true, 60, [&](const dpp::confirmation_callback_t &event) { @@ -2088,6 +2088,99 @@ Markdown lol \\|\\|spoiler\\|\\| \\~\\~strikethrough\\~\\~ \\`small \\*code\\* b }); } + start_test(POLL_CREATE); + if (!offline) { + dpp::message poll_msg{}; + + poll_msg.set_poll(dpp::poll{} + .set_question("hello!") + .add_answer("one", dpp::unicode_emoji::one) + .add_answer("two", dpp::unicode_emoji::two) + .add_answer("three", dpp::unicode_emoji::three) + .add_answer("four") + .set_duration(48) + .set_allow_multiselect(true) + ).set_channel_id(TEST_TEXT_CHANNEL_ID); + + bot.message_create(poll_msg, [&bot, poll_msg](const dpp::confirmation_callback_t& result) { + if (result.is_error()) { + set_status(POLL_CREATE, ts_failed, result.get_error().human_readable); + return; + } + + const dpp::message& m = std::get(result.value); + + if (!m.attached_poll.has_value()) { + set_status(POLL_CREATE, ts_failed, "poll missing in received message"); + return; + } + + if (m.attached_poll->find_answer(std::numeric_limits::max()) != nullptr) { + set_status(POLL_CREATE, ts_failed, "poll::find_answer failed to return nullptr"); + return; + } + + std::array correct = {false, false, false, false}; + int i = 0; + for (const auto& [_, answer] : m.attached_poll->answers) { + if (m.attached_poll->find_answer(answer.id) != &answer.media) { + set_status(POLL_CREATE, ts_failed, "poll::find_answer failed to return valid answer"); + return; + } + if (answer.media.text == "one" && answer.media.emoji.name == dpp::unicode_emoji::one) { + if (correct[i]) { + set_status(POLL_CREATE, ts_failed, "poll answer found twice"); + return; + } + correct[i] = true; + } + if (answer.media.text == "two" && answer.media.emoji.name == dpp::unicode_emoji::two) { + if (correct[i]) { + set_status(POLL_CREATE, ts_failed, "poll answer found twice"); + return; + } + correct[i] = true; + } + if (answer.media.text == "three" && answer.media.emoji.name == dpp::unicode_emoji::three) { + if (correct[i]) { + set_status(POLL_CREATE, ts_failed, "poll answer found twice"); + return; + } + correct[i] = true; + } + if (answer.media.text == "four" && answer.media.emoji.name.empty()) { + if (correct[i]) { + set_status(POLL_CREATE, ts_failed, "poll answer found twice"); + return; + } + correct[i] = true; + bot.poll_get_answer_voters(m, answer.id, 0, 100, [m, &bot](const dpp::confirmation_callback_t& result) { + if (result.is_error()) { + set_status(POLL_CREATE, ts_failed, "poll_get_answer_voters: " + result.get_error().human_readable); + return; + } + + start_test(POLL_END); + bot.poll_end(m, [message_id = m.id, channel_id = m.channel_id, &bot](const dpp::confirmation_callback_t& result) { + if (result.is_error()) { + set_status(POLL_END, ts_failed, result.get_error().human_readable); + return; + } + set_status(POLL_END, ts_success); + bot.message_delete(message_id, channel_id); + }); + }); + } + ++i; + } + if (correct == std::array{true, true, true, true}) { + set_status(POLL_CREATE, ts_success); + } else { + set_status(POLL_CREATE, ts_failed, "failed to find the submitted answers"); + } + }); + } + set_test(MEMBER_GET, false); if (!offline) { bot.guild_get_member(TEST_GUILD_ID, TEST_USER_ID, [](const dpp::confirmation_callback_t &event){ diff --git a/src/unittest/test.h b/src/unittest/test.h index 2cdb80ac77..51a707a06e 100644 --- a/src/unittest/test.h +++ b/src/unittest/test.h @@ -233,6 +233,9 @@ DPP_TEST(VOICESEND, "Send audio to voice channel", tf_online | tf_extended); // DPP_TEST(MESSAGEPIN, "Pinning a channel message", tf_online | tf_extended); DPP_TEST(MESSAGEUNPIN, "Unpinning a channel message", tf_online | tf_extended); +DPP_TEST(POLL_CREATE, "Creating a poll", tf_online); +DPP_TEST(POLL_END, "Ending a poll", tf_online); + DPP_TEST(THREAD_MEMBER_ADD, "cluster::thread_member_add", tf_online | tf_extended); DPP_TEST(THREAD_MEMBER_GET, "cluster::thread_member_get", tf_online | tf_extended); DPP_TEST(THREAD_MEMBERS_GET, "cluster::thread_members_get", tf_online | tf_extended);