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::{
|
use super::{
|
||||||
danmu::parse_danmu,
|
danmu::parse_danmu,
|
||||||
gift::{parse_combo_send, parse_gift},
|
gift::{parse_combo_send, parse_gift},
|
||||||
|
gift_v2::parse_gift_v2,
|
||||||
like::{parse_like_click, parse_like_notice, parse_like_update},
|
like::{parse_like_click, parse_like_notice, parse_like_update},
|
||||||
status::{
|
status::{
|
||||||
parse_gift_star_process, parse_online_rank_count, parse_protobuf_payload,
|
parse_gift_star_process, parse_online_rank_count, parse_protobuf_payload,
|
||||||
@@ -52,6 +53,14 @@ pub fn parse_command(value: Value) -> LiveCommand {
|
|||||||
error,
|
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) {
|
Some("COMBO_SEND") => match parse_combo_send(&value) {
|
||||||
Ok(message) => LiveCommand::ComboSend(Box::new(message)),
|
Ok(message) => LiveCommand::ComboSend(Box::new(message)),
|
||||||
Err(error) => LiveCommand::Invalid {
|
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 danmu;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
mod gift;
|
mod gift;
|
||||||
|
mod gift_v2;
|
||||||
mod like;
|
mod like;
|
||||||
mod status;
|
mod status;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn parses_packet_and_popularity() {
|
fn parses_packet_and_popularity() {
|
||||||
let packet = encode_packet(3, &42u32.to_be_bytes());
|
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);
|
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]
|
#[test]
|
||||||
fn parses_combo_send_and_keeps_nested_extra_fields() {
|
fn parses_combo_send_and_keeps_nested_extra_fields() {
|
||||||
let command = parse_command(json!({
|
let command = parse_command(json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user