use crate::api::BotApi;
use crate::models::serde_helpers::{deserialize_string_or_number, is_default};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum InteractionType {
Ping = 1,
ApplicationCommand = 2,
HttpProxy = 10,
InlineKeyboard = 11,
}
#[allow(non_upper_case_globals)]
pub const InteractionTypePing: InteractionType = InteractionType::Ping;
#[allow(non_upper_case_globals)]
pub const InteractionTypeCommand: InteractionType = InteractionType::ApplicationCommand;
impl From<u8> for InteractionType {
fn from(value: u8) -> Self {
match value {
1 => Self::Ping,
2 => Self::ApplicationCommand,
10 => Self::HttpProxy,
11 => Self::InlineKeyboard,
_ => Self::Ping, }
}
}
impl Serialize for InteractionType {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u8(*self as u8)
}
}
impl<'de> Deserialize<'de> for InteractionType {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Self::from(u8::deserialize(deserializer)?))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum InteractionDataType {
ChatInputSearch = 9,
HttpProxy = 10,
InlineKeyboardButtonClick = 11,
CallbackCommandClick = 12,
MessageFeedbackClick = 13,
ClearSessionClick = 14,
}
#[allow(non_upper_case_globals)]
pub const InteractionDataTypeChatSearch: InteractionDataType = InteractionDataType::ChatInputSearch;
#[allow(non_upper_case_globals)]
pub const InteractionDataTypeInlineKeyboardClick: InteractionDataType =
InteractionDataType::InlineKeyboardButtonClick;
#[allow(non_upper_case_globals)]
pub const InteractionDataTypeCallbackCommandClick: InteractionDataType =
InteractionDataType::CallbackCommandClick;
#[allow(non_upper_case_globals)]
pub const InteractionDataTypeMessageFeedbackClick: InteractionDataType =
InteractionDataType::MessageFeedbackClick;
#[allow(non_upper_case_globals)]
pub const InteractionDataTypeClearSessionClick: InteractionDataType =
InteractionDataType::ClearSessionClick;
pub type LayoutType = u32;
pub const LAYOUT_TYPE_IMAGE_TEXT: LayoutType = 0;
#[allow(non_upper_case_globals)]
pub const LayoutTypeImageText: LayoutType = LAYOUT_TYPE_IMAGE_TEXT;
pub type ActionType = u32;
pub const ACTION_TYPE_SEND_ARK: ActionType = 0;
#[allow(non_upper_case_globals)]
pub const ActionTypeSendARK: ActionType = ACTION_TYPE_SEND_ARK;
impl From<u8> for InteractionDataType {
fn from(value: u8) -> Self {
match value {
9 => Self::ChatInputSearch,
10 => Self::HttpProxy,
11 => Self::InlineKeyboardButtonClick,
12 => Self::CallbackCommandClick,
13 => Self::MessageFeedbackClick,
14 => Self::ClearSessionClick,
_ => Self::ChatInputSearch, }
}
}
impl Serialize for InteractionDataType {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u8(*self as u8)
}
}
impl<'de> Deserialize<'de> for InteractionDataType {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
Ok(Self::from(u8::deserialize(deserializer)?))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Resolved {
#[serde(default)]
pub keyword: String,
#[serde(default)]
pub user_id: String,
#[serde(default)]
pub request: String,
#[serde(default)]
pub message_id: String,
#[serde(default)]
pub member_nick: String,
#[serde(default)]
pub button_data: String,
#[serde(default)]
pub button_id: String,
#[serde(default)]
pub feature_id: String,
#[serde(default)]
pub feedback_opt: String,
#[serde(default)]
pub checked: i32,
}
impl Resolved {
pub fn new(data: &Value) -> Self {
serde_json::from_value(data.clone()).unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct InteractionData {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub data_type: Option<InteractionDataType>,
#[serde(default, skip_serializing_if = "is_default")]
pub resolved: Resolved,
}
impl InteractionData {
pub fn new(data: &Value) -> Self {
serde_json::from_value(data.clone()).unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SearchInputResolved {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub keyword: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SearchRsp {
#[serde(default)]
pub layouts: Vec<SearchLayout>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SearchLayout {
#[serde(rename = "LayoutType")]
pub layout_type: LayoutType,
#[serde(rename = "ActionType")]
pub action_type: ActionType,
#[serde(rename = "Title")]
pub title: String,
#[serde(rename = "Records", default)]
pub records: Vec<SearchRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SearchRecord {
#[serde(default)]
pub cover: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub tips: String,
#[serde(rename = "url", alias = "URL", default)]
pub url: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct Interaction {
#[serde(skip)]
api: BotApi,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub application_id: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub interaction_type: Option<InteractionType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scene: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_type: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
#[serde(skip_serializing_if = "is_default")]
pub data: InteractionData,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_openid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_openid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_member_openid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<u64>,
}
impl Interaction {
pub fn new(api: BotApi, event_id: Option<String>, data: &Value) -> Self {
let wire: InteractionWire = serde_json::from_value(data.clone()).unwrap_or_default();
Self {
api,
event_id,
id: wire.id,
application_id: wire.application_id,
interaction_type: wire.interaction_type,
scene: wire.scene,
chat_type: wire.chat_type,
data: wire.data,
guild_id: wire.guild_id,
channel_id: wire.channel_id,
user_openid: wire.user_openid,
group_openid: wire.group_openid,
group_member_openid: wire.group_member_openid,
timestamp: wire.timestamp,
version: wire.version,
}
}
pub fn api(&self) -> &BotApi {
&self.api
}
pub fn is_button_interaction(&self) -> bool {
matches!(
self.data.data_type,
Some(InteractionDataType::InlineKeyboardButtonClick)
)
}
pub fn is_command_interaction(&self) -> bool {
matches!(
self.interaction_type,
Some(InteractionType::ApplicationCommand)
)
}
pub fn button_id(&self) -> Option<&str> {
(!self.data.resolved.button_id.is_empty()).then_some(self.data.resolved.button_id.as_str())
}
pub fn button_data(&self) -> Option<&str> {
(!self.data.resolved.button_data.is_empty())
.then_some(self.data.resolved.button_data.as_str())
}
}
impl std::fmt::Display for Interaction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Interaction {{ id: {:?}, type: {:?}, scene: {:?}, chat_type: {:?}, event_id: {:?} }}",
self.id, self.interaction_type, self.scene, self.chat_type, self.event_id
)
}
}
#[derive(Debug, Default, Deserialize)]
struct InteractionWire {
#[serde(default)]
id: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_number")]
application_id: Option<String>,
#[serde(rename = "type", default)]
interaction_type: Option<InteractionType>,
#[serde(default)]
scene: Option<String>,
#[serde(default)]
chat_type: Option<u64>,
#[serde(default)]
data: InteractionData,
#[serde(default)]
guild_id: Option<String>,
#[serde(default)]
channel_id: Option<String>,
#[serde(default)]
user_openid: Option<String>,
#[serde(default)]
group_openid: Option<String>,
#[serde(default)]
group_member_openid: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_number")]
timestamp: Option<String>,
#[serde(default)]
version: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interaction_type() {
assert_eq!(InteractionType::Ping as u8, 1);
assert_eq!(InteractionType::ApplicationCommand as u8, 2);
assert_eq!(InteractionType::HttpProxy as u8, 10);
assert_eq!(InteractionType::InlineKeyboard as u8, 11);
}
#[test]
fn test_interaction_data_type() {
assert_eq!(InteractionDataType::ChatInputSearch as u8, 9);
assert_eq!(InteractionDataType::HttpProxy as u8, 10);
assert_eq!(InteractionDataType::InlineKeyboardButtonClick as u8, 11);
assert_eq!(InteractionDataType::CallbackCommandClick as u8, 12);
assert_eq!(InteractionDataType::MessageFeedbackClick as u8, 13);
assert_eq!(InteractionDataType::ClearSessionClick as u8, 14);
}
#[test]
fn test_interaction_type_from() {
assert_eq!(InteractionType::from(1), InteractionType::Ping);
assert_eq!(
InteractionType::from(2),
InteractionType::ApplicationCommand
);
assert_eq!(InteractionType::from(10), InteractionType::HttpProxy);
assert_eq!(InteractionType::from(11), InteractionType::InlineKeyboard);
}
#[test]
fn test_interaction_data_type_from() {
assert_eq!(
InteractionDataType::from(9),
InteractionDataType::ChatInputSearch
);
assert_eq!(
InteractionDataType::from(10),
InteractionDataType::HttpProxy
);
assert_eq!(
InteractionDataType::from(11),
InteractionDataType::InlineKeyboardButtonClick
);
assert_eq!(
InteractionDataType::from(12),
InteractionDataType::CallbackCommandClick
);
assert_eq!(
InteractionDataType::from(13),
InteractionDataType::MessageFeedbackClick
);
assert_eq!(
InteractionDataType::from(14),
InteractionDataType::ClearSessionClick
);
}
#[test]
fn interaction_types_serialize_as_numeric_wire_values() {
assert_eq!(
serde_json::to_value(InteractionType::ApplicationCommand).unwrap(),
serde_json::json!(2)
);
assert_eq!(
serde_json::from_value::<InteractionType>(serde_json::json!(11)).unwrap(),
InteractionType::InlineKeyboard
);
assert_eq!(
serde_json::to_value(InteractionDataType::ChatInputSearch).unwrap(),
serde_json::json!(9)
);
assert_eq!(
serde_json::from_value::<InteractionDataType>(serde_json::json!(14)).unwrap(),
InteractionDataType::ClearSessionClick
);
}
#[test]
fn interaction_payload_uses_expected_type_fields() {
let interaction = Interaction::new(
BotApi::new(crate::http::HttpClient::new(30, false).unwrap()),
Some("event-1".to_string()),
&serde_json::json!({
"id": "interaction-1",
"application_id": "app-1",
"type": 2,
"data": {
"name": "search",
"type": 9,
"resolved": {
"keyword": "botrs"
}
},
"version": 1
}),
);
let value = serde_json::to_value(&interaction).unwrap();
assert_eq!(value["type"], serde_json::json!(2));
assert_eq!(value["data"]["type"], serde_json::json!(9));
assert!(value.get("interaction_type").is_none());
assert!(value["data"].get("data_type").is_none());
}
#[test]
fn resolved_uses_required_zero_value_fields() {
let resolved: Resolved = serde_json::from_value(serde_json::json!({
"button_id": "btn-1",
"checked": 1
}))
.unwrap();
assert_eq!(resolved.keyword, "");
assert_eq!(resolved.user_id, "");
assert_eq!(resolved.request, "");
assert_eq!(resolved.message_id, "");
assert_eq!(resolved.member_nick, "");
assert_eq!(resolved.button_data, "");
assert_eq!(resolved.button_id, "btn-1");
assert_eq!(resolved.feature_id, "");
assert_eq!(resolved.feedback_opt, "");
assert_eq!(resolved.checked, 1);
let value = serde_json::to_value(Resolved::default()).unwrap();
assert_eq!(value["keyword"], "");
assert_eq!(value["button_id"], "");
assert_eq!(value["checked"], 0);
}
#[test]
fn search_dtos_keep_official_json_shape() {
let resolved = SearchInputResolved {
keyword: "botrs".to_string(),
};
let resolved_value = serde_json::to_value(&resolved).unwrap();
assert_eq!(resolved_value["keyword"], "botrs");
let empty_resolved = serde_json::to_value(SearchInputResolved::default()).unwrap();
assert!(empty_resolved.get("keyword").is_none());
let response = SearchRsp {
layouts: vec![SearchLayout {
layout_type: LayoutTypeImageText,
action_type: ActionTypeSendARK,
title: "docs".to_string(),
records: vec![SearchRecord {
cover: "https://example.com/cover.png".to_string(),
title: "BotRS".to_string(),
tips: "Rust SDK".to_string(),
url: "https://example.com".to_string(),
}],
}],
};
let value = serde_json::to_value(&response).unwrap();
assert_eq!(value["layouts"][0]["LayoutType"], 0);
assert_eq!(value["layouts"][0]["ActionType"], 0);
assert_eq!(value["layouts"][0]["Title"], "docs");
assert_eq!(
value["layouts"][0]["Records"][0]["cover"],
"https://example.com/cover.png"
);
assert_eq!(value["layouts"][0]["Records"][0]["title"], "BotRS");
assert_eq!(value["layouts"][0]["Records"][0]["tips"], "Rust SDK");
assert_eq!(
value["layouts"][0]["Records"][0]["url"],
"https://example.com"
);
assert!(value["layouts"][0].get("layout_type").is_none());
assert!(value["layouts"][0].get("action_type").is_none());
}
}