roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
//! Channel resolution helpers, typing/thinking indicators, and latency estimation.

use super::AppState;

pub(super) fn metadata_str(meta: Option<&serde_json::Value>, ptr: &str) -> Option<String> {
    meta.and_then(|m| m.pointer(ptr)).and_then(|v| {
        v.as_str()
            .map(|s| s.to_string())
            .or_else(|| v.as_i64().map(|n| n.to_string()))
            .or_else(|| v.as_u64().map(|n| n.to_string()))
    })
}

pub(super) fn resolve_channel_chat_id(inbound: &roboticus_channels::InboundMessage) -> String {
    let meta = inbound.metadata.as_ref();
    metadata_str(meta, "/chat_id")
        .or_else(|| metadata_str(meta, "/channel_id"))
        .or_else(|| metadata_str(meta, "/thread_id"))
        .or_else(|| metadata_str(meta, "/conversation_id"))
        .or_else(|| metadata_str(meta, "/group_id"))
        .or_else(|| metadata_str(meta, "/message/chat/id"))
        .or_else(|| metadata_str(meta, "/messages/0/chat/id"))
        .or_else(|| metadata_str(meta, "/messages/0/channel_id"))
        .unwrap_or_else(|| inbound.sender_id.clone())
}

pub(crate) fn channel_chat_id_for_inbound(inbound: &roboticus_channels::InboundMessage) -> String {
    resolve_channel_chat_id(inbound)
}

pub(super) fn resolve_channel_is_group(inbound: &roboticus_channels::InboundMessage) -> bool {
    let meta = inbound.metadata.as_ref();
    if let Some(v) = meta
        .and_then(|m| m.get("is_group"))
        .and_then(|v| v.as_bool())
    {
        return v;
    }
    if let Some(kind) = metadata_str(meta, "/message/chat/type") {
        return matches!(kind.as_str(), "group" | "supergroup");
    }
    false
}

pub(super) fn resolve_channel_scope(
    cfg: &roboticus_core::RoboticusConfig,
    inbound: &roboticus_channels::InboundMessage,
    chat_id: &str,
) -> roboticus_db::sessions::SessionScope {
    let mode = cfg.session.scope_mode.as_str();
    let channel = inbound.platform.to_lowercase();
    if mode == "group" && resolve_channel_is_group(inbound) {
        return roboticus_db::sessions::SessionScope::Group {
            group_id: chat_id.to_string(),
            channel,
        };
    }
    if mode == "peer" || mode == "group" {
        return roboticus_db::sessions::SessionScope::Peer {
            peer_id: inbound.sender_id.clone(),
            channel,
        };
    }
    if mode == "agent" {
        return roboticus_db::sessions::SessionScope::Agent;
    }
    tracing::warn!(
        scope_mode = %mode,
        platform = %channel,
        "session scope degraded to Agent (global) — unrecognized scope_mode"
    );
    roboticus_db::sessions::SessionScope::Agent
}

pub(super) fn parse_skills_json(skills_json: Option<&str>) -> Vec<String> {
    skills_json
        .and_then(|s| {
            serde_json::from_str::<Vec<String>>(s)
                .inspect_err(|e| tracing::warn!(error = %e, "failed to parse skills JSON"))
                .ok()
        })
        .unwrap_or_default()
}

/// Send a "typing..." indicator on the appropriate chat channel.
/// Best-effort — failures are silently ignored so they never block processing.
pub(super) async fn send_typing_indicator(
    state: &AppState,
    platform: &str,
    chat_id: &str,
    metadata: Option<&serde_json::Value>,
) {
    match platform {
        "telegram" => {
            if let Some(ref tg) = state.telegram {
                tg.send_typing(chat_id).await;
            }
        }
        "whatsapp" => {
            if let Some(ref wa) = state.whatsapp {
                let msg_id = metadata
                    .and_then(|m| m.pointer("/messages/0/id"))
                    .or_else(|| metadata.and_then(|m| m.get("id")))
                    .and_then(|v| v.as_str());
                wa.send_typing(chat_id, msg_id).await;
            }
        }
        "discord" => {
            if let Some(ref dc) = state.discord {
                dc.send_typing(chat_id).await;
            }
        }
        "signal" => {
            if let Some(ref sig) = state.signal {
                sig.send_typing(chat_id).await;
            }
        }
        _ => {}
    }
}

/// Resolve allow-list status for a channel sender/chat.
///
/// Returns `(sender_in_allowlist, allowlist_configured)`.
/// Shared by `build_channel_claim_context()` and `resolve_command_authority()`.
pub(super) fn resolve_allowlist_status(
    config: &roboticus_core::RoboticusConfig,
    platform: &str,
    chat_id: &str,
    sender_id: &str,
) -> (bool, bool) {
    match platform {
        "telegram" => {
            if let Some(ref tg) = config.channels.telegram {
                let in_list = tg
                    .allowed_chat_ids
                    .iter()
                    .any(|id| id.to_string() == chat_id);
                (
                    !tg.allowed_chat_ids.is_empty() && in_list,
                    !tg.allowed_chat_ids.is_empty(),
                )
            } else {
                (false, true)
            }
        }
        "whatsapp" => {
            if let Some(ref wa) = config.channels.whatsapp {
                let in_list = wa.allowed_numbers.iter().any(|n| n == sender_id);
                (
                    !wa.allowed_numbers.is_empty() && in_list,
                    !wa.allowed_numbers.is_empty(),
                )
            } else {
                (false, true)
            }
        }
        "discord" => {
            if let Some(ref dc) = config.channels.discord {
                let in_list = dc.allowed_guild_ids.iter().any(|g| g == chat_id);
                (
                    !dc.allowed_guild_ids.is_empty() && in_list,
                    !dc.allowed_guild_ids.is_empty(),
                )
            } else {
                (false, true)
            }
        }
        "signal" => {
            if let Some(ref sig) = config.channels.signal {
                let in_list = sig.allowed_numbers.iter().any(|n| n == sender_id);
                (
                    !sig.allowed_numbers.is_empty() && in_list,
                    !sig.allowed_numbers.is_empty(),
                )
            } else {
                (false, true)
            }
        }
        "email" => {
            let sender_lc = sender_id.to_lowercase();
            let in_list = config
                .channels
                .email
                .allowed_senders
                .iter()
                .any(|s| s.eq_ignore_ascii_case(&sender_lc));
            (
                !config.channels.email.allowed_senders.is_empty() && in_list,
                !config.channels.email.allowed_senders.is_empty(),
            )
        }
        _ => (false, true),
    }
}