use serde::{Deserialize, Serialize};
pub const ILINK_BASE_URL: &str = "https://ilinkai.weixin.qq.com";
pub const ILINK_CDN_BASE_URL: &str = "https://novac2c.cdn.weixin.qq.com/c2c";
#[derive(Debug, Serialize, Deserialize, Clone)]
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>,
}
impl Default for BaseInfo {
fn default() -> Self {
Self {
channel_version: Some(env!("CARGO_PKG_VERSION").to_string()),
bot_agent: Some(format!("ilink-hub/{}", env!("CARGO_PKG_VERSION"))),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetQrcodeResponse {
pub ret: i32,
pub qrcode: Option<String>,
pub qrcode_img_content: Option<String>,
pub errmsg: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QrcodeStatusResponse {
pub ret: i32,
pub status: Option<String>,
pub bot_token: Option<String>,
pub baseurl: Option<String>,
pub ilink_bot_id: Option<String>,
pub ilink_user_id: Option<String>,
pub errmsg: Option<String>,
}
pub mod msg_type {
pub const TEXT: i32 = 1;
pub const IMAGE: i32 = 2;
pub const VOICE: i32 = 3;
pub const FILE: i32 = 4;
pub const VIDEO: i32 = 5;
}
pub mod message_state {
pub const FINISH: i32 = 2;
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TextItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VoiceItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImageItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub cdn_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub md5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cdn_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub md5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct VideoItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub cdn_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub md5: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MessageItem {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub item_type: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_item: Option<TextItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_item: Option<VoiceItem>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image_item: Option<ImageItem>,
#[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(flatten)]
pub extra: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
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 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<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message_state: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub item_list: Option<std::sync::Arc<Vec<MessageItem>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ilink_hub_ext: Option<HubExt>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HubExt {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cli_session_id: Option<String>,
}
impl WeixinMessage {
pub fn text(&self) -> Option<&str> {
let items = self.item_list.as_ref()?;
items
.iter()
.find_map(|item| item.text_item.as_ref()?.text.as_deref())
.or_else(|| {
items
.iter()
.find_map(|item| item.voice_item.as_ref()?.text.as_deref())
})
}
pub fn first_item_type(&self) -> Option<i32> {
self.item_list.as_ref()?.first()?.item_type
}
pub fn has_content(&self) -> bool {
self.item_list
.as_ref()
.map(|l| !l.is_empty())
.unwrap_or(false)
}
pub fn has_media_content(&self) -> bool {
self.item_list
.as_ref()
.map(|l| {
l.iter()
.any(|item| !matches!(item.item_type, Some(msg_type::TEXT) | None))
})
.unwrap_or(false)
}
pub fn build_text_reply(context_token: String, text: String) -> WeixinMessage {
let mut msg = WeixinMessage {
context_token: Some(context_token),
message_type: Some(2), message_state: Some(message_state::FINISH),
from_user_id: Some(String::new()),
client_id: Some(new_client_id()),
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(msg_type::TEXT),
text_item: Some(TextItem { text: Some(text) }),
..Default::default()
}])),
..Default::default()
};
msg.ensure_outbound();
msg
}
pub fn build_image_reply(context_token: String, media_id: String) -> WeixinMessage {
let mut msg = WeixinMessage {
context_token: Some(context_token),
message_type: Some(2),
message_state: Some(message_state::FINISH),
from_user_id: Some(String::new()),
client_id: Some(new_client_id()),
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(msg_type::IMAGE),
image_item: Some(ImageItem {
media_id: Some(media_id),
..Default::default()
}),
..Default::default()
}])),
..Default::default()
};
msg.ensure_outbound();
msg
}
pub fn build_file_reply(
context_token: String,
media_id: String,
file_name: Option<String>,
) -> WeixinMessage {
let mut msg = WeixinMessage {
context_token: Some(context_token),
message_type: Some(2),
message_state: Some(message_state::FINISH),
from_user_id: Some(String::new()),
client_id: Some(new_client_id()),
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(msg_type::FILE),
file_item: Some(FileItem {
media_id: Some(media_id),
file_name,
..Default::default()
}),
..Default::default()
}])),
..Default::default()
};
msg.ensure_outbound();
msg
}
pub fn ensure_outbound(&mut self) {
self.from_user_id = Some(String::new());
if self.message_type.is_none() {
self.message_type = Some(2);
}
if self.message_state.is_none() {
self.message_state = Some(message_state::FINISH);
}
if self
.client_id
.as_ref()
.map(|s| s.is_empty())
.unwrap_or(true)
{
self.client_id = Some(new_client_id());
}
}
}
fn new_client_id() -> String {
format!("ilink-hub:{}", uuid::Uuid::new_v4())
}
#[cfg(test)]
mod outbound_tests {
use super::*;
#[test]
fn build_text_reply_sets_outbound_fields() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), "hi".to_string());
assert_eq!(msg.from_user_id.as_deref(), Some(""));
assert_eq!(msg.message_type, Some(2));
assert_eq!(msg.message_state, Some(message_state::FINISH));
assert!(msg.client_id.as_deref().unwrap().starts_with("ilink-hub:"));
}
#[test]
fn ensure_outbound_assigns_unique_client_id() {
let mut msg1 = WeixinMessage::default();
let mut msg2 = WeixinMessage::default();
msg1.ensure_outbound();
msg2.ensure_outbound();
assert_ne!(msg1.client_id, msg2.client_id);
}
#[test]
fn has_media_content_false_for_empty_text_reply() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), String::new());
assert!(
!msg.has_media_content(),
"空 TextItem 不应视为 media content"
);
}
#[test]
fn has_media_content_false_for_nonempty_text_reply() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), "hello".to_string());
assert!(!msg.has_media_content(), "纯文本回复不含 media content");
}
#[test]
fn has_media_content_true_for_image_reply() {
let msg = WeixinMessage::build_image_reply("ctx".to_string(), "media-id-abc".to_string());
assert!(msg.has_media_content(), "图片回复应视为有 media content");
}
#[test]
fn has_media_content_true_for_file_reply() {
let msg = WeixinMessage::build_file_reply(
"ctx".to_string(),
"media-id-xyz".to_string(),
Some("report.pdf".to_string()),
);
assert!(msg.has_media_content(), "文件回复应视为有 media content");
}
#[test]
fn has_media_content_false_for_empty_item_list() {
let msg = WeixinMessage::default();
assert!(
!msg.has_media_content(),
"无 item_list 时不含 media content"
);
}
#[test]
fn has_content_true_for_empty_text_item() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), String::new());
assert!(
msg.has_content(),
"has_content() 对含空 TextItem 的 item_list 应返回 true(记录现有行为)"
);
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetUpdatesRequest {
#[serde(default)]
pub get_updates_buf: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_info: Option<BaseInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
}
#[derive(Debug, 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 get_updates_buf: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendMessageRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub msg: Option<WeixinMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_info: Option<BaseInfo>,
}
impl SendMessageRequest {
pub fn text(context_token: String, text: String) -> Self {
Self {
msg: Some(WeixinMessage::build_text_reply(context_token, text)),
base_info: Some(BaseInfo::default()),
}
}
pub fn reply(context_token: String, text: String, to_user_id: &str) -> Self {
Self::reply_text(context_token, text, to_user_id, None)
}
pub fn reply_text(
context_token: String,
text: String,
to_user_id: &str,
cli_session_id: Option<String>,
) -> Self {
let mut msg = WeixinMessage::build_text_reply(context_token, text);
if !to_user_id.is_empty() {
msg.to_user_id = Some(to_user_id.to_string());
}
if cli_session_id.is_some() {
msg.ilink_hub_ext = Some(HubExt {
cli_session_id,
..Default::default()
});
}
Self {
msg: Some(msg),
base_info: Some(BaseInfo::default()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct SendMessageResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub ret: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub errmsg: Option<String>,
}
impl SendMessageResponse {
pub fn ok() -> Self {
Self {
ret: Some(0),
errmsg: None,
}
}
pub fn err(code: i32, msg: impl Into<String>) -> Self {
Self {
ret: Some(code),
errmsg: Some(msg.into()),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct GetConfigRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub ilink_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_token: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_info: Option<BaseInfo>,
}
#[derive(Debug, Serialize, Deserialize, Default)]
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, Serialize, Deserialize, Default)]
pub struct SendTypingRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub ilink_user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub typing_ticket: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_info: Option<BaseInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetUploadUrlRequest {
pub file_type: String,
pub file_size: u64,
pub file_md5: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetUploadUrlResponse {
pub ret: i32,
pub upload_url: Option<String>,
pub media_id: Option<String>,
pub errmsg: Option<String>,
}