refluxer 0.2.0

Rust API wrapper for Fluxer
Documentation
//! A translator bot backed by the public LibreTranslate API.
//!
//! Usage: `!tr <lang> <text>`, e.g. `!tr ru Hello, world!`. The source language
//! is auto-detected. Pass `!tr langs` to print the supported target codes.
//!
//! The default endpoint is the public instance at <https://libretranslate.com>.
//! Set `LIBRETRANSLATE_URL` to point at another instance (e.g. a self-hosted
//! one) and `LIBRETRANSLATE_API_KEY` if your instance requires a key — public
//! instances are aggressively rate-limited.
//!
//! Run: FLUXER_TOKEN=your_token cargo run --example translator_bot --features client

use std::sync::Arc;

use refluxer::model::id::{ChannelId, MessageId};
use refluxer::model::message::Message;
use refluxer::{Client, Context, EventHandler};
use serde::{Deserialize, Serialize};

const DEFAULT_ENDPOINT: &str = "https://libretranslate.com";
const MAX_INPUT_CHARS: usize = 1000;

struct Handler {
    http: Arc<reqwest::Client>,
    endpoint: String,
    api_key: Option<String>,
}

#[async_trait::async_trait]
impl EventHandler for Handler {
    async fn message_create(&self, ctx: Context, msg: Message) {
        if msg.author.bot.unwrap_or(false) {
            return;
        }
        let Some(rest) = msg.content.strip_prefix("!tr") else {
            return;
        };
        let rest = rest.trim_start();

        if rest.is_empty() {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                "Usage: `!tr <lang> <text>` — e.g. `!tr ru Hello`.",
            )
            .await;
            return;
        }
        if rest == "langs" {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                "Common targets: `en`, `ru`, `es`, `fr`, `de`, `it`, `pt`, `ja`, `zh`, `ko`, `ar`, `tr`, `uk`. Any ISO 639-1 code supported by your LibreTranslate instance works.",
            )
            .await;
            return;
        }

        let Some((lang, text)) = rest.split_once(' ') else {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                "Missing text. Usage: `!tr <lang> <text>`.",
            )
            .await;
            return;
        };
        let text = text.trim();
        if text.is_empty() {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                "Text to translate cannot be empty.",
            )
            .await;
            return;
        }
        if text.chars().count() > MAX_INPUT_CHARS {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                &format!("Input too long (>{MAX_INPUT_CHARS} chars). Public LibreTranslate instances don't like long payloads."),
            )
            .await;
            return;
        }
        if !is_valid_lang(lang) {
            reply(
                &ctx,
                msg.channel_id,
                msg.id,
                &format!("Invalid language code `{lang}`."),
            )
            .await;
            return;
        }

        match self.translate(text, lang).await {
            Ok(res) => {
                let detected = res
                    .detected_source_language
                    .unwrap_or_else(|| "auto".to_string());
                let target_owned = lang.to_string();
                let translated = res.translated_text;
                let original_preview = truncate(text, 200);

                let send = ctx
                    .send(msg.channel_id)
                    .reply(msg.id)
                    .embed(move |e| {
                        e.title("Translation")
                            .color(0x2E86DE)
                            .field("From", detected, true)
                            .field("To", target_owned, true)
                            .field("Original", original_preview, false)
                            .field("Translated", translated, false)
                            .footer("LibreTranslate")
                    })
                    .await;
                if let Err(e) = send {
                    tracing::warn!(error = ?e, "failed to send translation");
                }
            }
            Err(TranslateError::RateLimited) => {
                reply(
                    &ctx,
                    msg.channel_id,
                    msg.id,
                    "LibreTranslate rate limit reached, try again later.",
                )
                .await;
            }
            Err(TranslateError::Api { status, message }) => {
                tracing::warn!(%status, %message, "libretranslate api error");
                reply(
                    &ctx,
                    msg.channel_id,
                    msg.id,
                    &format!("LibreTranslate error ({status}): {message}"),
                )
                .await;
            }
            Err(TranslateError::Other(e)) => {
                tracing::warn!(error = ?e, "translate failed");
                reply(
                    &ctx,
                    msg.channel_id,
                    msg.id,
                    "Translation failed — see logs.",
                )
                .await;
            }
        }
    }
}

