Skip to content
Open
2 changes: 2 additions & 0 deletions docs/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ In the LINE Developers Console β†’ **Messaging API** tab β†’ scan the QR code wi

- **1:1 chat** β€” send a message to the bot, get an AI agent response
- **Group chat** β€” add the bot to a group, it responds to all messages
- **Images** β€” send image messages to the bot (automatically compressed and resized)
- **Audio** β€” send audio messages (e.g. voice notes). They are automatically transcribed (if STT is enabled in Core) and passed to the agent as text.
- **Webhook signature validation** β€” HMAC-SHA256 via `LINE_CHANNEL_SECRET`

### Not Supported (LINE API limitations)
Expand Down
6 changes: 6 additions & 0 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ explain VPC peering ← ignored in groups

DMs and replies within forum topics always trigger the agent (no @mention needed).

### File Attachments

- **Images** β€” send photos (compressed/resized automatically).
- **Documents** β€” send text-based files (e.g. `.txt`, `.csv`, `.rs`, `.py`) up to 512KB. They are passed directly to the agent as text.
- **Audio/Voice** β€” send voice notes or audio files. They are automatically transcribed (if STT is enabled in Core) and passed to the agent as text.

### Emoji reactions

The bot shows status reactions on your message as the agent works:
Expand Down
33 changes: 1 addition & 32 deletions gateway/src/adapters/feishu.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::media::{resize_and_compress, FILE_MAX_DOWNLOAD, IMAGE_MAX_DOWNLOAD};
use crate::schema::*;
use axum::extract::State;
use prost::Message as ProstMessage;
Expand Down Expand Up @@ -1371,38 +1372,6 @@ pub enum MediaRef {
Audio { message_id: String, file_key: String },
}

const IMAGE_MAX_DIMENSION_PX: u32 = 1200;
const IMAGE_JPEG_QUALITY: u8 = 75;
const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB
const FILE_MAX_DOWNLOAD: u64 = 512 * 1024; // 512 KB

/// Resize image so longest side <= 1200px, then encode as JPEG.
/// GIFs are passed through unchanged to preserve animation.
fn resize_and_compress(raw: &[u8]) -> Result<(Vec<u8>, String), image::ImageError> {
use image::ImageReader;
use std::io::Cursor;

let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?;
let format = reader.format();
if format == Some(image::ImageFormat::Gif) {
return Ok((raw.to_vec(), "image/gif".to_string()));
}
let img = reader.decode()?;
let (w, h) = (img.width(), img.height());
let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX {
let max_side = std::cmp::max(w, h);
let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side);
let new_w = (f64::from(w) * ratio) as u32;
let new_h = (f64::from(h) * ratio) as u32;
img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3)
} else {
img
};
let mut buf = Cursor::new(Vec::new());
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY);
img.write_with_encoder(encoder)?;
Ok((buf.into_inner(), "image/jpeg".to_string()))
}

