Skip to main content

weixin_agent/
types.rs

1//! Protocol types mirroring the Weixin iLink Bot API.
2
3use serde::{Deserialize, Serialize};
4use serde_repr::{Deserialize_repr, Serialize_repr};
5
6// ── Protocol constants ──────────────────────────────────────────────
7
8/// iLink-App-Id header value.
9pub const ILINK_APP_ID: &str = "bot";
10/// Channel version sent in `base_info`.
11pub const CHANNEL_VERSION: &str = "2.4.6";
12/// Fixed QR code base URL.
13pub const QR_CODE_BASE_URL: &str = "https://ilinkai.weixin.qq.com/";
14/// Default bot type for QR login.
15pub const DEFAULT_ILINK_BOT_TYPE: &str = "3";
16/// Error code returned when the bot token is stale / invalid.
17///
18/// The bot must be re-authenticated (QR login) before polling can resume.
19pub const STALE_TOKEN_ERRCODE: i32 = -14;
20/// Deprecated alias of [`STALE_TOKEN_ERRCODE`].
21#[deprecated(
22    since = "0.3.0",
23    note = "use STALE_TOKEN_ERRCODE — -14 means the token is stale, not the session"
24)]
25pub const SESSION_EXPIRED_ERRCODE: i32 = STALE_TOKEN_ERRCODE;
26/// Text chunk limit (characters).
27pub const TEXT_CHUNK_LIMIT: usize = 4000;
28
29// ── Timing constants (ms) ───────────────────────────────────────────
30
31/// Long-poll timeout.
32pub const DEFAULT_LONG_POLL_TIMEOUT_MS: u64 = 35_000;
33/// Regular API timeout.
34pub const DEFAULT_API_TIMEOUT_MS: u64 = 15_000;
35/// Config/typing API timeout.
36pub const DEFAULT_CONFIG_TIMEOUT_MS: u64 = 10_000;
37/// Poll-loop pause after the server reports a stale token.
38pub const SESSION_PAUSE_DURATION_MS: u64 = 3_600_000;
39/// Max consecutive poll failures before backoff.
40pub const MAX_CONSECUTIVE_FAILURES: u32 = 3;
41/// Backoff delay after max failures.
42pub const BACKOFF_DELAY_MS: u64 = 30_000;
43/// Normal retry delay.
44pub const RETRY_DELAY_MS: u64 = 2_000;
45/// CDN upload max retries.
46pub const UPLOAD_MAX_RETRIES: u32 = 3;
47/// Config cache TTL.
48pub const CONFIG_CACHE_TTL_MS: u64 = 86_400_000;
49/// Max QR refresh count.
50pub const MAX_QR_REFRESH_COUNT: u32 = 3;
51/// QR poll timeout.
52pub const DEFAULT_QR_POLL_TIMEOUT_MS: u64 = 35_000;
53
54// ── Enums ───────────────────────────────────────────────────────────
55
56/// CDN upload media type.
57#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq)]
58#[repr(u8)]
59#[non_exhaustive]
60pub enum UploadMediaType {
61    /// Image upload.
62    Image = 1,
63    /// Video upload.
64    Video = 2,
65    /// Generic file upload.
66    File = 3,
67    /// Voice upload.
68    Voice = 4,
69}
70
71/// Message sender type.
72///
73/// Unknown wire values are preserved in [`MessageType::Unknown`] instead of
74/// failing deserialization — see [`MessageItemType`] for the rationale.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum MessageType {
78    /// Unset.
79    #[default]
80    None,
81    /// From a human user.
82    User,
83    /// From a bot.
84    Bot,
85    /// Wire value not known to this SDK version.
86    Unknown(i32),
87}
88
89impl MessageType {
90    /// Wire value for this variant.
91    pub fn code(self) -> i32 {
92        match self {
93            Self::None => 0,
94            Self::User => 1,
95            Self::Bot => 2,
96            Self::Unknown(n) => n,
97        }
98    }
99
100    /// Build from a wire value; unrecognized values map to [`MessageType::Unknown`].
101    pub fn from_code(code: i32) -> Self {
102        match code {
103            0 => Self::None,
104            1 => Self::User,
105            2 => Self::Bot,
106            n => Self::Unknown(n),
107        }
108    }
109}
110
111/// Message item content type.
112///
113/// Unknown wire values are preserved in [`MessageItemType::Unknown`] instead of
114/// failing deserialization — the protocol adds item types over time, and one
115/// unrecognized item must not invalidate an entire `getUpdates` batch.
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum MessageItemType {
119    /// Unset.
120    #[default]
121    None,
122    /// Text content.
123    Text,
124    /// Image content.
125    Image,
126    /// Voice content.
127    Voice,
128    /// File attachment.
129    File,
130    /// Video content.
131    Video,
132    /// Tool call started (progress message).
133    ToolCallStart,
134    /// Tool call finished (progress message).
135    ToolCallResult,
136    /// Wire value not known to this SDK version.
137    Unknown(i32),
138}
139
140impl MessageItemType {
141    /// Wire value for this variant.
142    pub fn code(self) -> i32 {
143        match self {
144            Self::None => 0,
145            Self::Text => 1,
146            Self::Image => 2,
147            Self::Voice => 3,
148            Self::File => 4,
149            Self::Video => 5,
150            Self::ToolCallStart => 11,
151            Self::ToolCallResult => 12,
152            Self::Unknown(n) => n,
153        }
154    }
155
156    /// Build from a wire value; unrecognized values map to [`MessageItemType::Unknown`].
157    pub fn from_code(code: i32) -> Self {
158        match code {
159            0 => Self::None,
160            1 => Self::Text,
161            2 => Self::Image,
162            3 => Self::Voice,
163            4 => Self::File,
164            5 => Self::Video,
165            11 => Self::ToolCallStart,
166            12 => Self::ToolCallResult,
167            n => Self::Unknown(n),
168        }
169    }
170}
171
172/// Message generation state.
173///
174/// Unknown wire values are preserved in [`MessageState::Unknown`] instead of
175/// failing deserialization — see [`MessageItemType`] for the rationale.
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum MessageState {
179    /// New / finished.
180    #[default]
181    New,
182    /// Still generating (streaming).
183    Generating,
184    /// Generation complete.
185    Finish,
186    /// Wire value not known to this SDK version.
187    Unknown(i32),
188}
189
190impl MessageState {
191    /// Wire value for this variant.
192    pub fn code(self) -> i32 {
193        match self {
194            Self::New => 0,
195            Self::Generating => 1,
196            Self::Finish => 2,
197            Self::Unknown(n) => n,
198        }
199    }
200
201    /// Build from a wire value; unrecognized values map to [`MessageState::Unknown`].
202    pub fn from_code(code: i32) -> Self {
203        match code {
204            0 => Self::New,
205            1 => Self::Generating,
206            2 => Self::Finish,
207            n => Self::Unknown(n),
208        }
209    }
210}
211
212/// Implement `Serialize`/`Deserialize` as a bare protocol integer, preserving
213/// unknown values. Replaces `serde_repr`, which cannot express a data-carrying
214/// fallback variant (standards §2.7 exception).
215macro_rules! impl_wire_int_serde {
216    ($ty:ty) => {
217        impl Serialize for $ty {
218            fn serialize<S: serde::Serializer>(
219                &self,
220                serializer: S,
221            ) -> std::result::Result<S::Ok, S::Error> {
222                serializer.serialize_i32(self.code())
223            }
224        }
225
226        impl<'de> Deserialize<'de> for $ty {
227            fn deserialize<D: serde::Deserializer<'de>>(
228                deserializer: D,
229            ) -> std::result::Result<Self, D::Error> {
230                Ok(Self::from_code(i32::deserialize(deserializer)?))
231            }
232        }
233    };
234}
235
236impl_wire_int_serde!(MessageType);
237impl_wire_int_serde!(MessageItemType);
238impl_wire_int_serde!(MessageState);
239
240/// Typing indicator status.
241#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, PartialEq, Eq)]
242#[repr(u8)]
243#[non_exhaustive]
244pub enum TypingStatus {
245    /// Currently typing.
246    Typing = 1,
247    /// Cancel typing indicator.
248    Cancel = 2,
249}
250
251/// High-level media type for inbound messages.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253#[non_exhaustive]
254pub enum MediaType {
255    /// Image media.
256    Image,
257    /// Video media.
258    Video,
259    /// Voice media.
260    Voice,
261    /// Generic file.
262    File,
263}
264
265/// Outcome of a tool call, as reported to the peer.
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267#[non_exhaustive]
268pub enum ToolCallStatus {
269    /// Finished successfully.
270    Completed,
271    /// Finished with an error.
272    Failed,
273    /// Blocked (e.g. awaiting authorization).
274    Blocked,
275    /// Outcome not determined.
276    Unknown,
277}
278
279impl ToolCallStatus {
280    /// Wire representation.
281    pub fn as_str(self) -> &'static str {
282        match self {
283            Self::Completed => "completed",
284            Self::Failed => "failed",
285            Self::Blocked => "blocked",
286            Self::Unknown => "unknown",
287        }
288    }
289}
290
291// ── BaseInfo ────────────────────────────────────────────────────────
292
293/// Metadata attached to every outgoing API request.
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct BaseInfo {
296    /// Channel version string.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub channel_version: Option<String>,
299    /// Bot agent UA string.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub bot_agent: Option<String>,
302}
303
304/// Build a `BaseInfo` with the current channel version and bot agent.
305pub fn build_base_info_with_agent(bot_agent: &str) -> BaseInfo {
306    BaseInfo {
307        channel_version: Some(CHANNEL_VERSION.to_owned()),
308        bot_agent: Some(bot_agent.to_owned()),
309    }
310}
311
312/// Build a `BaseInfo` with the current channel version (legacy, prefer `build_base_info_with_agent`).
313pub fn build_base_info() -> BaseInfo {
314    build_base_info_with_agent("weixin-agent-rs")
315}
316
317// ── CDN / Media sub-structures ──────────────────────────────────────
318
319/// CDN media reference.
320#[derive(Debug, Clone, Default, Serialize, Deserialize)]
321pub struct CdnMedia {
322    /// Encrypted query parameter for CDN download.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub encrypt_query_param: Option<String>,
325    /// AES key (base64-encoded).
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub aes_key: Option<String>,
328    /// Encrypt type: 0 = fileid only, 1 = packed.
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub encrypt_type: Option<i32>,
331    /// Full download URL from server.
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub full_url: Option<String>,
334}
335
336/// Text item.
337#[derive(Debug, Clone, Default, Serialize, Deserialize)]
338pub struct TextItem {
339    /// Text content.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub text: Option<String>,
342}
343
344/// Image item.
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346pub struct ImageItem {
347    /// Original image CDN reference.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub media: Option<CdnMedia>,
350    /// Thumbnail CDN reference.
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub thumb_media: Option<CdnMedia>,
353    /// Raw AES key as hex string (preferred for inbound decryption).
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub aeskey: Option<String>,
356    /// Image URL.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub url: Option<String>,
359    /// Mid-size ciphertext bytes.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub mid_size: Option<i64>,
362    /// Thumbnail size.
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub thumb_size: Option<i64>,
365    /// Thumbnail height.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub thumb_height: Option<i64>,
368    /// Thumbnail width.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub thumb_width: Option<i64>,
371    /// HD size.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub hd_size: Option<i64>,
374}
375
376/// Voice item.
377#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378pub struct VoiceItem {
379    /// Voice CDN reference.
380    #[serde(skip_serializing_if = "Option::is_none")]
381    pub media: Option<CdnMedia>,
382    /// Encoding type.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub encode_type: Option<i32>,
385    /// Bits per sample.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub bits_per_sample: Option<i32>,
388    /// Sample rate (Hz).
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub sample_rate: Option<i32>,
391    /// Duration in milliseconds.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub playtime: Option<i64>,
394    /// Speech-to-text result.
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub text: Option<String>,
397}
398
399/// File item.
400#[derive(Debug, Clone, Default, Serialize, Deserialize)]
401pub struct FileItem {
402    /// File CDN reference.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub media: Option<CdnMedia>,
405    /// Original file name.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub file_name: Option<String>,
408    /// File MD5.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub md5: Option<String>,
411    /// Plaintext file size as string.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub len: Option<String>,
414}
415
416/// Video item.
417#[derive(Debug, Clone, Default, Serialize, Deserialize)]
418pub struct VideoItem {
419    /// Video CDN reference.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub media: Option<CdnMedia>,
422    /// Video ciphertext size.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub video_size: Option<i64>,
425    /// Play length in seconds.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub play_length: Option<i64>,
428    /// Video MD5.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub video_md5: Option<String>,
431    /// Thumbnail CDN reference.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub thumb_media: Option<CdnMedia>,
434    /// Thumbnail size.
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub thumb_size: Option<i64>,
437    /// Thumbnail height.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub thumb_height: Option<i64>,
440    /// Thumbnail width.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub thumb_width: Option<i64>,
443}
444
445/// Reference (quoted) message.
446#[derive(Debug, Clone, Default, Serialize, Deserialize)]
447pub struct RefMessage {
448    /// Quoted message item.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub message_item: Option<Box<MessageItem>>,
451    /// Summary title.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub title: Option<String>,
454}
455
456/// Tool call start payload (item type 11).
457#[derive(Debug, Clone, Default, Serialize, Deserialize)]
458#[non_exhaustive]
459pub struct ToolCallStartItem {
460    /// Tool name.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub tool_name: Option<String>,
463    /// Caller-assigned tool call ID, used to pair start with result.
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub tool_call_id: Option<String>,
466}
467
468/// Tool call result payload (item type 12).
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470#[non_exhaustive]
471pub struct ToolCallResultItem {
472    /// Tool name.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub tool_name: Option<String>,
475    /// Tool call ID matching the corresponding start item.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub tool_call_id: Option<String>,
478    /// Normalized status string (see [`ToolCallStatus::as_str`]).
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub status: Option<String>,
481}
482
483/// A single content item within a message.
484#[derive(Debug, Clone, Default, Serialize, Deserialize)]
485pub struct MessageItem {
486    /// Item type.
487    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
488    pub item_type: Option<MessageItemType>,
489    /// Creation timestamp (ms).
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub create_time_ms: Option<i64>,
492    /// Update timestamp (ms).
493    #[serde(skip_serializing_if = "Option::is_none")]
494    pub update_time_ms: Option<i64>,
495    /// Whether generation is complete.
496    #[serde(skip_serializing_if = "Option::is_none")]
497    pub is_completed: Option<bool>,
498    /// Item-level message ID.
499    #[serde(skip_serializing_if = "Option::is_none")]
500    pub msg_id: Option<String>,
501    /// Referenced (quoted) message.
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub ref_msg: Option<RefMessage>,
504    /// Text content.
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub text_item: Option<TextItem>,
507    /// Image content.
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub image_item: Option<ImageItem>,
510    /// Voice content.
511    #[serde(skip_serializing_if = "Option::is_none")]
512    pub voice_item: Option<VoiceItem>,
513    /// File content.
514    #[serde(skip_serializing_if = "Option::is_none")]
515    pub file_item: Option<FileItem>,
516    /// Video content.
517    #[serde(skip_serializing_if = "Option::is_none")]
518    pub video_item: Option<VideoItem>,
519    /// Tool call start content.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub tool_call_start_item: Option<ToolCallStartItem>,
522    /// Tool call result content.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub tool_call_result_item: Option<ToolCallResultItem>,
525}
526
527// ── WeixinMessage ───────────────────────────────────────────────────
528
529/// Unified message from `getUpdates`.
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
531pub struct WeixinMessage {
532    /// Sequence number.
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub seq: Option<i64>,
535    /// Server-assigned message ID.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub message_id: Option<i64>,
538    /// Sender user ID.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub from_user_id: Option<String>,
541    /// Recipient user ID.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub to_user_id: Option<String>,
544    /// Client-generated message ID.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub client_id: Option<String>,
547    /// Creation timestamp (ms).
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub create_time_ms: Option<i64>,
550    /// Update timestamp (ms).
551    #[serde(skip_serializing_if = "Option::is_none")]
552    pub update_time_ms: Option<i64>,
553    /// Deletion timestamp (ms); >0 means recalled.
554    #[serde(skip_serializing_if = "Option::is_none")]
555    pub delete_time_ms: Option<i64>,
556    /// Session ID.
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub session_id: Option<String>,
559    /// Group ID.
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub group_id: Option<String>,
562    /// Sender type (user / bot).
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub message_type: Option<MessageType>,
565    /// Generation state.
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub message_state: Option<MessageState>,
568    /// Content items.
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub item_list: Option<Vec<MessageItem>>,
571    /// Context token for replies.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub context_token: Option<String>,
574    /// Run ID grouping all messages of one logical outbound run.
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub run_id: Option<String>,
577}
578
579// ── API request / response types ────────────────────────────────────
580
581/// `getUpdates` request body.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct GetUpdatesRequest {
584    /// Full context buf from previous response.
585    pub get_updates_buf: String,
586    /// Metadata.
587    pub base_info: BaseInfo,
588}
589
590/// `getUpdates` response body.
591#[derive(Debug, Clone, Default, Serialize, Deserialize)]
592pub struct GetUpdatesResponse {
593    /// Return code (0 = success).
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub ret: Option<i32>,
596    /// Error code.
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub errcode: Option<i32>,
599    /// Error message.
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub errmsg: Option<String>,
602    /// Inbound messages.
603    #[serde(skip_serializing_if = "Option::is_none")]
604    pub msgs: Option<Vec<WeixinMessage>>,
605    /// Legacy sync buf (compat).
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub sync_buf: Option<String>,
608    /// New context buf to cache.
609    #[serde(skip_serializing_if = "Option::is_none")]
610    pub get_updates_buf: Option<String>,
611    /// Server-suggested next poll timeout (ms).
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub longpolling_timeout_ms: Option<u64>,
614}
615
616/// `sendMessage` request body.
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct SendMessageRequest {
619    /// The message to send.
620    pub msg: WeixinMessage,
621    /// Metadata.
622    pub base_info: BaseInfo,
623}
624
625/// `sendMessage` response body (internal).
626#[derive(Debug, Clone, Default, Serialize, Deserialize)]
627pub(crate) struct SendMessageResponse {
628    /// Return code (0 or absent = success).
629    #[serde(skip_serializing_if = "Option::is_none")]
630    pub ret: Option<i32>,
631    /// Error message.
632    #[serde(skip_serializing_if = "Option::is_none")]
633    pub errmsg: Option<String>,
634}
635
636/// `getUploadUrl` request body.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct GetUploadUrlRequest {
639    /// Random file key (32 hex chars).
640    pub filekey: String,
641    /// Upload media type.
642    pub media_type: UploadMediaType,
643    /// Recipient user ID.
644    pub to_user_id: String,
645    /// Plaintext file size.
646    pub rawsize: u64,
647    /// Plaintext file MD5 hex.
648    pub rawfilemd5: String,
649    /// Ciphertext file size.
650    pub filesize: u64,
651    /// Whether thumbnail is not needed.
652    #[serde(skip_serializing_if = "Option::is_none")]
653    pub no_need_thumb: Option<bool>,
654    /// Thumbnail plaintext size.
655    #[serde(skip_serializing_if = "Option::is_none")]
656    pub thumb_rawsize: Option<u64>,
657    /// Thumbnail plaintext MD5 hex.
658    #[serde(skip_serializing_if = "Option::is_none")]
659    pub thumb_rawfilemd5: Option<String>,
660    /// Thumbnail ciphertext size.
661    #[serde(skip_serializing_if = "Option::is_none")]
662    pub thumb_filesize: Option<u64>,
663    /// AES key hex string.
664    pub aeskey: String,
665    /// Metadata.
666    pub base_info: BaseInfo,
667}
668
669/// `getUploadUrl` response body.
670#[derive(Debug, Clone, Default, Serialize, Deserialize)]
671pub struct GetUploadUrlResponse {
672    /// Upload encrypted parameter.
673    #[serde(skip_serializing_if = "Option::is_none")]
674    pub upload_param: Option<String>,
675    /// Thumbnail upload parameter.
676    #[serde(skip_serializing_if = "Option::is_none")]
677    pub thumb_upload_param: Option<String>,
678    /// Full upload URL from server.
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub upload_full_url: Option<String>,
681}
682
683/// `getConfig` request body (internal).
684#[derive(Debug, Clone, Serialize)]
685pub(crate) struct GetConfigRequest {
686    /// User ID to get config for.
687    pub ilink_user_id: String,
688    /// Optional context token.
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub context_token: Option<String>,
691    /// Metadata.
692    pub base_info: BaseInfo,
693}
694
695/// `getConfig` response body.
696#[derive(Debug, Clone, Default, Serialize, Deserialize)]
697pub struct GetConfigResponse {
698    /// Return code.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub ret: Option<i32>,
701    /// Error message.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub errmsg: Option<String>,
704    /// Typing ticket (base64).
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub typing_ticket: Option<String>,
707}
708
709/// `sendTyping` request body.
710#[derive(Debug, Clone, Serialize, Deserialize)]
711pub struct SendTypingRequest {
712    /// Target user ID.
713    pub ilink_user_id: String,
714    /// Typing ticket from `getConfig`.
715    #[serde(skip_serializing_if = "Option::is_none")]
716    pub typing_ticket: Option<String>,
717    /// Typing status.
718    pub status: TypingStatus,
719    /// Metadata.
720    pub base_info: BaseInfo,
721}
722
723// ── QR login types ──────────────────────────────────────────────────
724
725/// QR code response from server.
726#[derive(Debug, Clone, Serialize, Deserialize)]
727pub struct QrCodeResponse {
728    /// QR code token string.
729    pub qrcode: String,
730    /// QR code image URL.
731    pub qrcode_img_content: String,
732}
733
734/// QR status response from server.
735#[derive(Debug, Clone, Default, Serialize, Deserialize)]
736pub struct QrStatusResponse {
737    /// Current status.
738    pub status: String,
739    /// Bot token (on confirmed).
740    #[serde(skip_serializing_if = "Option::is_none")]
741    pub bot_token: Option<String>,
742    /// Bot ID (on confirmed).
743    #[serde(skip_serializing_if = "Option::is_none")]
744    pub ilink_bot_id: Option<String>,
745    /// Base URL (on confirmed).
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub baseurl: Option<String>,
748    /// User ID who scanned.
749    #[serde(skip_serializing_if = "Option::is_none")]
750    pub ilink_user_id: Option<String>,
751    /// Redirect host for IDC redirect.
752    #[serde(skip_serializing_if = "Option::is_none")]
753    pub redirect_host: Option<String>,
754}
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759
760    #[test]
761    fn item_type_round_trips_known_values() {
762        for code in [0, 1, 2, 3, 4, 5, 11, 12] {
763            assert_eq!(MessageItemType::from_code(code).code(), code);
764        }
765    }
766
767    #[test]
768    fn item_type_preserves_unknown_value() {
769        let t = MessageItemType::from_code(99);
770        assert_eq!(t, MessageItemType::Unknown(99));
771        assert_eq!(t.code(), 99);
772    }
773
774    #[test]
775    fn item_type_serializes_as_wire_int() {
776        assert_eq!(
777            serde_json::to_string(&MessageItemType::ToolCallStart).unwrap(),
778            "11"
779        );
780        assert_eq!(
781            serde_json::to_string(&MessageItemType::ToolCallResult).unwrap(),
782            "12"
783        );
784        assert_eq!(
785            serde_json::to_string(&MessageItemType::Unknown(77)).unwrap(),
786            "77"
787        );
788    }
789
790    #[test]
791    fn unknown_item_type_does_not_break_batch() {
792        // Regression: one unrecognized item must not invalidate the whole getUpdates batch.
793        let json = r#"{"ret":0,"msgs":[
794            {"message_type":1,"from_user_id":"u1","item_list":[{"type":1,"text_item":{"text":"hi"}}]},
795            {"message_type":2,"item_list":[{"type":11,"tool_call_start_item":{"tool_name":"bash"}}]},
796            {"message_type":2,"item_list":[{"type":99}]}
797        ]}"#;
798        let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
799        let msgs = resp.msgs.unwrap();
800        assert_eq!(msgs.len(), 3);
801        assert_eq!(
802            msgs[1].item_list.as_ref().unwrap()[0].item_type,
803            Some(MessageItemType::ToolCallStart)
804        );
805        assert_eq!(
806            msgs[2].item_list.as_ref().unwrap()[0].item_type,
807            Some(MessageItemType::Unknown(99))
808        );
809    }
810
811    #[test]
812    fn unknown_message_state_does_not_break_parse() {
813        let json = r#"{"ret":0,"msgs":[{"message_type":1,"message_state":3}]}"#;
814        let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
815        assert_eq!(
816            resp.msgs.unwrap()[0].message_state,
817            Some(MessageState::Unknown(3))
818        );
819    }
820
821    #[test]
822    fn unknown_message_type_does_not_break_parse() {
823        let json = r#"{"ret":0,"msgs":[{"message_type":7}]}"#;
824        let resp: GetUpdatesResponse = serde_json::from_str(json).unwrap();
825        assert_eq!(
826            resp.msgs.unwrap()[0].message_type,
827            Some(MessageType::Unknown(7))
828        );
829    }
830
831    #[test]
832    fn run_id_deserializes_and_serializes() {
833        let msg: WeixinMessage = serde_json::from_str(r#"{"run_id":"abc123"}"#).unwrap();
834        assert_eq!(msg.run_id.as_deref(), Some("abc123"));
835        let json = serde_json::to_string(&msg).unwrap();
836        assert!(json.contains(r#""run_id":"abc123""#));
837        // Absent run_id must not be serialized.
838        let empty = WeixinMessage::default();
839        assert!(!serde_json::to_string(&empty).unwrap().contains("run_id"));
840    }
841
842    #[test]
843    fn tool_call_status_wire_strings() {
844        assert_eq!(ToolCallStatus::Completed.as_str(), "completed");
845        assert_eq!(ToolCallStatus::Failed.as_str(), "failed");
846        assert_eq!(ToolCallStatus::Blocked.as_str(), "blocked");
847        assert_eq!(ToolCallStatus::Unknown.as_str(), "unknown");
848    }
849
850    #[test]
851    fn channel_version_matches_reference() {
852        assert_eq!(CHANNEL_VERSION, "2.4.6");
853        assert_eq!(STALE_TOKEN_ERRCODE, -14);
854    }
855
856    #[test]
857    fn send_message_response_parses_error_and_empty_object() {
858        let err: SendMessageResponse =
859            serde_json::from_str(r#"{"ret":-14,"errmsg":"stale"}"#).unwrap();
860        assert_eq!(err.ret, Some(-14));
861        assert_eq!(err.errmsg.as_deref(), Some("stale"));
862        let ok: SendMessageResponse = serde_json::from_str("{}").unwrap();
863        assert!(ok.ret.is_none());
864    }
865
866    #[test]
867    fn item_type_round_trips_i32_bounds_through_serde() {
868        // Must go through serde, not just from_code/code — the risk is at the wire layer.
869        for code in [i32::MIN, -1, i32::MAX] {
870            let t = MessageItemType::from_code(code);
871            let json = serde_json::to_string(&t).unwrap();
872            assert_eq!(json, code.to_string());
873            assert_eq!(serde_json::from_str::<MessageItemType>(&json).unwrap(), t);
874        }
875    }
876
877    #[test]
878    fn item_type_rejects_out_of_i32_range() {
879        // Values beyond i32 must fail rather than being silently truncated.
880        assert!(serde_json::from_str::<MessageItemType>("2147483648").is_err());
881    }
882}