use crate::api::BotApi;
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)?))
}
}
fn is_default<T>(value: &T) -> bool
where
T: Default + PartialEq,
{
value == &T::default()
}
fn string_field(data: &Value, key: &str) -> String {
data.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
#[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 {
Self {
keyword: string_field(data, "keyword"),
user_id: string_field(data, "user_id"),
request: string_field(data, "request"),
message_id: string_field(data, "message_id"),
member_nick: string_field(data, "member_nick"),
button_data: string_field(data, "button_data"),
button_id: string_field(data, "button_id"),
feature_id: string_field(data, "feature_id"),
feedback_opt: string_field(data, "feedback_opt"),
checked: data
.get("checked")
.and_then(Value::as_i64)
.map_or(0, |value| value as i32),
}
}
}
#[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 {
Self {
name: string_field(data, "name"),
data_type: data
.get("type")
.and_then(Value::as_u64)
.map(|value| InteractionDataType::from(value as u8)),
resolved: Resolved::new(
data.get("resolved")
.unwrap_or(&Value::Object(serde_json::Map::new())),
),
}
}
}
#[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 {
Self {
api,
event_id,
id: data.get("id").and_then(|v| v.as_str()).map(String::from),
application_id: data.get("application_id").and_then(|v| {
v.as_str()
.map(String::from)
.or_else(|| v.as_u64().map(|value| value.to_string()))
}),
interaction_type: data
.get("type")
.and_then(|v| v.as_u64())
.map(|v| InteractionType::from(v as u8)),
scene: data.get("scene").and_then(|v| v.as_str()).map(String::from),
chat_type: data.get("chat_type").and_then(|v| v.as_u64()),
data: InteractionData::new(
data.get("data")
.unwrap_or(&Value::Object(serde_json::Map::new())),
),
guild_id: data
.get("guild_id")
.and_then(|v| v.as_str())
.map(String::from),
channel_id: data
.get("channel_id")
.and_then(|v| v.as_str())
.map(String::from),
user_openid: data
.get("user_openid")
.and_then(|v| v.as_str())
.map(String::from),
group_openid: data
.get("group_openid")
.and_then(|v| v.as_str())
.map(String::from),
group_member_openid: data
.get("group_member_openid")
.and_then(|v| v.as_str())
.map(String::from),
timestamp: data.get("timestamp").and_then(|v| {
v.as_str()
.map(String::from)
.or_else(|| v.as_u64().map(|value| value.to_string()))
}),
version: data.get("version").and_then(|v| v.as_u64()),
}
}
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
)
}
}
#[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_botgo_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_botgo_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 botgo_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 botgo_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());
}
}