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() {
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,
#[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…");
}
}