diff --git a/src/websocket/command/parse/dispatch.rs b/src/websocket/command/parse/dispatch.rs index 0229dba..6bd0e2f 100644 --- a/src/websocket/command/parse/dispatch.rs +++ b/src/websocket/command/parse/dispatch.rs @@ -4,6 +4,7 @@ use super::super::*; use super::{ danmu::parse_danmu, gift::{parse_combo_send, parse_gift}, + gift_v2::parse_gift_v2, like::{parse_like_click, parse_like_notice, parse_like_update}, status::{ parse_gift_star_process, parse_online_rank_count, parse_protobuf_payload, @@ -52,6 +53,14 @@ pub fn parse_command(value: Value) -> LiveCommand { error, }, }, + Some("SEND_GIFT_V2") | Some("GIFT_V2") => match parse_gift_v2(&value) { + Ok(message) => LiveCommand::Gift(Box::new(message)), + Err(error) => LiveCommand::Invalid { + command, + raw: value, + error, + }, + }, Some("COMBO_SEND") => match parse_combo_send(&value) { Ok(message) => LiveCommand::ComboSend(Box::new(message)), Err(error) => LiveCommand::Invalid { diff --git a/src/websocket/command/parse/gift_v2.rs b/src/websocket/command/parse/gift_v2.rs new file mode 100644 index 0000000..2529725 --- /dev/null +++ b/src/websocket/command/parse/gift_v2.rs @@ -0,0 +1,303 @@ +//! `SEND_GIFT_V2` protobuf 载荷的最小兼容解码器。 +//! +//! 字段编号来自上游协议文档的实测 schema。实现只读取可映射到既有礼物模型的稳定字段, +//! 并跳过未知字段,以兼容服务端后续增加的 protobuf 数据。 + +use super::super::*; +use super::common::*; +use super::gift::parse_gift_danmu; +use super::status::command_data; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde_json::Value; +use std::collections::HashMap; + +pub(super) fn parse_gift_v2(value: &Value) -> std::result::Result { + let outer_data = command_data(value, "SEND_GIFT_V2")?; + let nested_data = outer_data.get("data").and_then(Value::as_object); + let payload = nested_data.unwrap_or(outer_data); + let pb_base64 = payload + .get("pb") + .and_then(value_string) + .ok_or_else(|| "SEND_GIFT_V2 缺少 Base64 protobuf 载荷 data.pb".to_owned())?; + let protobuf = STANDARD + .decode(&pb_base64) + .map_err(|error| format!("SEND_GIFT_V2.data.pb 不是有效 Base64:{error}"))?; + let message = decode_send_gift_v2(&protobuf)?; + let gift = message + .gift + .ok_or_else(|| "SEND_GIFT_V2 protobuf 缺少 gift 字段".to_owned())?; + + let mut extra = object_extra(&Value::Object(payload.clone()), &["pb", "dmscore"]); + extra.insert("pb".to_owned(), Value::String(pb_base64)); + if let Some(dm_score) = payload + .get("dmscore") + .or_else(|| outer_data.get("dmscore")) + .and_then(value_u64) + { + extra.insert("dmscore".to_owned(), Value::from(dm_score)); + } + if let Some(outer_extra) = + nested_data.map(|_| object_extra(&Value::Object(outer_data.clone()), &["data", "dmscore"])) + { + extra.insert( + "outer_data_extra".to_owned(), + Value::Object(outer_extra.into_iter().collect()), + ); + } + insert_optional_string(&mut extra, "face", message.face); + insert_optional_u64(&mut extra, "timestamp", gift.timestamp); + insert_optional_string(&mut extra, "transaction_id", gift.transaction_id); + insert_optional_u64(&mut extra, "gift_type", gift.gift_type.map(u64::from)); + insert_optional_u64( + &mut extra, + "discount_price", + gift.discount_price.map(u64::from), + ); + + Ok(GiftMessage { + cmd: value.get("cmd").and_then(value_string).unwrap_or_default(), + data: GiftData { + uid: nonzero(message.uid), + uname: nonempty(message.uname), + gift_id: gift.gift_id.map(u64::from), + gift_name: nonempty(gift.gift_name), + num: gift.num.map(u64::from), + price: gift.price.map(u64::from), + total_coin: gift.total_coin.map(u64::from), + coin_type: nonempty(gift.coin_type), + action: nonempty(gift.action), + wealth_level: None, + guard_level: message.medal.as_ref().and_then(|medal| medal.guard_level), + medal: message.medal.map(to_gift_medal), + extra, + }, + danmu: value.get("danmu").and_then(parse_gift_danmu), + message_id: None, + requires_ack: None, + message_type: None, + sent_at: gift.timestamp, + extra: object_extra(value, &["cmd", "data", "danmu"]), + }) +} + +fn to_gift_medal(medal: Medal) -> GiftMedalInfo { + let mut extra = HashMap::new(); + insert_optional_u64(&mut extra, "anchor_uid", nonzero(medal.anchor_uid)); + insert_optional_u64(&mut extra, "color_start", medal.color_start.map(u64::from)); + insert_optional_u64(&mut extra, "color", medal.color.map(u64::from)); + insert_optional_u64( + &mut extra, + "color_border", + medal.color_border.map(u64::from), + ); + insert_optional_u64(&mut extra, "color_end", medal.color_end.map(u64::from)); + GiftMedalInfo { + level: medal.level, + name: nonempty(medal.name), + anchor_name: None, + guard_level: medal.guard_level, + extra, + } +} + +fn decode_send_gift_v2(bytes: &[u8]) -> std::result::Result { + let fields = decode_fields(bytes)?; + Ok(SendGiftV2 { + uid: field_varint(&fields, 1).unwrap_or_default(), + uname: field_string(&fields, 2)?, + face: field_string(&fields, 3)?, + medal: field_bytes(&fields, 8).map(decode_medal).transpose()?, + gift: field_bytes(&fields, 10).map(decode_gift).transpose()?, + }) +} + +fn decode_medal(bytes: &[u8]) -> std::result::Result { + let fields = decode_fields(bytes)?; + Ok(Medal { + anchor_uid: field_varint(&fields, 1).unwrap_or_default(), + level: field_varint(&fields, 5).and_then(|value| value.try_into().ok()), + name: field_string(&fields, 6)?, + color_start: field_varint(&fields, 7).and_then(|value| value.try_into().ok()), + color: field_varint(&fields, 8).and_then(|value| value.try_into().ok()), + color_border: field_varint(&fields, 9).and_then(|value| value.try_into().ok()), + color_end: field_varint(&fields, 10).and_then(|value| value.try_into().ok()), + guard_level: field_varint(&fields, 11).and_then(|value| value.try_into().ok()), + }) +} + +fn decode_gift(bytes: &[u8]) -> std::result::Result { + let fields = decode_fields(bytes)?; + Ok(Gift { + gift_id: field_varint(&fields, 1).and_then(|value| value.try_into().ok()), + gift_name: field_string(&fields, 2)?, + num: field_varint(&fields, 3).and_then(|value| value.try_into().ok()), + gift_type: field_varint(&fields, 4).and_then(|value| value.try_into().ok()), + price: field_varint(&fields, 5).and_then(|value| value.try_into().ok()), + total_coin: field_varint(&fields, 6).and_then(|value| value.try_into().ok()), + discount_price: field_varint(&fields, 7).and_then(|value| value.try_into().ok()), + coin_type: field_string(&fields, 8)?, + transaction_id: field_string(&fields, 9)?, + timestamp: field_varint(&fields, 10), + action: field_string(&fields, 18)?, + }) +} + +fn decode_fields(bytes: &[u8]) -> std::result::Result, String> { + let mut offset = 0; + let mut fields = Vec::new(); + while offset < bytes.len() { + let key = read_varint(bytes, &mut offset)?; + let number = u32::try_from(key >> 3) + .ok() + .filter(|number| *number != 0) + .ok_or_else(|| "protobuf 字段编号无效".to_owned())?; + let value = match key & 7 { + 0 => WireValue::Varint(read_varint(bytes, &mut offset)?), + 1 => { + read_exact(bytes, &mut offset, 8)?; + WireValue::Fixed + } + 2 => { + let length = usize::try_from(read_varint(bytes, &mut offset)?) + .map_err(|_| "protobuf 长度超出平台范围".to_owned())?; + WireValue::Bytes(read_exact(bytes, &mut offset, length)?.to_vec()) + } + 5 => { + read_exact(bytes, &mut offset, 4)?; + WireValue::Fixed + } + wire_type => return Err(format!("不支持的 protobuf wire type:{wire_type}")), + }; + fields.push(Field { number, value }); + } + Ok(fields) +} + +fn read_varint(bytes: &[u8], offset: &mut usize) -> std::result::Result { + let mut value = 0_u64; + for shift in (0..64).step_by(7) { + let byte = *bytes + .get(*offset) + .ok_or_else(|| "protobuf varint 被截断".to_owned())?; + *offset += 1; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Ok(value); + } + } + Err("protobuf varint 过长".to_owned()) +} + +fn read_exact<'a>( + bytes: &'a [u8], + offset: &mut usize, + length: usize, +) -> std::result::Result<&'a [u8], String> { + let end = offset + .checked_add(length) + .ok_or_else(|| "protobuf 长度溢出".to_owned())?; + let value = bytes + .get(*offset..end) + .ok_or_else(|| "protobuf 字段被截断".to_owned())?; + *offset = end; + Ok(value) +} + +fn field_varint(fields: &[Field], number: u32) -> Option { + fields.iter().find_map(|field| { + if field.number == number { + match field.value { + WireValue::Varint(value) => Some(value), + _ => None, + } + } else { + None + } + }) +} + +fn field_bytes(fields: &[Field], number: u32) -> Option<&[u8]> { + fields.iter().find_map(|field| { + if field.number == number { + match &field.value { + WireValue::Bytes(value) => Some(value.as_slice()), + _ => None, + } + } else { + None + } + }) +} + +fn field_string(fields: &[Field], number: u32) -> std::result::Result, String> { + field_bytes(fields, number) + .map(|bytes| { + String::from_utf8(bytes.to_vec()) + .map_err(|_| format!("protobuf 字段 {number} 不是 UTF-8 字符串")) + }) + .transpose() +} + +fn nonzero(value: u64) -> Option { + (value != 0).then_some(value) +} + +fn nonempty(value: Option) -> Option { + value.filter(|value| !value.is_empty()) +} + +fn insert_optional_u64(extra: &mut HashMap, key: &str, value: Option) { + if let Some(value) = value { + extra.insert(key.to_owned(), Value::from(value)); + } +} + +fn insert_optional_string(extra: &mut HashMap, key: &str, value: Option) { + if let Some(value) = nonempty(value) { + extra.insert(key.to_owned(), Value::String(value)); + } +} + +struct SendGiftV2 { + uid: u64, + uname: Option, + face: Option, + medal: Option, + gift: Option, +} + +struct Medal { + anchor_uid: u64, + level: Option, + name: Option, + color_start: Option, + color: Option, + color_border: Option, + color_end: Option, + guard_level: Option, +} + +struct Gift { + gift_id: Option, + gift_name: Option, + num: Option, + gift_type: Option, + price: Option, + total_coin: Option, + discount_price: Option, + coin_type: Option, + transaction_id: Option, + timestamp: Option, + action: Option, +} + +struct Field { + number: u32, + value: WireValue, +} + +enum WireValue { + Varint(u64), + Bytes(Vec), + Fixed, +} diff --git a/src/websocket/command/parse/mod.rs b/src/websocket/command/parse/mod.rs index b1f3b2a..077f591 100644 --- a/src/websocket/command/parse/mod.rs +++ b/src/websocket/command/parse/mod.rs @@ -7,6 +7,7 @@ mod common; mod danmu; mod dispatch; mod gift; +mod gift_v2; mod like; mod status; diff --git a/src/websocket/tests.rs b/src/websocket/tests.rs index 3fde24a..71f5296 100644 --- a/src/websocket/tests.rs +++ b/src/websocket/tests.rs @@ -1,5 +1,15 @@ use super::*; +use base64::{engine::general_purpose::STANDARD, Engine}; use serde_json::json; + +fn gift_v2_payload() -> String { + STANDARD.encode([ + 0x08, 42, 0x12, 6, b't', b'e', b's', b't', b'e', b'r', 0x42, 11, 0x28, 12, 0x32, 5, b'm', + b'e', b'd', b'a', b'l', 0x58, 3, 0x52, 28, 0x08, 123, 0x12, 4, b'g', b'i', b'f', b't', + 0x18, 2, 0x28, 100, 0x30, 0xc8, 1, 0x42, 4, b'g', b'o', b'l', b'd', 0x92, 1, 4, b'g', b'i', + b'v', b'e', + ]) +} #[test] fn parses_packet_and_popularity() { let packet = encode_packet(3, &42u32.to_be_bytes()); @@ -42,6 +52,58 @@ fn dispatches_known_command_and_keeps_extra_fields() { assert_eq!(gift.extra["new_server_field"]["enabled"], true); } +#[test] +fn parses_send_gift_v2_protobuf_envelope() { + let command = parse_command(json!({ + "cmd": "SEND_GIFT_V2", + "danmu": { "area": 0 }, + "data": { + "dmscore": "672", + "pb": gift_v2_payload(), + "data_future": { "enabled": true } + }, + "top_level_future": true + })); + let LiveCommand::Gift(gift) = command else { + panic!("应归一化为礼物消息"); + }; + assert_eq!(gift.cmd, "SEND_GIFT_V2"); + assert_eq!(gift.data.uid, Some(42)); + assert_eq!(gift.data.uname.as_deref(), Some("tester")); + assert_eq!(gift.data.gift_id, Some(123)); + assert_eq!(gift.data.gift_name.as_deref(), Some("gift")); + assert_eq!(gift.data.total_coin, Some(200)); + assert_eq!(gift.data.extra["dmscore"], 672); + assert_eq!(gift.data.extra["pb"], gift_v2_payload()); + assert_eq!(gift.danmu.as_ref().and_then(|danmu| danmu.area), Some(0)); + assert_eq!(gift.data.extra["data_future"]["enabled"], true); + assert_eq!(gift.extra["top_level_future"], true); +} + +#[test] +fn parses_nested_gift_v2_payload_and_legacy_alias() { + let command = parse_command(json!({ + "cmd": "GIFT_V2", + "data": { + "dmscore": 60, + "data": { + "pb": gift_v2_payload(), + "payload_future": true + }, + "outer_future": true + } + })); + let LiveCommand::Gift(gift) = command else { + panic!("应归一化为礼物消息"); + }; + assert_eq!(gift.cmd, "GIFT_V2"); + assert_eq!(gift.data.gift_name.as_deref(), Some("gift")); + assert_eq!(gift.data.extra["dmscore"], 60); + assert_eq!(gift.data.extra["pb"], gift_v2_payload()); + assert_eq!(gift.data.extra["payload_future"], true); + assert_eq!(gift.data.extra["outer_data_extra"]["outer_future"], true); +} + #[test] fn parses_combo_send_and_keeps_nested_extra_fields() { let command = parse_command(json!({