soup-sdk 0.3.2

채팅 이벤트 수신 SDK
Documentation
use std::env;
use std::sync::Arc;
use std::time::Duration;

use soup_sdk::{
    LiveStatus, SoopHttpClient,
    chat::{Event, SoopChatConnection, SoopChatOptions, commands::Command},
};

const DEFAULT_CHAT_SAMPLE_SECONDS: u64 = 20;
const MAX_PRINTED_EVENT_SAMPLES: usize = 10;
const MAX_PRINTED_PARTICIPANTS: usize = 10;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let config = DebugConfig::from_args(env::args())?;
    let client = Arc::new(SoopHttpClient::new());

    println!("SOOP SDK debug target: {}", config.streamer_id);
    println!("chat sample seconds: {}", config.chat_sample_seconds);
    println!();

    let live_status = check_http_apis(Arc::clone(&client), &config.streamer_id).await?;
    check_live_status(&live_status);

    if matches!(live_status, LiveStatus::Offline) {
        println!("[skip] live-chat: streamer is offline");
        return Ok(());
    }

    check_live_chat(
        Arc::clone(&client),
        &config.streamer_id,
        config.chat_sample_seconds,
        &config.participant_labels,
    )
    .await?;

    Ok(())
}

struct DebugConfig {
    streamer_id: String,
    chat_sample_seconds: u64,
    participant_labels: Vec<String>,
}

impl DebugConfig {
    fn from_args(args: impl IntoIterator<Item = String>) -> anyhow::Result<Self> {
        let mut args = args.into_iter();
        let program = args.next().unwrap_or_else(|| "soup-sdk".to_string());

        let Some(streamer_id) = args.next() else {
            anyhow::bail!(
                "missing streamer id\n\nusage: {program} <streamer_id> [chat_sample_seconds] [participant_labels]\nexample: {program} danchu17 20 나른자,모지나"
            );
        };

        let chat_sample_seconds = match args.next() {
            Some(value) => value.parse::<u64>().map_err(|error| {
                anyhow::anyhow!("invalid chat_sample_seconds value {value:?}: {error}")
            })?,
            None => DEFAULT_CHAT_SAMPLE_SECONDS,
        };

        let participant_labels = args
            .next()
            .map(|value| {
                value
                    .split(',')
                    .map(str::trim)
                    .filter(|label| !label.is_empty())
                    .map(str::to_string)
                    .collect::<Vec<_>>()
            })
            .unwrap_or_default();

        if args.next().is_some() {
            anyhow::bail!(
                "too many arguments\n\nusage: {program} <streamer_id> [chat_sample_seconds] [participant_labels]"
            );
        }

        if streamer_id.trim().is_empty() {
            anyhow::bail!("streamer id must not be empty");
        }

        if chat_sample_seconds == 0 {
            anyhow::bail!("chat_sample_seconds must be greater than zero");
        }

        Ok(Self {
            streamer_id,
            chat_sample_seconds,
            participant_labels,
        })
    }
}

async fn check_http_apis(
    client: Arc<SoopHttpClient>,
    streamer_id: &str,
) -> anyhow::Result<LiveStatus> {
    println!("== API calls ==");

    let live_status = client.get_live_status(streamer_id).await?;
    match &live_status {
        LiveStatus::Live(detail) => {
            println!(
                "[ok] get_live_status: live nick={} title={} ch_domain={} ch_pt={} ch_no={} categories={:?}",
                detail.streamer_nick,
                detail.title,
                detail.ch_domain,
                detail.ch_pt,
                detail.ch_no,
                detail.categories
            );
        }
        LiveStatus::Offline => {
            println!("[ok] get_live_status: offline");
        }
    }

    let station = client.get_station(streamer_id).await?;
    println!(
        "[ok] get_station: title={} viewer_count={} is_password={} broad_start={}",
        station.title, station.viewer_count, station.is_password, station.broad_start
    );

    let emoticons = client.get_signature_emoticon(streamer_id).await?;
    println!(
        "[ok] get_signature_emoticon: tier1={} tier2={}",
        emoticons.tier_1.len(),
        emoticons.tier_2.len()
    );

    match client.get_vod_list(streamer_id, 1).await {
        Ok(vods) => {
            println!("[ok] get_vod_list: public_vods={}", vods.len());

            if let Some(first_vod) = vods.first() {
                println!(
                    "[ok] get_vod_list first: id={} title={} duration={} thumbnail={} reg_date={}",
                    first_vod.id,
                    first_vod.title,
                    first_vod.duration,
                    first_vod.thumbnail_url,
                    first_vod.reg_date
                );
            }
        }
        Err(error) => println!("[warn] get_vod_list: {error}"),
    }

    println!();
    Ok(live_status)
}

fn check_live_status(live_status: &LiveStatus) {
    println!("== Live status ==");
    match live_status {
        LiveStatus::Live(detail) => {
            println!(
                "[ok] live: streamer_nick={} title={} chat={}:{} room={}",
                detail.streamer_nick, detail.title, detail.ch_domain, detail.ch_pt, detail.ch_no
            );
        }
        LiveStatus::Offline => {
            println!("[ok] offline");
        }
    }
    println!();
}

