Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion gateway/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 @@ -1268,38 +1269,6 @@ pub enum MediaRef {
File { message_id: String, file_key: String, file_name: 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
8 changes: 8 additions & 0 deletions gateway/src/adapters/googlechat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_123".into()),
Expand Down Expand Up @@ -1413,6 +1414,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_fail".into()),
Expand Down Expand Up @@ -1459,6 +1461,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_empty".into()),
Expand Down Expand Up @@ -1502,6 +1505,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_multi_fail".into()),
Expand Down Expand Up @@ -1535,6 +1539,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "hello".into(),
attachments: Vec::new(),
},
command: None,
request_id: Some("req_notoken".into()),
Expand Down Expand Up @@ -1579,6 +1584,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: "updated text".into(),
attachments: Vec::new(),
},
command: Some("edit_message".into()),
request_id: None,
Expand Down Expand Up @@ -1620,6 +1626,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_multi".into()),
Expand Down Expand Up @@ -1676,6 +1683,7 @@ mod tests {
content: Content {
content_type: "text".into(),
text: long_text,
attachments: Vec::new(),
},
command: None,
request_id: Some("req_partial".into()),
Expand Down
152 changes: 121 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,10 +162,11 @@ pub async fn webhook(
display_name: user_id.into(),
is_bot: false,
},
text,
&text,
&msg.id,
vec![],
);
gateway_event.content.attachments = attachments;

// Cache the reply token for hybrid Reply/Push dispatch
if let Some(ref reply_token) = event.reply_token {
Expand Down Expand Up @@ -266,3 +278,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 +305 to +309

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, content_type, 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