use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireUser {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireFile {
pub kind: String,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireReplyRef {
pub message_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireSticker {
pub file_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emoji: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set_name: Option<String>,
#[serde(default = "default_static")]
pub format: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
fn default_static() -> String {
"static".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboundMessage {
pub chat: String,
pub user: WireUser,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<WireReplyRef>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<WireFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sticker: Option<WireSticker>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ambient: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboundCommand {
pub chat: String,
pub user: WireUser,
pub name: String,
#[serde(default)]
pub args: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboundButton {
pub chat: String,
pub user: WireUser,
pub data: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboundReaction {
pub chat: String,
pub user: WireUser,
pub message_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub added: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub removed: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InboundEdited {
pub chat: String,
pub user: WireUser,
pub message_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Inbound {
Subscribe,
Message(Box<InboundMessage>),
Command(InboundCommand),
Button(InboundButton),
Reaction(InboundReaction),
Edited(InboundEdited),
}
impl Inbound {
pub fn chat(&self) -> &str {
match self {
Self::Message(e) => &e.chat,
Self::Command(e) => &e.chat,
Self::Button(e) => &e.chat,
Self::Reaction(e) => &e.chat,
Self::Edited(e) => &e.chat,
Self::Subscribe => "",
}
}
pub fn user(&self) -> Option<&WireUser> {
match self {
Self::Message(e) => Some(&e.user),
Self::Command(e) => Some(&e.user),
Self::Button(e) => Some(&e.user),
Self::Reaction(e) => Some(&e.user),
Self::Edited(e) => Some(&e.user),
Self::Subscribe => None,
}
}
pub fn message_id(&self) -> Option<i64> {
match self {
Self::Message(e) => e.message_id,
Self::Command(e) => e.message_id,
Self::Button(e) => e.message_id,
Self::Reaction(e) => Some(e.message_id),
Self::Edited(e) => Some(e.message_id),
Self::Subscribe => None,
}
}
pub fn thread_id(&self) -> Option<i64> {
match self {
Self::Message(e) => e.thread_id,
Self::Command(e) => e.thread_id,
Self::Button(e) => e.thread_id,
Self::Edited(e) => e.thread_id,
Self::Reaction(_) | Self::Subscribe => None,
}
}
pub(crate) fn ensure_message_id(&mut self, next: impl Fn() -> i64) {
let slot = match self {
Self::Message(e) => &mut e.message_id,
Self::Command(e) => &mut e.message_id,
Self::Button(e) => &mut e.message_id,
_ => return,
};
if slot.is_none() {
*slot = Some(next());
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireButton {
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundMessage {
pub chat: String,
pub message_id: i64,
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub buttons: Vec<Vec<WireButton>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply_to: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extras: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundFile {
pub chat: String,
pub message_id: i64,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundReaction {
pub chat: String,
pub message_id: i64,
pub emojis: Vec<String>,
#[serde(default)]
pub is_big: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundEdit {
pub chat: String,
pub message_id: i64,
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundDelete {
pub chat: String,
pub message_id: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundPin {
pub chat: String,
pub message_id: i64,
#[serde(default)]
pub unpin: bool,
#[serde(default)]
pub notify: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundAction {
pub chat: String,
pub action: String,
#[serde(default)]
pub clear: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundAck {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutboundError {
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Outbound {
Message(OutboundMessage),
File(OutboundFile),
Reaction(OutboundReaction),
Edit(OutboundEdit),
Delete(OutboundDelete),
Pin(OutboundPin),
Action(OutboundAction),
Ack(OutboundAck),
Error(OutboundError),
}
#[cfg(test)]
mod tests {
use super::*;
fn user() -> WireUser {
WireUser {
id: "u1".to_string(),
name: "alice".to_string(),
}
}
#[test]
fn inbound_message_round_trips() {
let event = Inbound::Message(Box::new(InboundMessage {
chat: "c1".to_string(),
user: user(),
message_id: Some(7),
text: Some("hi".to_string()),
caption: None,
thread_id: Some(3),
reply_to: None,
files: vec![WireFile {
kind: "photo".to_string(),
path: "/tmp/x.png".to_string(),
mime: None,
file_id: None,
}],
sticker: None,
ambient: false,
}));
let line = serde_json::to_string(&event).unwrap();
let parsed: Inbound = serde_json::from_str(&line).unwrap();
assert_eq!(parsed, event);
assert_eq!(parsed.chat(), "c1");
assert_eq!(parsed.message_id(), Some(7));
assert_eq!(parsed.thread_id(), Some(3));
}
#[test]
fn inbound_tagged_variants_parse() {
let command: Inbound = serde_json::from_str(
r#"{"type":"command","chat":"c1","user":{"id":"u","name":"n"},"name":"ping","args":"a b"}"#,
)
.unwrap();
assert!(matches!(command, Inbound::Command(_)));
let button: Inbound = serde_json::from_str(
r#"{"type":"button","chat":"c1","user":{"id":"u","name":"n"},"data":"yes"}"#,
)
.unwrap();
assert!(matches!(button, Inbound::Button(_)));
let reaction: Inbound = serde_json::from_str(
r#"{"type":"reaction","chat":"c1","user":{"id":"u","name":"n"},"message_id":9,"added":["👍"]}"#,
)
.unwrap();
match reaction {
Inbound::Reaction(r) => assert_eq!(r.added, ["👍"]),
other => panic!("expected reaction, got {other:?}"),
}
}
#[test]
fn outbound_message_omits_empty_fields() {
let outbound = Outbound::Message(OutboundMessage {
chat: "c1".to_string(),
message_id: 1,
text: "hi".to_string(),
buttons: vec![],
reply_to: None,
thread_id: None,
extras: None,
});
let json = serde_json::to_value(&outbound).unwrap();
assert_eq!(json["type"], "message");
assert!(json.get("buttons").is_none());
assert!(json.get("thread_id").is_none());
}
#[test]
fn ensure_message_id_fills_missing() {
let mut event = Inbound::Message(Box::new(InboundMessage {
chat: "c".to_string(),
user: user(),
message_id: None,
text: None,
caption: None,
thread_id: None,
reply_to: None,
files: vec![],
sticker: None,
ambient: false,
}));
event.ensure_message_id(|| 42);
assert_eq!(event.message_id(), Some(42));
let mut fixed = Inbound::Edited(InboundEdited {
chat: "c".to_string(),
user: user(),
message_id: 5,
text: None,
thread_id: None,
});
fixed.ensure_message_id(|| 99);
assert_eq!(fixed.message_id(), Some(5));
}
}