async fn check_live_chat(
    client: Arc<SoopHttpClient>,
    streamer_id: &str,
    sample_seconds: u64,
    participant_labels: &[String],
) -> anyhow::Result<()> {
    println!("== Live chat ==");

    let options = SoopChatOptions {
        streamer_id: streamer_id.to_string(),
        password: String::new(),
        diagnostics: false,
    };

    let connection = SoopChatConnection::new(client, options)?;
    let mut events = connection.subscribe();

    connection.start().await?;
    println!("[ok] start: connected task started, sampling {sample_seconds}s");
    tokio::time::sleep(Duration::from_secs(1)).await;
    connection.command(Command::RequestParticipantList)?;
    println!("[ok] participant-list: request sent");

    let deadline = tokio::time::Instant::now() + Duration::from_secs(sample_seconds);
    let mut counts = ChatEventCounts::default();
    let mut printed_samples = 0usize;

    while tokio::time::Instant::now() < deadline {
        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());

        match tokio::time::timeout(remaining, events.recv()).await {
            Ok(Ok(event)) => {
                count_event(&mut counts, &event);

                if let Event::Unknown(code) = &event {
                    println!("[event] unknown code={code:?}");
                }

                if printed_samples < MAX_PRINTED_EVENT_SAMPLES && should_print_event_sample(&event)
                {
                    print_event_sample(&event, participant_labels);
                    printed_samples += 1;
                }
            }
            Ok(Err(error)) => {
                println!("[warn] recv: {error}");
                break;
            }
            Err(_) => break,
        }
    }

    connection.command(Command::Shutdown)?;

    if let Ok(Ok(event)) = tokio::time::timeout(Duration::from_secs(2), events.recv()).await {
        count_event(&mut counts, &event);
        if should_print_event_sample(&event) {
            print_event_sample(&event, participant_labels);
        }
    }

    println!(
        "[ok] summary: connected={} disconnected={} chat={} donation={} subscribe={} participant_lists={} participant_count={} diagnostic={} raw={} unknown={} other={}",
        counts.connected,
        counts.disconnected,
        counts.chat,
        counts.donation,
        counts.subscribe,
        counts.participant_lists,
        counts.participant_count,
        counts.diagnostic,
        counts.raw,
        counts.unknown,
        counts.other
    );

    Ok(())
}

#[derive(Default)]
struct ChatEventCounts {
    connected: usize,
    disconnected: usize,
    chat: usize,
    donation: usize,
    subscribe: usize,
    participant_lists: usize,
    participant_count: usize,
    diagnostic: usize,
    raw: usize,
    unknown: usize,
    other: usize,
}

fn count_event(counts: &mut ChatEventCounts, event: &Event) {
    match event {
        Event::Connected => counts.connected += 1,
        Event::Disconnected(_) => counts.disconnected += 1,
        Event::Chat(_) => counts.chat += 1,
        Event::Donation(_) => counts.donation += 1,
        Event::Subscribe(_) => counts.subscribe += 1,
        Event::Enter(_) => {}
        Event::ParticipantList(event) => {
            counts.participant_lists += 1;
            counts.participant_count = event.count;
        }
        Event::Diagnostic(_) => counts.diagnostic += 1,
        Event::Raw(_) => counts.raw += 1,
        Event::Unknown(_) => counts.unknown += 1,
        _ => counts.other += 1,
    }
}

fn print_event_sample(event: &Event, participant_labels: &[String]) {
    match event {
        Event::Connected => println!("[event] connected"),
        Event::Disconnected(event) => println!("[event] disconnected reason={:?}", event.reason),
        Event::Chat(event) => println!(
            "[event] chat user={} comment={}",
            event.user.label, event.comment
        ),
        Event::Donation(event) => println!(
            "[event] donation from={} amount={}",
            event.from_label, event.amount
        ),
        Event::Subscribe(event) => println!("[event] subscribe {event:?}"),
        Event::Enter(event) => println!("[event] enter user={}", event.user.label),
        Event::ParticipantList(event) => {
            println!(
                "[event] participant-list count={} result_code={}",
                event.count, event.result_code
            );

            for participant in event.entries.iter().take(MAX_PRINTED_PARTICIPANTS) {
                println!(
                    "[event] participant user={} id={} follow_level={} fan_level={} supporter={} manager={}",
                    participant.label,
                    participant.id,
                    participant.status.follow_level,
                    participant.status.fan_level,
                    participant.status.is_supporter,
                    participant.status.is_manager
                );
            }

            let remaining = event.entries.len().saturating_sub(MAX_PRINTED_PARTICIPANTS);
            if remaining > 0 {
                println!("[event] participant-list ... and {remaining} more");
            }

            for label in participant_labels {
                match event
                    .entries
                    .iter()
                    .find(|participant| &participant.label == label)
                {
                    Some(participant) => println!(
                        "[event] participant-match label={} id={} follow_level={} fan_level={} supporter={} manager={}",
                        participant.label,
                        participant.id,
                        participant.status.follow_level,
                        participant.status.fan_level,
                        participant.status.is_supporter,
                        participant.status.is_manager
                    ),
                    None => println!("[event] participant-match label={label} not found"),
                }
            }
        }
        Event::Diagnostic(event) => println!("[event] diagnostic {event:?}"),
        Event::Raw(raw) => println!("[event] raw bytes={}", raw.len()),
        Event::Unknown(code) => println!("[event] unknown code={code:?}"),
        other => println!("[event] other {other:?}"),
    }
}

fn should_print_event_sample(event: &Event) -> bool {
    !matches!(
        event,
        Event::Diagnostic(_) | Event::Raw(_) | Event::Unknown(_)
    )
}