feat: normalize protobuf gift events
Decode SEND_GIFT_V2 protobuf payloads and map their stable fields to the existing Gift command so existing consumers receive new-format gift events.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<GiftMessage, String> {
|
||||
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<SendGiftV2, String> {
|
||||
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<Medal, String> {
|
||||
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<Gift, String> {
|
||||
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<Vec<Field>, 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<u64, String> {
|
||||
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<u64> {
|
||||
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<Option<String>, 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<u64> {
|
||||
(value != 0).then_some(value)
|
||||
}
|
||||
|
||||
fn nonempty(value: Option<String>) -> Option<String> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn insert_optional_u64(extra: &mut HashMap<String, Value>, key: &str, value: Option<u64>) {
|
||||
if let Some(value) = value {
|
||||
extra.insert(key.to_owned(), Value::from(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_optional_string(extra: &mut HashMap<String, Value>, key: &str, value: Option<String>) {
|
||||
if let Some(value) = nonempty(value) {
|
||||
extra.insert(key.to_owned(), Value::String(value));
|
||||
}
|
||||
}
|
||||
|
||||
struct SendGiftV2 {
|
||||
uid: u64,
|
||||
uname: Option<String>,
|
||||
face: Option<String>,
|
||||
medal: Option<Medal>,
|
||||
gift: Option<Gift>,
|
||||
}
|
||||
|
||||
struct Medal {
|
||||
anchor_uid: u64,
|
||||
level: Option<u8>,
|
||||
name: Option<String>,
|
||||
color_start: Option<u32>,
|
||||
color: Option<u32>,
|
||||
color_border: Option<u32>,
|
||||
color_end: Option<u32>,
|
||||
guard_level: Option<u8>,
|
||||
}
|
||||
|
||||
struct Gift {
|
||||
gift_id: Option<u32>,
|
||||
gift_name: Option<String>,
|
||||
num: Option<u32>,
|
||||
gift_type: Option<u32>,
|
||||
price: Option<u32>,
|
||||
total_coin: Option<u32>,
|
||||
discount_price: Option<u32>,
|
||||
coin_type: Option<String>,
|
||||
transaction_id: Option<String>,
|
||||
timestamp: Option<u64>,
|
||||
action: Option<String>,
|
||||
}
|
||||
|
||||
struct Field {
|
||||
number: u32,
|
||||
value: WireValue,
|
||||
}
|
||||
|
||||
enum WireValue {
|
||||
Varint(u64),
|
||||
Bytes(Vec<u8>),
|
||||
Fixed,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod common;
|
||||
mod danmu;
|
||||
mod dispatch;
|
||||
mod gift;
|
||||
mod gift_v2;
|
||||
mod like;
|
||||
mod status;
|
||||
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user