use serde::{Deserialize, Serialize};
pub mod op {
pub const DISPATCH: u32 = 0;
pub const HEARTBEAT: u32 = 1;
pub const IDENTIFY: u32 = 2;
pub const RESUME: u32 = 6;
pub const RECONNECT: u32 = 7;
pub const INVALID_SESSION: u32 = 9;
pub const HELLO: u32 = 10;
pub const HEARTBEAT_ACK: u32 = 11;
}
pub mod event_type {
pub const READY: &str = "READY";
pub const RESUMED: &str = "RESUMED";
pub const C2C_MESSAGE_CREATE: &str = "C2C_MESSAGE_CREATE";
pub const GROUP_AT_MESSAGE_CREATE: &str = "GROUP_AT_MESSAGE_CREATE";
}
pub const INTENT_C2C: u32 = 1 << 12;
pub const INTENT_GROUP_AT_MESSAGE: u32 = 1 << 25;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayPayload {
pub op: u32,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub d: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub s: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub t: Option<String>,
}
impl GatewayPayload {
pub fn identify(access_token: &str, intents: u32) -> Self {
Self {
op: op::IDENTIFY,
d: Some(serde_json::json!({
"token": format!("QQBot {}", access_token),
"intents": intents,
"shard": [0, 1],
})),
s: None,
t: None,
}
}
pub fn heartbeat(last_seq: Option<u64>) -> Self {
Self {
op: op::HEARTBEAT,
d: Some(serde_json::Value::from(last_seq)),
s: None,
t: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HelloData {
pub heartbeat_interval: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct QqAttachment {
#[serde(default)]
pub url: String,
#[serde(default, rename = "content_type")]
pub content_type: Option<String>,
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MessageEvent {
pub id: String,
#[serde(default)]
pub content: String,
pub author: Author,
#[serde(default, rename = "group_openid")]
pub group_openid: Option<String>,
#[serde(default)]
pub attachments: Vec<QqAttachment>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Author {
#[serde(default, rename = "user_openid")]
pub user_openid: Option<String>,
#[serde(default, rename = "member_openid")]
pub member_openid: Option<String>,
}
impl MessageEvent {
pub fn user_id(&self) -> Option<&str> {
self.author
.user_openid
.as_deref()
.or(self.author.member_openid.as_deref())
}
}
pub mod msg_type {
pub const TEXT: u32 = 0;
pub const MARKDOWN: u32 = 2;
pub const MEDIA: u32 = 7;
}
pub mod file_type {
pub const IMAGE: u32 = 1;
pub const VIDEO: u32 = 2;
pub const VOICE: u32 = 3;
pub const FILE: u32 = 4;
}
#[derive(Debug, Clone, Serialize)]
pub struct MediaFileInfo {
pub file_info: String, }
#[derive(Debug, Clone, Serialize)]
pub struct SendMessageRequest {
pub content: String,
pub msg_type: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub msg_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub msg_seq: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<MediaFileInfo>,
}
#[derive(Debug, Clone, Serialize)]
pub struct UploadMediaRequest {
pub file_type: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_data: Option<String>,
pub srv_send_msg: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct UploadMediaResponse {
#[serde(default, rename = "file_info")]
pub file_info: Option<String>,
#[serde(default, rename = "file_uuid")]
pub file_uuid: Option<String>,
#[serde(default)]
pub ttl: Option<u64>,
#[serde(default)]
pub id: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SendMessageResponse {
pub id: Option<String>,
#[serde(default)]
pub msg_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AccessTokenRequest {
#[serde(rename = "appId")]
pub app_id: String,
#[serde(rename = "clientSecret")]
pub client_secret: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AccessTokenResponse {
pub access_token: String,
#[serde(default, deserialize_with = "deserialize_string_or_number")]
pub expires_in: u64,
#[serde(default)]
pub code: Option<i32>,
#[serde(default)]
pub message: Option<String>,
}
fn deserialize_string_or_number<'de, D>(deserializer: D) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrNumber {
String(String),
Number(u64),
}
match StringOrNumber::deserialize(deserializer)? {
StringOrNumber::String(s) => s.parse().map_err(serde::de::Error::custom),
StringOrNumber::Number(n) => Ok(n),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hello_payload() {
let json = r#"{"op":10,"d":{"heartbeat_interval":41250}}"#;
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
assert_eq!(payload.op, op::HELLO);
let hello: HelloData = serde_json::from_value(payload.d.unwrap()).unwrap();
assert_eq!(hello.heartbeat_interval, 41250);
}
#[test]
fn builds_identify_payload() {
let p = GatewayPayload::identify("tok-123", INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
assert_eq!(p.op, op::IDENTIFY);
let d = p.d.unwrap();
assert_eq!(d["token"], "QQBot tok-123");
assert_eq!(d["intents"], INTENT_C2C | INTENT_GROUP_AT_MESSAGE);
}
#[test]
fn builds_heartbeat_payload() {
let p = GatewayPayload::heartbeat(Some(42));
assert_eq!(p.op, op::HEARTBEAT);
assert_eq!(p.d.unwrap(), 42);
}
#[test]
fn parses_group_at_message_event() {
let json = r#"{
"id": "msg-1",
"content": " hello",
"author": {"member_openid": "mem-1"},
"group_openid": "grp-1"
}"#;
let ev: MessageEvent = serde_json::from_str(json).unwrap();
assert_eq!(ev.id, "msg-1");
assert_eq!(ev.group_openid.as_deref(), Some("grp-1"));
assert_eq!(ev.user_id(), Some("mem-1"));
assert!(ev.attachments.is_empty());
}
#[test]
fn parses_c2c_message_event() {
let json = r#"{
"id": "msg-2",
"content": "hi",
"author": {"user_openid": "user-1"}
}"#;
let ev: MessageEvent = serde_json::from_str(json).unwrap();
assert_eq!(ev.user_id(), Some("user-1"));
assert!(ev.group_openid.is_none());
}
#[test]
fn parses_message_with_attachments() {
let json = r#"{
"id": "msg-3",
"content": "look at this",
"author": {"user_openid": "user-2"},
"attachments": [
{
"url": "https://cdn.qq.com/img/abc123.png",
"content_type": "image/png",
"filename": "screenshot.png",
"size": 102400,
"width": 1920,
"height": 1080
}
]
}"#;
let ev: MessageEvent = serde_json::from_str(json).unwrap();
assert_eq!(ev.attachments.len(), 1);
let att = &ev.attachments[0];
assert_eq!(att.url, "https://cdn.qq.com/img/abc123.png");
assert_eq!(att.content_type.as_deref(), Some("image/png"));
assert_eq!(att.filename.as_deref(), Some("screenshot.png"));
assert_eq!(att.size, Some(102400));
assert_eq!(att.width, Some(1920));
assert_eq!(att.height, Some(1080));
}
#[test]
fn serializes_send_message_request() {
let req = SendMessageRequest {
content: "hello".into(),
msg_type: msg_type::TEXT,
msg_id: Some("msg-1".into()),
msg_seq: Some(1),
media: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"content\":\"hello\""));
assert!(json.contains("\"msg_id\":\"msg-1\""));
assert!(json.contains("\"msg_seq\":1"));
}
#[test]
fn send_message_request_omits_none_fields() {
let req = SendMessageRequest {
content: "hi".into(),
msg_type: msg_type::TEXT,
msg_id: None,
msg_seq: None,
media: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("msg_id"));
assert!(!json.contains("msg_seq"));
assert!(!json.contains("media"));
}
#[test]
fn serializes_media_message_request() {
let req = SendMessageRequest {
content: "image".into(),
msg_type: msg_type::MEDIA,
msg_id: Some("msg-4".into()),
msg_seq: Some(2),
media: Some(MediaFileInfo {
file_info: "/abc123.png".to_string(),
}),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"msg_type\":7"));
assert!(json.contains("\"file_info\":\"/abc123.png\""));
}
}