lark-channel 0.3.0

Lark/Feishu Channel SDK for Rust
Documentation
use std::env;
use std::io;
use std::time::Duration;

use lark_channel::lark_openapi::{
    OpenApiClient, OpenApiTransport, ReqwestOpenApiTransport, TokioTungsteniteWebSocketTransport,
    WebSocketEventAck,
};
use lark_channel::{
    ChannelConfig, ChannelEvent, EventLoop, EventLoopOptions, MessageChatType, MessageId,
    MessageSender, MessageSenderOptions, MessageSenderType, NormalizedMessage,
    OpenApiWebSocketEventConnector, ReceivedEvent,
};

const OPENAPI_UUID_MAX_CHARS: usize = 50;
const ECHO_UUID_PREFIX: &str = "echo-";
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x00000100000001b3;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = ChannelConfig::new(
        required_env("LARK_APP_ID")?,
        required_env("LARK_APP_SECRET")?,
    );
    let openapi = OpenApiClient::new(config, ReqwestOpenApiTransport::new());
    let sender = MessageSender::with_options(openapi.clone(), sender_options_from_env()?);
    let echo = EchoConfig::from_env()?;
    let connector =
        OpenApiWebSocketEventConnector::new(openapi, TokioTungsteniteWebSocketTransport::new());
    let mut options = EventLoopOptions::new()
        .with_max_reconnects(optional_usize("LARK_WS_MAX_RECONNECTS")?.unwrap_or(3))
        .with_reconnect_delay(Duration::from_millis(
            optional_u64("LARK_WS_RECONNECT_DELAY_MS")?.unwrap_or(1000),
        ));
    if optional_bool("LARK_WS_USE_SERVER_RECONNECT_CONFIG")?.unwrap_or(false) {
        options = options.with_server_reconnect_config(true);
    }
    if let Some(timeout_ms) = optional_u64("LARK_WS_HEARTBEAT_TIMEOUT_MS")? {
        options = options.with_heartbeat_timeout(Some(Duration::from_millis(timeout_ms)));
    }

    println!(
        "starting echo bot: max_reconnects={}, reconnect_delay={:?}, server_reconnect_config={}, heartbeat_timeout={:?}",
        options.max_reconnects(),
        options.reconnect_delay(),
        options.use_server_reconnect_config(),
        options.heartbeat_timeout()
    );

    let mut event_loop = EventLoop::with_options(connector, options);
    let exit = event_loop
        .run(move |event| {
            let sender = sender.clone();
            let echo = echo.clone();
            async move { handle_echo_event(sender, echo, event).await }
        })
        .await?;

    println!("echo bot event loop exited: {exit:?}");
    Ok(())
}

async fn handle_echo_event<T>(
    sender: MessageSender<T>,
    echo: EchoConfig,
    event: ReceivedEvent,
) -> lark_channel::Result<WebSocketEventAck>
where
    T: OpenApiTransport,
{
    let ChannelEvent::Message(message) = event.event else {
        println!(
            "skipping non-message event: message_id={:?}",
            event.message_id
        );
        return Ok(WebSocketEventAck::ok());
    };

    if message.sender.sender_type == MessageSenderType::Bot {
        println!("skipping bot-authored message: {}", message.message_id);
        return Ok(WebSocketEventAck::ok());
    }
    if !should_echo_message(&message, &echo) {
        println!(
            "skipping message outside echo policy: message_id={}, chat_type={:?}",
            message.message_id, message.chat_type
        );
        return Ok(WebSocketEventAck::ok());
    }

    let text = message.text.trim();
    if text.is_empty() {
        println!("skipping empty text message: {}", message.message_id);
        return Ok(WebSocketEventAck::ok());
    }

    let reply_text = format!("{}{}", echo.prefix, text);
    let reply_id = sender
        .text_reply(MessageId(message.message_id.clone()), reply_text)
        .uuid(echo_uuid_for_message_id(&message.message_id))
        .reply_in_thread(echo.reply_in_thread)
        .send()
        .await?;
    println!(
        "echo reply sent: parent_message_id={}, reply_message_id={}",
        message.message_id, reply_id.0
    );

    Ok(WebSocketEventAck::ok())
}

