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:
2026-07-21 13:35:24 -07:00
parent b3e3a3c87c
commit 3cb2a902d4
4 changed files with 375 additions and 0 deletions
+9
View File
@@ -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 {
+303
View File
@@ -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,
}
+1
View File
@@ -7,6 +7,7 @@ mod common;
mod danmu;
mod dispatch;
mod gift;
mod gift_v2;
mod like;
mod status;
+62
View File
@@ -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!({