use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
pub const ILINK_APP_ID: &str = "bot";
pub const CHANNEL_VERSION: &str = "2.4.6";
pub const QR_CODE_BASE_URL: &str = "https://ilinkai.weixin.qq.com/";
pub const DEFAULT_ILINK_BOT_TYPE: &str = "3";
pub const STALE_TOKEN_ERRCODE: i32 = -14;
#[deprecated(
since = "0.3.0",
note = "use STALE_TOKEN_ERRCODE — -14 means the token is stale, not the session"
)]
pub const SESSION_EXPIRED_ERRCODE: i32 = STALE_TOKEN_ERRCODE;
pub const TEXT_CHUNK_LIMIT: usize = 4000;
pub const DEFAULT_LONG_POLL_TIMEOUT_MS: u64 = 35_000;
pub const DEFAULT_API_TIMEOUT_MS: u64 = 15_000;
pub const DEFAULT_CONFIG_TIMEOUT_MS: u64 = 10_000;
pub const SESSION_PAUSE_DURATION_MS: u64 = 3_600_000;
pub const MAX_CONSECUTIVE_FAILURES: u32 = 3;
pub const BACKOFF_DELAY_MS: u64 = 30_000;
pub const RETRY_DELAY_MS: u64 = 2_000;
pub const UPLOAD_MAX_RETRIES: u32 = 3;
pub const CONFIG_CACHE_TTL_MS: u64 = 86_400_000;
pub const MAX_QR_REFRESH_COUNT: u32 = 3;
pub const DEFAULT_QR_POLL_TIMEOUT_MS: u64 = 35_000;
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq)]
#[repr(u8)]
#[non_exhaustive]
pub enum UploadMediaType {
Image = 1,
Video = 2,
File = 3,
Voice = 4,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum MessageType {
#[default]
None,
User,
Bot,
Unknown(i32),
}
impl MessageType {
pub fn code(self) -> i32 {
match self {
Self::None => 0,
Self::User => 1,
Self::Bot => 2,
Self::Unknown(n) => n,
}
}
pub fn from_code(code: i32) -> Self {
match code {
0 => Self::None,
1 => Self::User,
2 => Self::Bot,
n => Self::Unknown(n),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum MessageItemType {
#[default]
None,
Text,
Image,
Voice,
File,
Video,
ToolCallStart,
ToolCallResult,
Unknown(i32),
}
impl MessageItemType {
pub fn code(self) -> i32 {
match self {
Self::None => 0,
Self::Text => 1,
Self::Image => 2,
Self::Voice => 3,
Self::File => 4,
Self::Video => 5,
Self::ToolCallStart => 11,
Self::ToolCallResult => 12,
Self::Unknown(n) => n,
}
}
pub fn from_code(code: i32) -> Self {
match code {
0 => Self::None,
1 => Self::Text,
2 => Self::Image,
3 => Self::Voice,
4 => Self::File,
5 => Self::Video,
11 => Self::ToolCallStart,
12 => Self::ToolCallResult,
n => Self::Unknown(n),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum MessageState {
#[default]
New,
Generating,
Finish,
Unknown(i32),
}
impl MessageState {
pub fn code(self) -> i32 {
match self {
Self::New => 0,
Self::Generating => 1,
Self::Finish => 2,
Self::Unknown(n) => n,
}
}
pub fn from_code(code: i32) -> Self {
match code {
0 => Self::New,
1 => Self::Generating,
2 => Self::Finish,
n => Self::Unknown(n),
}
}
}
macro_rules! impl_wire_int_serde {
($ty:ty) => {
impl Serialize for $ty {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_i32(self.code())
}
}
impl<'de> Deserialize<'de> for $ty {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
Ok(Self::from_code(i32::deserialize(deserializer)?))
}
}
};
}
impl_wire_int_serde!(MessageType);
impl_wire_int_serde!(MessageItemType);
impl_wire_int_serde!(MessageState);
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq)]
#[repr(u8)]
#[non_exhaustive]
pub enum TypingStatus {
Typing = 1,
Cancel = 2,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum MediaType {
Image,
Video,
Voice,
File,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ToolCallStatus {
Completed,
Failed,
Blocked,
Unknown,
}
impl ToolCallStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Completed => "completed",
Self::Failed => "failed",
Self::Blocked => "blocked",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BaseInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub channel_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bot_agent: Option<String>,
}
pub fn build_base_info_with_agent(bot_agent: &str) -> BaseInfo {
BaseInfo {
channel_version: Some(CHANNEL_VERSION.to_owned()),
bot_agent: Some(bot_agent.to_owned()),
}
}
pub fn build_base_info() -> BaseInfo {
build_base_info_with_agent("weixin-agent-rs")
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CdnMedia {
#[serde(skip_serializing_if = "Option::is_none")]
pub encrypt_query_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aes_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encrypt_type: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub full_url: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TextItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImageItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aeskey: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mid_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_height: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_width: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hd_size: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VoiceItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encode_type: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bits_per_sample: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sample_rate: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub playtime: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FileItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub md5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub len: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VideoItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub play_length: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_md5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_media: Option<CdnMedia>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_size: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_height: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_width: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RefMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub message_item: Option<Box<MessageItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToolCallStartItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ToolCallResultItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageItem {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub item_type: Option<MessageItemType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_completed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub msg_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_msg: Option<RefMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_item: Option<TextItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_item: Option<ImageItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_item: Option<VoiceItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_item: Option<FileItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video_item: Option<VideoItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_start_item: Option<ToolCallStartItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_result_item: Option<ToolCallResultItem>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WeixinMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub create_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub update_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delete_time_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_type: Option<MessageType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_state: Option<MessageState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub item_list: Option<Vec<MessageItem>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetUpdatesRequest {
pub get_updates_buf: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GetUpdatesResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub ret: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errcode: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errmsg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub msgs: Option<Vec<WeixinMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_buf: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub get_updates_buf: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub longpolling_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageRequest {
pub msg: WeixinMessage,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(crate) struct SendMessageResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub ret: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errmsg: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetUploadUrlRequest {
pub filekey: String,
pub media_type: UploadMediaType,
pub to_user_id: String,
pub rawsize: u64,
pub rawfilemd5: String,
pub filesize: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_need_thumb: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_rawsize: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_rawfilemd5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_filesize: Option<u64>,
pub aeskey: String,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GetUploadUrlResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb_upload_param: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upload_full_url: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct GetConfigRequest {
pub ilink_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GetConfigResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub ret: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errmsg: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_ticket: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendTypingRequest {
pub ilink_user_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_ticket: Option<String>,
pub status: TypingStatus,
pub base_info: BaseInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QrCodeResponse {
pub qrcode: String,
pub qrcode_img_content: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QrStatusResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub bot_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ilink_bot_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseurl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ilink_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redirect_host: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_type_round_trips_known_values() {
for code in [0, 1, 2, 3, 4, 5, 11, 12] {
assert_eq!(MessageItemType::from_code(code).code(), code);
}
}
#[test]
fn item_type_preserves_unknown_value() {
let t = MessageItemType::from_code(99);
assert_eq!(t, MessageItemType::Unknown(99));
assert_eq!(t.code(), 99);
}
#[test]
fn item_type_serializes_as_wire_int() {
assert_eq!(
serde_json::to_string(&MessageItemType::ToolCallStart).unwrap(),
"11"
);
assert_eq!(
serde_json::to_string(&MessageItemType::ToolCallResult).unwrap(),
"12"
);
assert_eq!(
serde_json::to_string(&MessageItemType::Unknown(77)).unwrap(),
"77"
);
}
#[test]
fn unknown_item_type_does_not_break_batch() {
let json = r#"{"ret":0,"msgs":[
{"message_type":1,"from_user_id":"u1","item_list":[{"type":1,"text_item":{"text":"hi"}}]},
{"message_type":2,"item_list":[{"type":11,"tool_call_start_item":{"tool_name":"bash"}}]},
{"message_type":2,"item_list":[{"type":99}]}
]}"#;
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
let msgs = resp.msgs.unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(
msgs[1].item_list.as_ref().unwrap()[0].item_type,
Some(MessageItemType::ToolCallStart)
);
assert_eq!(
msgs[2].item_list.as_ref().unwrap()[0].item_type,
Some(MessageItemType::Unknown(99))
);
}
#[test]
fn unknown_message_state_does_not_break_parse() {
let json = r#"{"ret":0,"msgs":[{"message_type":1,"message_state":3}]}"#;
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.msgs.unwrap()[0].message_state,
Some(MessageState::Unknown(3))
);
}
#[test]
fn unknown_message_type_does_not_break_parse() {
let json = r#"{"ret":0,"msgs":[{"message_type":7}]}"#;
let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.msgs.unwrap()[0].message_type,
Some(MessageType::Unknown(7))
);
}
#[test]
fn run_id_deserializes_and_serializes() {
let msg: WeixinMessage = serde_json::from_str(r#"{"run_id":"abc123"}"#).unwrap();
assert_eq!(msg.run_id.as_deref(), Some("abc123"));
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains(r#""run_id":"abc123""#));
let empty = WeixinMessage::default();
assert!(!serde_json::to_string(&empty).unwrap().contains("run_id"));
}
#[test]
fn tool_call_status_wire_strings() {
assert_eq!(ToolCallStatus::Completed.as_str(), "completed");
assert_eq!(ToolCallStatus::Failed.as_str(), "failed");
assert_eq!(ToolCallStatus::Blocked.as_str(), "blocked");
assert_eq!(ToolCallStatus::Unknown.as_str(), "unknown");
}
#[test]
fn channel_version_matches_reference() {
assert_eq!(CHANNEL_VERSION, "2.4.6");
assert_eq!(STALE_TOKEN_ERRCODE, -14);
}
#[test]
fn send_message_response_parses_error_and_empty_object() {
let err: SendMessageResponse =
serde_json::from_str(r#"{"ret":-14,"errmsg":"stale"}"#).unwrap();
assert_eq!(err.ret, Some(-14));
assert_eq!(err.errmsg.as_deref(), Some("stale"));
let ok: SendMessageResponse = serde_json::from_str("{}").unwrap();
assert!(ok.ret.is_none());
}
#[test]
fn item_type_round_trips_i32_bounds_through_serde() {
for code in [i32::MIN, -1, i32::MAX] {
let t = MessageItemType::from_code(code);
let json = serde_json::to_string(&t).unwrap();
assert_eq!(json, code.to_string());
assert_eq!(serde_json::from_str::<MessageItemType>(&json).unwrap(), t);
}
}
#[test]
fn item_type_rejects_out_of_i32_range() {
assert!(serde_json::from_str::<MessageItemType>("2147483648").is_err());
}
}