danmuji-sdk 0.1.0

Rust SDK abstractions for Bilibili Danmuji (bililive_dm) plugins
Documentation
use std::ffi::c_void;
use std::slice;
use std::sync::Mutex;

use crate::model::{Danmaku, GiftRank, InteractType, MsgType};

pub type HostLogFn = unsafe extern "C" fn(userdata: *mut c_void, text: FfiStr);
pub type HostAddDmFn = unsafe extern "C" fn(userdata: *mut c_void, text: FfiStr, fullscreen: i32);
pub type HostSendSspMsgFn = unsafe extern "C" fn(userdata: *mut c_void, text: FfiStr);
pub type HostGetRoomIdFn = unsafe extern "C" fn(userdata: *mut c_void, room_id: *mut i32) -> i32;
pub type HostGetFlagFn = unsafe extern "C" fn(userdata: *mut c_void) -> i32;

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FfiStr {
    pub ptr: *const u8,
    pub len: usize,
}

impl FfiStr {
    pub fn null() -> Self {
        Self {
            ptr: std::ptr::null(),
            len: 0,
        }
    }

    pub fn from_str(value: &str) -> Self {
        Self {
            ptr: value.as_ptr(),
            len: value.len(),
        }
    }

    pub unsafe fn to_string_lossy(self) -> Option<String> {
        if self.ptr.is_null() {
            return None;
        }

        let bytes = slice::from_raw_parts(self.ptr, self.len);
        Some(String::from_utf8_lossy(bytes).into_owned())
    }
}

impl From<&'static str> for FfiStr {
    fn from(value: &'static str) -> Self {
        Self::from_str(value)
    }
}

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FfiPluginMetadata {
    pub name: FfiStr,
    pub author: FfiStr,
    pub contact: FfiStr,
    pub version: FfiStr,
    pub description: FfiStr,
}

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FfiPluginContext {
    pub has_room_id: i32,
    pub room_id: i32,
    pub status: i32,
    pub debug_mode: i32,
}

#[repr(C)]
#[derive(Clone, Copy)]
pub struct FfiHostApi {
    pub userdata: *mut c_void,
    pub log: HostLogFn,
    pub add_dm: HostAddDmFn,
    pub send_ssp_msg: HostSendSspMsgFn,
    pub get_room_id: HostGetRoomIdFn,
    pub get_status: HostGetFlagFn,
    pub get_debug_mode: HostGetFlagFn,
}

unsafe impl Send for FfiHostApi {}
unsafe impl Sync for FfiHostApi {}

static HOST_API: Mutex<Option<FfiHostApi>> = Mutex::new(None);

pub fn set_host_api(api: FfiHostApi) {
    if let Ok(mut host_api) = HOST_API.lock() {
        *host_api = Some(api);
    }
}

pub fn host_api() -> Option<FfiHostApi> {
    HOST_API.lock().ok().and_then(|api| *api)
}

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FfiGiftRank {
    pub user_name: FfiStr,
    pub coin: FfiStr,
    pub uid: i32,
    pub uid_long: i64,
    pub uid_str: FfiStr,
}

impl FfiGiftRank {
    pub unsafe fn to_model(self) -> GiftRank {
        GiftRank {
            user_name: self.user_name.to_string_lossy(),
            coin: self.coin.to_string_lossy(),
            uid: self.uid,
            uid_long: self.uid_long,
            uid_str: self.uid_str.to_string_lossy(),
        }
    }
}

#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct FfiDanmaku {
    pub msg_type: i32,
    pub interact_type: i32,
    pub comment_text: FfiStr,
    pub comment_user: FfiStr,
    pub user_name: FfiStr,
    pub user_id: i32,
    pub user_id_long: i64,
    pub user_id_str: FfiStr,
    pub user_guard_level: i32,
    pub gift_user: FfiStr,
    pub gift_name: FfiStr,
    pub gift_num: FfiStr,
    pub gift_count: i32,
    pub gift_rcost: FfiStr,
    pub gift_ranking_ptr: *const FfiGiftRank,
    pub gift_ranking_len: usize,
    pub is_admin: i32,
    pub is_vip: i32,
    pub room_id: FfiStr,
    pub raw_data: FfiStr,
    pub json_version: i32,
    pub price: FfiStr,
    pub sc_keep_time: i32,
    pub watched_count: i64,
    pub raw_data_jtoken: FfiStr,
}

impl FfiDanmaku {
    pub unsafe fn to_model(self) -> Danmaku {
        let gift_ranking = if self.gift_ranking_ptr.is_null() || self.gift_ranking_len == 0 {
            Vec::new()
        } else {
            slice::from_raw_parts(self.gift_ranking_ptr, self.gift_ranking_len)
                .iter()
                .map(|rank| rank.to_model())
                .collect()
        };

        Danmaku {
            msg_type: MsgType::from_raw(self.msg_type),
            interact_type: InteractType::from_raw(self.interact_type),
            comment_text: self.comment_text.to_string_lossy(),
            comment_user: self.comment_user.to_string_lossy(),
            user_name: self.user_name.to_string_lossy(),
            user_id: self.user_id,
            user_id_long: self.user_id_long,
            user_id_str: self.user_id_str.to_string_lossy(),
            user_guard_level: self.user_guard_level,
            gift_user: self.gift_user.to_string_lossy(),
            gift_name: self.gift_name.to_string_lossy(),
            gift_num: self.gift_num.to_string_lossy(),
            gift_count: self.gift_count,
            gift_rcost: self.gift_rcost.to_string_lossy(),
            gift_ranking,
            is_admin: self.is_admin != 0,
            is_vip: self.is_vip != 0,
            room_id: self.room_id.to_string_lossy(),
            raw_data: self.raw_data.to_string_lossy(),
            json_version: self.json_version,
            price: self.price.to_string_lossy(),
            sc_keep_time: self.sc_keep_time,
            watched_count: self.watched_count,
            raw_data_jtoken: self.raw_data_jtoken.to_string_lossy(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::FfiStr;

    #[test]
    fn ffi_str_round_trips_utf8() {
        let text = "hello";
        let ffi = FfiStr::from_str(text);

        let decoded = unsafe { ffi.to_string_lossy() };

        assert_eq!(decoded.as_deref(), Some(text));
    }

    #[test]
    fn null_ffi_str_is_none() {
        let decoded = unsafe { FfiStr::null().to_string_lossy() };

        assert!(decoded.is_none());
    }
}