Skip to main content

robit_qq/
protocol.rs

1//! QQ Official Bot WebSocket gateway protocol types (opcodes, payloads).
2//!
3//! The QQ Bot gateway is Discord-like: a WebSocket connection exchanging JSON
4//! [`GatewayPayload`] frames keyed by `op` (opcode). The client identifies,
5//! heartbeats, and receives `op=0` dispatch events (`C2C_MESSAGE_CREATE`,
6//! `GROUP_AT_MESSAGE_CREATE`, etc.). Message sending is done over HTTP.
7//!
8//! Reference: QQ Official Bot API (https://bot.q.qq.com).
9
10use serde::{Deserialize, Serialize};
11
12// ===========================================================================
13// Opcodes
14// ===========================================================================
15
16/// Gateway opcode values.
17pub mod op {
18    /// Server pushes an event.
19    pub const DISPATCH: u32 = 0;
20    /// Client sends / server replies to heartbeat.
21    pub const HEARTBEAT: u32 = 1;
22    /// Client authenticates the connection.
23    pub const IDENTIFY: u32 = 2;
24    /// Client resumes a broken session.
25    pub const RESUME: u32 = 6;
26    /// Server asks the client to reconnect.
27    pub const RECONNECT: u32 = 7;
28    /// Server indicates the session is invalid.
29    pub const INVALID_SESSION: u32 = 9;
30    /// Server sends the heartbeat interval (Hello).
31    pub const HELLO: u32 = 10;
32    /// Server acknowledges a heartbeat.
33    pub const HEARTBEAT_ACK: u32 = 11;
34}
35
36/// Dispatch event type strings (the `t` field of an `op=0` payload).
37pub mod event_type {
38    /// Ready event — sent after a successful Identify.
39    pub const READY: &str = "READY";
40    /// Resumed event — sent after a successful Resume.
41    pub const RESUMED: &str = "RESUMED";
42    /// C2C (private) chat message.
43    pub const C2C_MESSAGE_CREATE: &str = "C2C_MESSAGE_CREATE";
44    /// Group @-mention message.
45    pub const GROUP_AT_MESSAGE_CREATE: &str = "GROUP_AT_MESSAGE_CREATE";
46}
47
48// ===========================================================================
49// Intent bitflags
50// ===========================================================================
51
52/// Intent for C2C (private) messages.
53pub const INTENT_C2C: u32 = 1 << 12;
54/// Intent for group @-mention messages.
55pub const INTENT_GROUP_AT_MESSAGE: u32 = 1 << 25;
56
57// ===========================================================================
58// Gateway payload
59// ===========================================================================
60
61/// Top-level gateway payload, exchanged over the WebSocket connection.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct GatewayPayload {
64    /// Opcode (see [`op`]).
65    pub op: u32,
66    /// Data payload.
67    #[serde(skip_serializing_if = "Option::is_none", default)]
68    pub d: Option<serde_json::Value>,
69    /// Sequence number (for dispatch / resume).
70    #[serde(skip_serializing_if = "Option::is_none", default)]
71    pub s: Option<u64>,
72    /// Event type (for `op=0` Dispatch).
73    #[serde(skip_serializing_if = "Option::is_none", default)]
74    pub t: Option<String>,
75}
76
77impl GatewayPayload {
78    /// Build an Identify (`op=2`) payload.
79    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    /// Build a Heartbeat (`op=1`) payload carrying the last sequence number.
93    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/// `d` payload of the Hello (`op=10`) event.
104#[derive(Debug, Clone, Deserialize)]
105pub struct HelloData {
106    pub heartbeat_interval: u64,
107}
108
109// ===========================================================================
110// Message events
111// ===========================================================================
112
113/// An attachment in a QQ message event (image, file, etc.).
114#[derive(Debug, Clone, Deserialize)]
115pub struct QqAttachment {
116    /// Download URL for the attachment.
117    #[serde(default)]
118    pub url: String,
119    /// MIME content type (e.g. "image/jpeg", "image/png").
120    #[serde(default, rename = "content_type")]
121    pub content_type: Option<String>,
122    /// Original filename.
123    #[serde(default)]
124    pub filename: Option<String>,
125    /// File size in bytes.
126    #[serde(default)]
127    pub size: Option<u64>,
128    /// Image width in pixels.
129    #[serde(default)]
130    pub width: Option<u32>,
131    /// Image height in pixels.
132    #[serde(default)]
133    pub height: Option<u32>,
134}
135
136/// Common fields of an incoming message event (`C2C_MESSAGE_CREATE`,
137/// `GROUP_AT_MESSAGE_CREATE`).
138#[derive(Debug, Clone, Deserialize)]
139pub struct MessageEvent {
140    /// Message ID (needed to reply via HTTP).
141    pub id: String,
142    /// Message content (text). For group @-messages this excludes the @mention.
143    #[serde(default)]
144    pub content: String,
145    /// Author / sender.
146    pub author: Author,
147    /// For group messages: the group's openid.
148    #[serde(default, rename = "group_openid")]
149    pub group_openid: Option<String>,
150    /// Media attachments (images, files) included in the message.
151    #[serde(default)]
152    pub attachments: Vec<QqAttachment>,
153}
154
155#[derive(Debug, Clone, Deserialize)]
156pub struct Author {
157    /// Sender's openid.
158    #[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    /// The sender's user identifier (openid).
166    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
174// ===========================================================================
175// HTTP send-message request
176// ===========================================================================
177
178/// Message type for the send-message HTTP API.
179pub mod msg_type {
180    /// Plain text.
181    pub const TEXT: u32 = 0;
182    /// Markdown (template / raw).
183    pub const MARKDOWN: u32 = 2;
184    /// Rich media message (image / file).
185    pub const MEDIA: u32 = 7;
186}
187
188/// File type constants for the upload API.
189pub mod file_type {
190    /// Image file.
191    pub const IMAGE: u32 = 1;
192    /// Video file.
193    pub const VIDEO: u32 = 2;
194    /// Voice/audio file.
195    pub const VOICE: u32 = 3;
196    /// General file (document, archive, etc.).
197    pub const FILE: u32 = 4;
198}
199
200/// Media file info for sending media messages (msg_type=7).
201#[derive(Debug, Clone, Serialize)]
202pub struct MediaFileInfo {
203    pub file_info: String, // The file info string returned by the upload API
204}
205
206/// Request body for POSTing a message to a group or user.
207#[derive(Debug, Clone, Serialize)]
208pub struct SendMessageRequest {
209    pub content: String,
210    pub msg_type: u32,
211    /// The incoming message ID this reply references (QQ requires this for
212    /// passive replies within 5 minutes of the original message).
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub msg_id: Option<String>,
215    /// Monotonic sequence to dedupe replies within a msg_id.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub msg_seq: Option<u32>,
218    /// Media file info (required for msg_type=7, the URL returned by upload API).
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub media: Option<MediaFileInfo>,
221}
222
223/// Request body for uploading a file to QQ.
224/// Uses JSON body (not multipart). Either `url` (public URL for QQ to download)
225/// or `file_data` (base64-encoded binary) must be provided.
226#[derive(Debug, Clone, Serialize)]
227pub struct UploadMediaRequest {
228    /// File type: 1 = image, 2 = video, 3 = voice, 4 = file
229    pub file_type: u32,
230    /// Public URL for QQ servers to download from (alternative to file_data).
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub url: Option<String>,
233    /// Base64-encoded file binary data (alternative to url).
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub file_data: Option<String>,
236    /// true = send directly (counts against rate limit),
237    /// false = return file_info for later use (recommended).
238    pub srv_send_msg: bool,
239}
240
241/// Response body for the upload media API.
242#[derive(Debug, Clone, Deserialize)]
243pub struct UploadMediaResponse {
244    /// The file_info string to use when sending a msg_type=7 message.
245    #[serde(default, rename = "file_info")]
246    pub file_info: Option<String>,
247    /// UUID of the uploaded file.
248    #[serde(default, rename = "file_uuid")]
249    pub file_uuid: Option<String>,
250    /// TTL in seconds for the uploaded file.
251    #[serde(default)]
252    pub ttl: Option<u64>,
253    /// File ID.
254    #[serde(default)]
255    pub id: Option<String>,
256}
257
258/// Response body for the send-message HTTP API.
259#[derive(Debug, Clone, Deserialize)]
260pub struct SendMessageResponse {
261    pub id: Option<String>,
262    #[serde(default)]
263    pub msg_id: Option<String>,
264}
265
266// ===========================================================================
267// Access token (OAuth2)
268// ===========================================================================
269
270/// Request body for the getAppAccessToken endpoint.
271#[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/// Response body for the getAppAccessToken endpoint.
280#[derive(Debug, Clone, Deserialize)]
281pub struct AccessTokenResponse {
282    pub access_token: String,
283    /// Seconds until expiry.
284    #[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}