impl Handler {
    async fn translate(
        &self,
        text: &str,
        target: &str,
    ) -> Result<TranslateResponse, TranslateError> {
        let req = TranslateRequest {
            q: text,
            source: "auto",
            target,
            format: "text",
            api_key: self.api_key.as_deref(),
        };
        let url = format!("{}/translate", self.endpoint.trim_end_matches('/'));
        let resp = self.http.post(url).json(&req).send().await?;
        let status = resp.status();
        if status.as_u16() == 429 {
            return Err(TranslateError::RateLimited);
        }
        if !status.is_success() {
            // LibreTranslate surfaces failures as `{"error": "..."}` with
            // arbitrary HTTP codes (400 for bad input, 403 for missing API
            // key on hosted instances, 500 for backend issues). Forward the
            // message verbatim so users can act on it.
            let message = resp
                .json::<ApiError>()
                .await
                .map(|e| e.error)
                .unwrap_or_else(|_| "unknown error".to_string());
            return Err(TranslateError::Api { status, message });
        }
        Ok(resp.json::<TranslateResponse>().await?)
    }
}

#[derive(Debug, Deserialize)]
struct ApiError {
    error: String,
}

#[derive(Debug, Serialize)]
struct TranslateRequest<'a> {
    q: &'a str,
    source: &'a str,
    target: &'a str,
    format: &'a str,
    #[serde(skip_serializing_if = "Option::is_none", rename = "api_key")]
    api_key: Option<&'a str>,
}

#[derive(Debug, Deserialize)]
struct TranslateResponse {
    #[serde(rename = "translatedText")]
    translated_text: String,
    // Present only when source == "auto". LibreTranslate returns an object
    // `{ confidence, language }` — we only need the language code.
    #[serde(
        default,
        rename = "detectedLanguage",
        deserialize_with = "deserialize_detected"
    )]
    detected_source_language: Option<String>,
}

fn deserialize_detected<'de, D>(de: D) -> Result<Option<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    #[derive(Deserialize)]
    struct Detected {
        language: String,
    }
    let opt = Option::<Detected>::deserialize(de)?;
    Ok(opt.map(|d| d.language))
}

#[derive(Debug)]
enum TranslateError {
    RateLimited,
    Api {
        status: reqwest::StatusCode,
        message: String,
    },
    Other(anyhow::Error),
}

impl From<reqwest::Error> for TranslateError {
    fn from(e: reqwest::Error) -> Self {
        TranslateError::Other(e.into())
    }
}

fn is_valid_lang(s: &str) -> bool {
    let len = s.len();
    (2..=5).contains(&len) && s.chars().all(|c| c.is_ascii_alphabetic() || c == '-')
}

fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let mut out: String = s.chars().take(max).collect();
    out.push('');
    out
}

async fn reply(ctx: &Context, channel_id: ChannelId, reply_to: MessageId, text: &str) {
    if let Err(e) = ctx.send(channel_id).content(text).reply(reply_to).await {
        tracing::warn!(error = ?e, "failed to send reply");
    }
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let token = std::env::var("FLUXER_TOKEN").expect("FLUXER_TOKEN env var required");
    let endpoint =
        std::env::var("LIBRETRANSLATE_URL").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string());
    let api_key = std::env::var("LIBRETRANSLATE_API_KEY").ok();

    let http = Arc::new(
        reqwest::Client::builder()
            .user_agent("refluxer-translator-example")
            .build()
            .expect("build reqwest client"),
    );

    let client = Client::builder()
        .token(&token)
        .event_handler(Handler {
            http,
            endpoint,
            api_key,
        })
        .build()
        .expect("failed to build client");

    if let Err(e) = client.start().await {
        eprintln!("Client error: {e}");
    }
}

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

    #[test]
    fn lang_codes() {
        assert!(is_valid_lang("en"));
        assert!(is_valid_lang("ru"));
        assert!(is_valid_lang("zh-CN"));
        assert!(!is_valid_lang(""));
        assert!(!is_valid_lang("a"));
        assert!(!is_valid_lang("english"));
        assert!(!is_valid_lang("e1"));
    }

    #[test]
    fn truncate_adds_ellipsis() {
        assert_eq!(truncate("hi", 10), "hi");
        assert_eq!(truncate("abcdef", 3), "abc…");
    }
}