fn echo_uuid_for_message_id(message_id: &str) -> String {
    let prefixed_len = ECHO_UUID_PREFIX.chars().count() + message_id.chars().count();
    if prefixed_len <= OPENAPI_UUID_MAX_CHARS {
        return format!("{ECHO_UUID_PREFIX}{message_id}");
    }

    format!("{ECHO_UUID_PREFIX}{:016x}", stable_hash(message_id))
}

fn stable_hash(value: &str) -> u64 {
    value.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| {
        (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME)
    })
}

fn should_echo_message(message: &NormalizedMessage, echo: &EchoConfig) -> bool {
    match message.chat_type {
        MessageChatType::P2p => true,
        MessageChatType::Group => {
            echo.echo_all_group_messages
                || echo
                    .bot_open_id
                    .as_deref()
                    .is_some_and(|bot_open_id| message.mentions_bot(bot_open_id))
        }
        MessageChatType::Unknown => false,
    }
}

#[derive(Debug, Clone)]
struct EchoConfig {
    bot_open_id: Option<String>,
    echo_all_group_messages: bool,
    prefix: String,
    reply_in_thread: bool,
}

impl EchoConfig {
    fn from_env() -> Result<Self, io::Error> {
        Ok(Self {
            bot_open_id: env::var("LARK_BOT_OPEN_ID").ok(),
            echo_all_group_messages: optional_bool("LARK_ECHO_ALL_GROUP_MESSAGES")?
                .unwrap_or(false),
            prefix: env::var("LARK_ECHO_PREFIX").unwrap_or_else(|_| "echo: ".to_owned()),
            reply_in_thread: optional_bool("LARK_ECHO_REPLY_IN_THREAD")?.unwrap_or(false),
        })
    }
}

fn sender_options_from_env() -> Result<MessageSenderOptions, io::Error> {
    let mut options = MessageSenderOptions::new();
    if let Ok(max_attempts) = env::var("LARK_MAX_ATTEMPTS") {
        options.set_max_attempts(parse_usize("LARK_MAX_ATTEMPTS", &max_attempts)?);
    }
    Ok(options)
}

fn required_env(name: &str) -> Result<String, io::Error> {
    env::var(name).map_err(|_| {
        io::Error::new(
            io::ErrorKind::NotFound,
            format!("missing required environment variable: {name}"),
        )
    })
}

fn optional_usize(name: &str) -> Result<Option<usize>, io::Error> {
    env::var(name)
        .ok()
        .map(|value| parse_usize(name, &value))
        .transpose()
}

fn optional_u64(name: &str) -> Result<Option<u64>, io::Error> {
    env::var(name)
        .ok()
        .map(|value| parse_u64(name, &value))
        .transpose()
}

fn optional_bool(name: &str) -> Result<Option<bool>, io::Error> {
    env::var(name)
        .ok()
        .map(|value| parse_bool(name, &value))
        .transpose()
}

fn parse_usize(name: &str, value: &str) -> Result<usize, io::Error> {
    value.parse().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{name} must be a non-negative integer"),
        )
    })
}

fn parse_u64(name: &str, value: &str) -> Result<u64, io::Error> {
    value.parse().map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{name} must be a non-negative integer"),
        )
    })
}

fn parse_bool(name: &str, value: &str) -> Result<bool, io::Error> {
    match value {
        "1" | "true" | "TRUE" | "True" | "yes" | "YES" | "Yes" => Ok(true),
        "0" | "false" | "FALSE" | "False" | "no" | "NO" | "No" => Ok(false),
        _ => Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            format!("{name} must be true or false"),
        )),
    }
}

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

    #[test]
    fn echo_uuid_uses_short_message_id_directly() {
        assert_eq!(echo_uuid_for_message_id("om_123"), "echo-om_123");
    }

    #[test]
    fn echo_uuid_hashes_long_message_id_within_openapi_limit() {
        let message_id = format!("om_{}", "x".repeat(80));

        let uuid = echo_uuid_for_message_id(&message_id);

        assert_eq!(uuid, echo_uuid_for_message_id(&message_id));
        assert!(uuid.starts_with(ECHO_UUID_PREFIX));
        assert!(uuid.chars().count() <= OPENAPI_UUID_MAX_CHARS);
    }
}