1use serde::{Deserialize, Serialize};
11
12pub mod op {
18 pub const DISPATCH: u32 = 0;
20 pub const HEARTBEAT: u32 = 1;
22 pub const IDENTIFY: u32 = 2;
24 pub const RESUME: u32 = 6;
26 pub const RECONNECT: u32 = 7;
28 pub const INVALID_SESSION: u32 = 9;
30 pub const HELLO: u32 = 10;
32 pub const HEARTBEAT_ACK: u32 = 11;
34}
35
36pub mod event_type {
38 pub const READY: &str = "READY";
40 pub const RESUMED: &str = "RESUMED";
42 pub const C2C_MESSAGE_CREATE: &str = "C2C_MESSAGE_CREATE";
44 pub const GROUP_AT_MESSAGE_CREATE: &str = "GROUP_AT_MESSAGE_CREATE";
46}
47
48pub const INTENT_C2C: u32 = 1 << 12;
54pub const INTENT_GROUP_AT_MESSAGE: u32 = 1 << 25;
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct GatewayPayload {
64 pub op: u32,
66 #[serde(skip_serializing_if = "Option::is_none", default)]
68 pub d: Option<serde_json::Value>,
69 #[serde(skip_serializing_if = "Option::is_none", default)]
71 pub s: Option<u64>,
72 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub t: Option<String>,
75}
76
77impl GatewayPayload {
78 pub fn identify(access_token: &str, intents: u32) -> Self {
80 Self {
81 op: op::IDENTIFY,
82 d: Some(serde_json::json!({
83 "token": format!("QQBot {}", access_token),
84 "intents": intents,
85 "shard": [0, 1],
86 })),
87 s: None,
88 t: None,
89 }
90 }
91
92 pub fn heartbeat(last_seq: Option<u64>) -> Self {
94 Self {
95 op: op::HEARTBEAT,
96 d: Some(serde_json::Value::from(last_seq)),
97 s: None,
98 t: None,
99 }
100 }
101}
102
103#[derive(Debug, Clone, Deserialize)]
105pub struct HelloData {
106 pub heartbeat_interval: u64,
107}
108
109#[derive(Debug, Clone, Deserialize)]
115pub struct QqAttachment {
116 #[serde(default)]
118 pub url: String,
119 #[serde(default, rename = "content_type")]
121 pub content_type: Option<String>,
122 #[serde(default)]
124 pub filename: Option<String>,
125 #[serde(default)]
127 pub size: Option<u64>,
128 #[serde(default)]
130 pub width: Option<u32>,
131 #[serde(default)]
133 pub height: Option<u32>,
134}
135
136#[derive(Debug, Clone, Deserialize)]
139pub struct MessageEvent {
140 pub id: String,
142 #[serde(default)]
144 pub content: String,
145 pub author: Author,
147 #[serde(default, rename = "group_openid")]
149 pub group_openid: Option<String>,
150 #[serde(default)]
152 pub attachments: Vec<QqAttachment>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156pub struct Author {
157 #[serde(default, rename = "user_openid")]
159 pub user_openid: Option<String>,
160 #[serde(default, rename = "member_openid")]
161 pub member_openid: Option<String>,
162}
163
164impl MessageEvent {
165 pub fn user_id(&self) -> Option<&str> {
167 self.author
168 .user_openid
169 .as_deref()
170 .or(self.author.member_openid.as_deref())
171 }
172}
173
174pub mod msg_type {
180 pub const TEXT: u32 = 0;
182 pub const MARKDOWN: u32 = 2;
184 pub const MEDIA: u32 = 7;
186}
187
188pub mod file_type {
190 pub const IMAGE: u32 = 1;
192 pub const VIDEO: u32 = 2;
194 pub const VOICE: u32 = 3;
196 pub const FILE: u32 = 4;
198}
199
200#[derive(Debug, Clone, Serialize)]
202pub struct MediaFileInfo {
203 pub file_info: String, }
205
206#[derive(Debug, Clone, Serialize)]
208pub struct SendMessageRequest {
209 pub content: String,
210 pub msg_type: u32,
211 #[serde(skip_serializing_if = "Option::is_none")]
214 pub msg_id: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub msg_seq: Option<u32>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub media: Option<MediaFileInfo>,
221}
222
223#[derive(Debug, Clone, Serialize)]
227pub struct UploadMediaRequest {
228 pub file_type: u32,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub url: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub file_data: Option<String>,
236 pub srv_send_msg: bool,
239}
240
241#[derive(Debug, Clone, Deserialize)]
243pub struct UploadMediaResponse {
244 #[serde(default, rename = "file_info")]
246 pub file_info: Option<String>,
247 #[serde(default, rename = "file_uuid")]
249 pub file_uuid: Option<String>,
250 #[serde(default)]
252 pub ttl: Option<u64>,
253 #[serde(default)]
255 pub id: Option<String>,
256}
257
258#[derive(Debug, Clone, Deserialize)]
260pub struct SendMessageResponse {
261 pub id: Option<String>,
262 #[serde(default)]
263 pub msg_id: Option<String>,
264}
265
266#[derive(Debug, Serialize)]
272pub struct AccessTokenRequest {
273 #[serde(rename = "appId")]
274 pub app_id: String,
275 #[serde(rename = "clientSecret")]
276 pub client_secret: String,
277}
278
279#[derive(Debug, Clone, Deserialize)]
281pub struct AccessTokenResponse {
282 pub access_token: String,
283 #[serde(default, deserialize_with = "deserialize_string_or_number")]
285 pub expires_in: u64,
286 #[serde(default)]
287 pub code: Option<i32>,
288 #[serde(default)]
289 pub message: Option<String>,
290}
291
292fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
293where
294 D: serde::Deserializer<'de>,
295{
296 #[derive(Deserialize)]
297 #[serde(untagged)]
298 enum StringOrNumber {
299 String(String),
300 Number(u64),
301 }
302
303 match StringOrNumber::deserialize(deserializer)? {
304 StringOrNumber::String(s) => s.parse().map_err(serde::de::Error::custom),
305 StringOrNumber::Number(n) => Ok(n),
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn parses_hello_payload() {
315 let json = r#"{"op":10,"d":{"heartbeat_interval":41250}}"#;
316 let payload: GatewayPayload = serde_json::from_str(json).unwrap();
317 assert_eq!(payload.op, op::HELLO);
318 let hello: HelloData = serde_json::from_value(payload.d.unwrap()).unwrap();
319 assert_eq!(hello.heartbeat_interval, 41250);
320 }
321
322 #[test]
323 fn builds_identify_payload() {
324 let p = GatewayPayload::identify("tok-123", INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
325 assert_eq!(p.op, op::IDENTIFY);
326 let d = p.d.unwrap();
327 assert_eq!(d["token"], "QQBot tok-123");
328 assert_eq!(d["intents"], INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
329 }
330
331 #[test]
332 fn builds_heartbeat_payload() {
333 let p = GatewayPayload::heartbeat(Some(42));
334 assert_eq!(p.op, op::HEARTBEAT);
335 assert_eq!(p.d.unwrap(), 42);
336 }
337
338 #[test]
339 fn parses_group_at_message_event() {
340 let json = r#"{
341 "id": "msg-1",
342 "content": " hello",
343 "author": {"member_openid": "mem-1"},
344 "group_openid": "grp-1"
345 }"#;
346 let ev: MessageEvent = serde_json::from_str(json).unwrap();
347 assert_eq!(ev.id, "msg-1");
348 assert_eq!(ev.group_openid.as_deref(), Some("grp-1"));
349 assert_eq!(ev.user_id(), Some("mem-1"));
350 assert!(ev.attachments.is_empty());
351 }
352
353 #[test]
354 fn parses_c2c_message_event() {
355 let json = r#"{
356 "id": "msg-2",
357 "content": "hi",
358 "author": {"user_openid": "user-1"}
359 }"#;
360 let ev: MessageEvent = serde_json::from_str(json).unwrap();
361 assert_eq!(ev.user_id(), Some("user-1"));
362 assert!(ev.group_openid.is_none());
363 }
364
365 #[test]
366 fn parses_message_with_attachments() {
367 let json = r#"{
368 "id": "msg-3",
369 "content": "look at this",
370 "author": {"user_openid": "user-2"},
371 "attachments": [
372 {
373 "url": "https://cdn.qq.com/img/abc123.png",
374 "content_type": "image/png",
375 "filename": "screenshot.png",
376 "size": 102400,
377 "width": 1920,
378 "height": 1080
379 }
380 ]
381 }"#;
382 let ev: MessageEvent = serde_json::from_str(json).unwrap();
383 assert_eq!(ev.attachments.len(), 1);
384 let att = &ev.attachments[0];
385 assert_eq!(att.url, "https://cdn.qq.com/img/abc123.png");
386 assert_eq!(att.content_type.as_deref(), Some("image/png"));
387 assert_eq!(att.filename.as_deref(), Some("screenshot.png"));
388 assert_eq!(att.size, Some(102400));
389 assert_eq!(att.width, Some(1920));
390 assert_eq!(att.height, Some(1080));
391 }
392
393 #[test]
394 fn serializes_send_message_request() {
395 let req = SendMessageRequest {
396 content: "hello".into(),
397 msg_type: msg_type::TEXT,
398 msg_id: Some("msg-1".into()),
399 msg_seq: Some(1),
400 media: None,
401 };
402 let json = serde_json::to_string(&req).unwrap();
403 assert!(json.contains("\"content\":\"hello\""));
404 assert!(json.contains("\"msg_id\":\"msg-1\""));
405 assert!(json.contains("\"msg_seq\":1"));
406 }
407
408 #[test]
409 fn send_message_request_omits_none_fields() {
410 let req = SendMessageRequest {
411 content: "hi".into(),
412 msg_type: msg_type::TEXT,
413 msg_id: None,
414 msg_seq: None,
415 media: None,
416 };
417 let json = serde_json::to_string(&req).unwrap();
418 assert!(!json.contains("msg_id"));
419 assert!(!json.contains("msg_seq"));
420 assert!(!json.contains("media"));
421 }
422
423 #[test]
424 fn serializes_media_message_request() {
425 let req = SendMessageRequest {
426 content: "image".into(),
427 msg_type: msg_type::MEDIA,
428 msg_id: Some("msg-4".into()),
429 msg_seq: Some(2),
430 media: Some(MediaFileInfo {
431 file_info: "/abc123.png".to_string(),
432 }),
433 };
434 let json = serde_json::to_string(&req).unwrap();
435 assert!(json.contains("\"msg_type\":7"));
436 assert!(json.contains("\"file_info\":\"/abc123.png\""));
437 }
438}