use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub a2a_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub a2a_depth: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<serde_json::Value>,
}
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());
}
if self.message_id.is_none() {
self.message_id = Some(new_outbound_msg_id());
}
}
}
fn new_outbound_msg_id() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed) % 1_000_000;
(millis * 1_000_000 + n) as i64
}
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(记录现有行为)"
);
}
#[test]
fn text_returns_text_item_content() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), "hello".to_string());
assert_eq!(msg.text(), Some("hello"));
}
#[test]
fn text_falls_back_to_voice_item_asr_transcript() {
let msg = WeixinMessage {
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(msg_type::VOICE),
voice_item: Some(VoiceItem {
text: Some("voice asr text".to_string()),
}),
..Default::default()
}])),
..Default::default()
};
assert_eq!(
msg.text(),
Some("voice asr text"),
"text() must fall back to voice_item ASR transcript"
);
}
#[test]
fn text_returns_none_when_no_item_list() {
let msg = WeixinMessage::default();
assert!(msg.text().is_none());
}
#[test]
fn text_returns_none_when_text_item_has_no_text_field_and_no_voice_fallback() {
let msg = WeixinMessage {
item_list: Some(std::sync::Arc::new(vec![MessageItem {
item_type: Some(msg_type::IMAGE),
image_item: Some(crate::ilink::types::ImageItem {
cdn_url: Some("https://cdn.example.com/img.jpg".to_string()),
..Default::default()
}),
..Default::default()
}])),
..Default::default()
};
assert!(
msg.text().is_none(),
"image-only message must return None from text()"
);
}
#[test]
fn first_item_type_returns_type_of_first_item() {
let msg = WeixinMessage::build_text_reply("ctx".to_string(), "hi".to_string());
assert_eq!(msg.first_item_type(), Some(msg_type::TEXT));
}
#[test]
fn first_item_type_returns_none_when_no_item_list() {
let msg = WeixinMessage::default();
assert!(msg.first_item_type().is_none());
}
#[test]
fn reply_text_sets_to_user_id_when_non_empty() {
let req =
SendMessageRequest::reply_text("ctx".to_string(), "hi".to_string(), "user-123", None);
let to_user = req.msg.as_ref().unwrap().to_user_id.as_deref();
assert_eq!(
to_user,
Some("user-123"),
"non-empty to_user_id must be set on msg"
);
}
#[test]
fn reply_text_does_not_set_to_user_id_when_empty() {
let req = SendMessageRequest::reply_text("ctx".to_string(), "hi".to_string(), "", None);
let to_user = req.msg.as_ref().unwrap().to_user_id.as_deref();
assert!(to_user.is_none(), "empty to_user_id must NOT be set on msg");
}
#[test]
fn reply_text_sets_ilink_hub_ext_when_cli_session_id_provided() {
let req = SendMessageRequest::reply_text(
"ctx".to_string(),
"hi".to_string(),
"",
Some("session-uuid-abc".to_string()),
);
let ext = req.msg.as_ref().unwrap().ilink_hub_ext.as_ref();
assert!(ext.is_some(), "cli_session_id must set ilink_hub_ext");
assert_eq!(
ext.unwrap().cli_session_id.as_deref(),
Some("session-uuid-abc")
);
}
#[test]
fn send_message_response_ok_has_ret_zero_and_no_errmsg() {
let r = SendMessageResponse::ok();
assert_eq!(r.ret, Some(0));
assert!(r.errmsg.is_none());
}
#[test]
fn send_message_response_err_has_non_zero_ret_and_errmsg() {
let r = SendMessageResponse::err(-1, "bad request");
assert_eq!(r.ret, Some(-1));
assert_eq!(r.errmsg.as_deref(), Some("bad request"));
}
}
#[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>,
}