Skip to content
9 changes: 9 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl
| `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. |
| `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. |

### Gateway Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `GATEWAY_MEDIA_BASE_URL` | Public base URL for media proxy (e.g. `https://gateway.example.com`). | `http://localhost:8080` |
| `GATEWAY_MEDIA_STORE_TTL` | Seconds to keep media in memory before expiration. | `300` |
| `GATEWAY_MEDIA_STORE_MAX_ENTRIES` | Maximum number of media items to keep in memory. | `1000` |


---

## `[agent]`
Expand Down
1 change: 1 addition & 0 deletions docs/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ 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
- **Webhook signature validation** — HMAC-SHA256 via `LINE_CHANNEL_SECRET`
- **Media Support** — Support for incoming images and audio/voice messages via media proxy.

### Not Supported (LINE API limitations)

Expand Down
4 changes: 4 additions & 0 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ explain VPC peering ← ignored in groups

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

### Media support

Incoming images and audio/voice messages are supported via media proxy. The gateway downloads the media and provides a temporary URL to the agent for analysis.

### Emoji reactions

The bot shows status reactions on your message as the agent works:
Expand Down
43 changes: 26 additions & 17 deletions gateway/src/adapters/feishu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,14 @@ mod event_types {
pub event: Option<FeishuEventBody>,
pub challenge: Option<String>,
#[serde(rename = "type")]
#[allow(dead_code)]
pub event_type_field: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct FeishuEventHeader {
pub event_id: Option<String>,
#[allow(dead_code)]
pub event_type: Option<String>,
}

Expand Down Expand Up @@ -231,6 +233,7 @@ mod event_types {
pub struct FeishuMention {
pub key: Option<String>,
pub id: Option<FeishuMentionId>,
#[allow(dead_code)]
pub name: Option<String>,
}

Expand Down Expand Up @@ -781,6 +784,7 @@ pub async fn start_websocket(
}

/// Single WebSocket connection lifecycle.
#[allow(clippy::too_many_arguments)]
async fn ws_connect_loop(
token_cache: &Arc<FeishuTokenCache>,
bot_open_id_store: &Arc<RwLock<Option<String>>>,
Expand Down Expand Up @@ -848,7 +852,7 @@ async fn ws_connect_loop(
ack.payload = Some(b"{\"code\":200}".to_vec());
let ack_bytes = ack.encode_to_vec();
let _ = ws_tx.send(
tokio_tungstenite::tungstenite::Message::Binary(ack_bytes.into())
tokio_tungstenite::tungstenite::Message::Binary(ack_bytes)
).await;
}
}
Expand All @@ -869,6 +873,7 @@ async fn ws_connect_loop(
}

/// Process a single WebSocket text message.
#[allow(clippy::too_many_arguments)]
async fn handle_ws_message(
text: &str,
bot_open_id_store: &Arc<RwLock<Option<String>>>,
Expand Down Expand Up @@ -1080,8 +1085,8 @@ fn markdown_to_post(md: &str) -> serde_json::Value {
let line = raw_lines[li];
// Detect fenced code block
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
let lang = trimmed[3..].trim().to_string();
if let Some(rest) = trimmed.strip_prefix("```") {
let lang = rest.trim().to_string();
let mut code = String::new();
li += 1;
while li < raw_lines.len() {
Expand Down Expand Up @@ -1154,8 +1159,8 @@ fn parse_inline(line: &str) -> Vec<serde_json::Value> {
}
if close_ticks == ticks {
// Found matching close — content between is literal
for j in i..end {
buf.push(chars[j]);
for ch in chars.iter().take(end).skip(i) {
buf.push(*ch);
}
i = end + close_ticks;
break 'outer;
Expand All @@ -1167,8 +1172,8 @@ fn parse_inline(line: &str) -> Vec<serde_json::Value> {
}
if end >= len {
// No matching close — treat backticks as literal
for j in i..len {
buf.push(chars[j]);
for ch in chars.iter().skip(i) {
buf.push(*ch);
}
i = len;
}
Expand All @@ -1194,8 +1199,8 @@ fn parse_inline(line: &str) -> Vec<serde_json::Value> {
}
if close_run == run {
// Found matching close — strip both, keep inner text
for j in after..scan {
buf.push(chars[j]);
for ch in chars.iter().take(scan).skip(after) {
buf.push(*ch);
}
i = scan + close_run;
found_close = true;
Expand Down Expand Up @@ -1351,13 +1356,15 @@ pub async fn download_feishu_image(
let ext = if mime == "image/gif" { "gif" } else { "jpg" };
Some(crate::schema::Attachment {
attachment_type: "image".into(),
filename: format!("{}.{}", image_key, ext),
mime_type: mime,
data,
size: compressed.len() as u64,
url: None,
filename: Some(format!("{}.{}", image_key, ext)),
mime_type: Some(mime),
data: Some(data),
size: Some(compressed.len() as u64),
})
}


/// Download a Feishu file by message_id + file_key → base64 Attachment (text files only).
pub async fn download_feishu_file(
client: &reqwest::Client,
Expand Down Expand Up @@ -1413,13 +1420,15 @@ pub async fn download_feishu_file(
let data = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
Some(crate::schema::Attachment {
attachment_type: "text_file".into(),
filename: file_name.to_string(),
mime_type: "text/plain".into(),
data,
size: bytes.len() as u64,
url: None,
filename: Some(file_name.to_string()),
mime_type: Some("text/plain".into()),
data: Some(data),
size: Some(bytes.len() as u64),
})
}


/// Send a post (rich text) message to a feishu chat_id.
/// Returns the sent message_id on success, None on failure.
/// When `reply_to` is Some(root_id), uses the reply API to stay in a thread.
Expand Down
1 change: 1 addition & 0 deletions gateway/src/adapters/googlechat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub struct GoogleChatSpace {
pub name: String,
#[serde(rename = "type")]
pub space_type: Option<String>,
#[allow(dead_code)]
pub space_type_renamed: Option<String>,
}

Expand Down
156 changes: 125 additions & 31 deletions gateway/src/adapters/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,45 +90,138 @@ pub async fn webhook(
let Some(ref msg) = event.message else {
continue;
};
if msg.message_type != "text" {

let mut attachments = vec![];
let mut text = msg.text.clone().unwrap_or_default();

// Handle Image/Audio attachments (Issue #690 Phase 1)
if let Some(ref access_token) = state.line_access_token {
if msg.message_type == "image" || msg.message_type == "audio" {
let url = format!("https://api-data.line.me/v2/bot/message/{}/content", msg.id);
let client = reqwest::Client::new();
let resp = client.get(url).bearer_auth(access_token).send().await;

if let Ok(r) = resp {
if !r.status().is_success() {
warn!(status = %r.status(), id = %msg.id, "failed to download LINE media");
continue;
}
// Issue #690 review fix: Check file size before downloading
let content_length = r
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

if content_length > state.media_max_file_size {
warn!(
size = content_length,
max = state.media_max_file_size,
id = %msg.id,
"LINE media too large, skipping"
);
continue;
}

let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if msg.message_type == "image" {
"image/jpeg"
} else {
"audio/x-m4a"
})
.to_string();

if let Ok(data) = r.bytes().await {
let uuid = uuid::Uuid::new_v4().to_string();
let size = data.len() as u64;
let proxied = {
let mut store =
state.media_store.lock().unwrap_or_else(|e| e.into_inner());
if store.len() >= state.media_max_entries {
warn!(
size = store.len(),
"media store full, skipping LINE media proxy"
);
false
} else {
store.insert(
uuid.clone(),
(data.to_vec(), mime.clone(), std::time::Instant::now()),
);
true
}
};
if proxied {
attachments.push(Attachment {
attachment_type: msg.message_type.clone(),
url: Some(format!("{}/media/{}", state.public_url, uuid)),
mime_type: Some(mime),
filename: Some(format!(
"line-{}.{}",
msg.id,
if msg.message_type == "image" {
"jpg"
} else {
"m4a"
}
)),
size: Some(size),
data: None,
});
if text.is_empty() {
text = format!("[{}]", msg.message_type);
}
info!(id = %msg.id, uuid = %uuid, "proxied LINE inbound media");
}
}
}
} else if msg.message_type != "text" {
// Issue #690 review fix: Warn when media message is dropped due to missing access_token
warn!(
msg_type = %msg.message_type,
"LINE media message dropped (access_token not configured)"
);
Comment on lines +182 to +187
continue;
}
} else if msg.message_type != "text" {
warn!(
msg_type = %msg.message_type,
"LINE media message dropped (access_token not configured)"
);
continue;
}
let Some(ref text) = msg.text else {
continue;
};
if text.trim().is_empty() {

if text.trim().is_empty() && attachments.is_empty() {
continue;
}

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 +231,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 +244,11 @@ pub async fn webhook(
display_name: user_id.into(),
is_bot: false,
},
text,
&text,
&msg.id,
vec![],
);
gateway_event.attachments = attachments;

// Cache the reply token for hybrid Reply/Push dispatch
if let Some(ref reply_token) = event.reply_token {
Expand Down
Loading
Loading