/// Download a Feishu image by message_id + image_key β†’ resize/compress β†’ base64 Attachment.
pub async fn download_feishu_image(
Expand Down
157 changes: 126 additions & 31 deletions gateway/src/adapters/line.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::media::{resize_and_compress, AUDIO_MAX_DOWNLOAD, IMAGE_MAX_DOWNLOAD};
use crate::schema::*;
use axum::extract::State;
use serde::Deserialize;
Expand Down Expand Up @@ -90,45 +91,55 @@ pub async fn webhook(
let Some(ref msg) = event.message else {
continue;
};
if msg.message_type != "text" {
let is_text = msg.message_type == "text";
let is_image = msg.message_type == "image";
let is_audio = msg.message_type == "audio";

if !is_text && !is_image && !is_audio {
continue;
}
Comment on lines +94 to 100
let Some(ref text) = msg.text else {
continue;
};
if text.trim().is_empty() {

let text = msg.text.clone().unwrap_or_default();
if is_text && text.trim().is_empty() {
continue;
}

let mut attachments = Vec::new();
if is_image || is_audio {
if let Some(ref access_token) = state.line_access_token {
let client = &state.client;
let att_type = if is_image { "image" } else { "audio" };
if let Some(att) = download_line_media(client, access_token, &msg.id, att_type).await {
attachments.push(att);
}
} else {
warn!("LINE media received but LINE_CHANNEL_ACCESS_TOKEN not set");
}
}

let source = event.source.as_ref();
let (channel_id, channel_type) = match source {
Some(s) if s.source_type == "group" => {
match s.group_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "group".to_string()),
_ => {
warn!("LINE group event missing groupId, skipping");
continue;
}
Some(s) if s.source_type == "group" => match s.group_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "group".to_string()),
_ => {
warn!("LINE group event missing groupId, skipping");
continue;
}
}
Some(s) if s.source_type == "room" => {
match s.room_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "room".to_string()),
_ => {
warn!("LINE room event missing roomId, skipping");
continue;
}
},
Some(s) if s.source_type == "room" => match s.room_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "room".to_string()),
_ => {
warn!("LINE room event missing roomId, skipping");
continue;
}
}
Some(s) => {
match s.user_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "user".to_string()),
_ => {
warn!("LINE user event missing userId, skipping");
continue;
}
},
Some(s) => match s.user_id.as_deref() {
Some(id) if !id.is_empty() => (id.to_string(), "user".to_string()),
_ => {
warn!("LINE user event missing userId, skipping");
continue;
}
}
},
None => {
warn!("LINE event missing source, skipping");
continue;
Expand All @@ -138,7 +149,7 @@ pub async fn webhook(
.and_then(|s| s.user_id.as_deref())
.unwrap_or("unknown");

let gateway_event = GatewayEvent::new(
let mut gateway_event = GatewayEvent::new(
"line",
ChannelInfo {
id: channel_id.clone(),
Expand All @@ -151,11 +162,17 @@ pub async fn webhook(
display_name: user_id.into(),
is_bot: false,
},
text,
&text,
&msg.id,
vec![],
);
gateway_event.content.attachments = attachments;


// Guard: skip empty events (no text + no attachments)
if gateway_event.content.text.trim().is_empty() && gateway_event.content.attachments.is_empty() {
continue;
}
// Cache the reply token for hybrid Reply/Push dispatch
if let Some(ref reply_token) = event.reply_token {
let mut cache = state
Expand Down Expand Up @@ -266,3 +283,81 @@ pub async fn dispatch_line_reply(

used_reply
}

/// Download media content from LINE Messaging API.
async fn download_line_media(
client: &reqwest::Client,
access_token: &str,
message_id: &str,
attachment_type: &str,
) -> Option<Attachment> {
let url = format!(
"https://api-data.line.me/v2/bot/message/{}/content",
message_id
);
let resp = client
.get(url)
.bearer_auth(access_token)
.send()
.await
.ok()?;

if !resp.status().is_success() {
error!(status = %resp.status(), "LINE media download failed");
return None;
}

let max_size = if attachment_type == "image" {
IMAGE_MAX_DOWNLOAD
} else {
AUDIO_MAX_DOWNLOAD
};
Comment on lines +310 to +314

if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) {
if let Ok(size) = cl.to_str().unwrap_or("0").parse::<u64>() {
if size > max_size {
warn!(message_id, size, "LINE {} Content-Length exceeds limit", attachment_type);
return None;
}
}
}

let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or(if attachment_type == "image" { "image/jpeg" } else { "audio/mp4" })
.to_string();

let bytes = resp.bytes().await.ok()?;
if bytes.len() as u64 > max_size {
warn!(message_id, size = bytes.len(), "LINE {} exceeds limit", attachment_type);
return None;
}

let (data_bytes, mime, filename) = if attachment_type == "image" {
match resize_and_compress(&bytes) {
Ok((c, m)) => (c, m, format!("{}.jpg", message_id)),
Err(e) => {
error!(err = %e, "LINE image processing failed");
return None;
}
}
} else {
// For audio, we don't process, just send as is.
// LINE audio is usually m4a.
(bytes.to_vec(), content_type, format!("{}.m4a", message_id))
};

use base64::Engine;
let b64_data = base64::engine::general_purpose::STANDARD.encode(&data_bytes);
info!(message_id, size = data_bytes.len(), "LINE {} download successful", attachment_type);

Some(Attachment {
attachment_type: attachment_type.into(),
filename,
mime_type: mime,
data: b64_data,
size: data_bytes.len() as u64,
})
}
Loading
Loading