Skip to content

Commit cab50a0

Browse files
committed
feat: download-to-disk for unhandled inbound file attachments
All inbound files that aren't image/audio/text (PDF, docx, video, etc.) are now downloaded to local disk and a metadata block is injected into the agent prompt with the file path, allowing agents to read files using their own tools. - Add download_to_disk() and store_to_disk() to src/media.rs - Add TTL-based eviction loop (1 hour default) - Update Discord, Slack adapters to call download_to_disk - Update Gateway to handle document attachment type - Update Telegram, Feishu, WeChat, Google Chat adapters to accept binary files - Add 12 unit tests (filename sanitization, download scenarios) Closes #738
1 parent 0269b3b commit cab50a0

11 files changed

Lines changed: 512 additions & 103 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,6 @@ tempfile = "3.27.0"
3535

3636
[target.'cfg(unix)'.dependencies]
3737
libc = "0.2"
38+
39+
[dev-dependencies]
40+
mockito = "1"

gateway/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gateway/src/adapters/feishu.rs

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,7 +1374,7 @@ pub enum MediaRef {
13741374
const IMAGE_MAX_DIMENSION_PX: u32 = 1200;
13751375
const IMAGE_JPEG_QUALITY: u8 = 75;
13761376
const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB
1377-
const FILE_MAX_DOWNLOAD: u64 = 512 * 1024; // 512 KB
1377+
const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; // 20 MB
13781378

13791379
/// Resize image so longest side <= 1200px, then encode as JPEG.
13801380
/// GIFs are passed through unchanged to preserve animation.
@@ -1470,17 +1470,6 @@ pub async fn download_feishu_file(
14701470
file_key: &str,
14711471
file_name: &str,
14721472
) -> Option<crate::schema::Attachment> {
1473-
// Only download text-like files
1474-
let ext = file_name.rsplit('.').next().unwrap_or("").to_lowercase();
1475-
const TEXT_EXTS: &[&str] = &[
1476-
"txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml",
1477-
"rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp",
1478-
"rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf", "env",
1479-
];
1480-
if !TEXT_EXTS.contains(&ext.as_str()) {
1481-
tracing::debug!(file_name, "skipping non-text file attachment");
1482-
return None;
1483-
}
14841473
let url = format!(
14851474
"{}/open-apis/im/v1/messages/{}/resources/{}?type=file",
14861475
api_base, message_id, file_key
@@ -1508,14 +1497,24 @@ pub async fn download_feishu_file(
15081497
let bytes = resp.bytes().await.ok()?;
15091498
// Fallback check (Content-Length may be absent or misreported)
15101499
if bytes.len() as u64 > FILE_MAX_DOWNLOAD {
1511-
tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds 512KB limit");
1500+
tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds limit");
15121501
return None;
15131502
}
15141503
let path = crate::store::store_media(&bytes).await?;
1504+
1505+
// Determine attachment type: text_file if recognized text extension and valid UTF-8
1506+
let att_type = if crate::media::is_text_extension(file_name) && String::from_utf8(bytes.to_vec()).is_ok() {
1507+
"text_file"
1508+
} else {
1509+
"document"
1510+
};
1511+
let mime = if att_type == "text_file" { "text/plain" } else { "application/octet-stream" };
1512+
1513+
tracing::info!(file_name, size = bytes.len(), att_type, "feishu file stored");
15151514
Some(crate::schema::Attachment {
1516-
attachment_type: "text_file".into(),
1515+
attachment_type: att_type.into(),
15171516
filename: file_name.to_string(),
1518-
mime_type: "text/plain".into(),
1517+
mime_type: mime.into(),
15191518
data: String::new(),
15201519
size: bytes.len() as u64,
15211520
path: Some(path),

gateway/src/adapters/googlechat.rs

Lines changed: 28 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,11 @@ const TEXT_FILE_COUNT_CAP: usize = 5;
2525
/// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB).
2626
const TEXT_TOTAL_CAP: u64 = 1024 * 1024;
2727

28-
// --- Google Chat types ---
29-
//
30-
// Google Chat delivers webhooks in two shapes depending on the App's
31-
// Connection settings in the Cloud Console:
32-
// - HTTP endpoint URL mode: top-level fields (message, user, space, ...)
33-
// - Pub/Sub mode: wrapped under `chat.messagePayload`
34-
// Both are supported via the optional fields below; the handler prefers
35-
// the wrapped form and falls back to top-level when `chat` is absent.
28+
// --- Google Chat types (v2 envelope format) ---
3629

3730
#[derive(Debug, Deserialize)]
3831
pub struct GoogleChatEnvelope {
3932
pub chat: Option<ChatPayload>,
40-
// HTTP endpoint URL top-level fields (used when `chat` is None)
41-
pub message: Option<GoogleChatMessage>,
42-
pub user: Option<GoogleChatUser>,
43-
pub space: Option<GoogleChatSpace>,
4433
}
4534

4635
#[derive(Debug, Deserialize)]
@@ -143,20 +132,20 @@ pub struct GoogleChatSpace {
143132

144133
const GOOGLE_CHAT_ISSUER: &str = "https://accounts.google.com";
145134
const GOOGLE_CHAT_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs";
146-
const GOOGLE_CHAT_SIGNER_EMAIL: &str = "chat@system.gserviceaccount.com";
135+
const GOOGLE_CHAT_EMAIL_SUFFIX: &str = "@gcp-sa-gsuiteaddons.iam.gserviceaccount.com";
147136
const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600);
148137

149-
/// Verify the JWT's `email` claim belongs to Google Chat.
150-
/// HTTP endpoint URL webhooks are signed by `chat@system.gserviceaccount.com`.
138+
/// Verify the JWT's `email` claim belongs to a Google Chat service account.
139+
/// Google Chat webhooks use `service-{PROJECT_NUMBER}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`.
151140
/// Without this check, any Google-issued ID token would be accepted.
152141
fn verify_email_claim(claims: &serde_json::Value) -> Result<(), String> {
153142
let email = claims
154143
.get("email")
155144
.and_then(|v| v.as_str())
156145
.ok_or("missing email claim")?;
157-
if email != GOOGLE_CHAT_SIGNER_EMAIL {
146+
if !email.ends_with(GOOGLE_CHAT_EMAIL_SUFFIX) {
158147
return Err(format!(
159-
"email claim mismatch: expected {GOOGLE_CHAT_SIGNER_EMAIL}, got {email}"
148+
"email claim mismatch: expected *{GOOGLE_CHAT_EMAIL_SUFFIX}, got {email}"
160149
));
161150
}
162151
Ok(())
@@ -495,20 +484,13 @@ pub async fn webhook(
495484
}
496485
};
497486

498-
// Try the Pub/Sub `chat`-wrapped shape first, then fall back to the
499-
// HTTP endpoint URL top-level shape.
500-
let (msg_opt, top_user, top_space) = if let Some(chat) = envelope.chat {
501-
let user = chat.user;
502-
let (msg, space) = match chat.message_payload {
503-
Some(p) => (p.message, p.space),
504-
None => (None, None),
505-
};
506-
(msg, user, space)
507-
} else {
508-
(envelope.message, envelope.user, envelope.space)
487+
let Some(chat) = envelope.chat else {
488+
return empty_json_response();
509489
};
510-
511-
let Some(ref msg) = msg_opt else {
490+
let Some(payload) = chat.message_payload else {
491+
return empty_json_response();
492+
};
493+
let Some(ref msg) = payload.message else {
512494
return empty_json_response();
513495
};
514496

@@ -525,8 +507,8 @@ pub async fn webhook(
525507
return empty_json_response();
526508
}
527509

528-
let sender = msg.sender.as_ref().or(top_user.as_ref());
529-
let space = msg.space.as_ref().or(top_space.as_ref());
510+
let sender = msg.sender.as_ref().or(chat.user.as_ref());
511+
let space = msg.space.as_ref().or(payload.space.as_ref());
530512

531513
let is_bot = sender.map(|s| s.user_type == "BOT").unwrap_or(false);
532514
if is_bot {
@@ -1297,11 +1279,8 @@ pub async fn download_googlechat_file(
12971279
remaining_budget: u64,
12981280
) -> Option<crate::schema::Attachment> {
12991281
let ext = content_name.rsplit('.').next().unwrap_or("").to_lowercase();
1300-
if !TEXT_EXTS.contains(&ext.as_str()) {
1301-
tracing::debug!(content_name, "skipping non-text googlechat file attachment");
1302-
return None;
1303-
}
1304-
let max_size = FILE_MAX_DOWNLOAD.min(remaining_budget);
1282+
let is_text = TEXT_EXTS.contains(&ext.as_str());
1283+
let max_size = if is_text { FILE_MAX_DOWNLOAD.min(remaining_budget) } else { remaining_budget.min(20 * 1024 * 1024) };
13051284
let url = media_url(api_base, resource_name);
13061285
let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await {
13071286
Ok(r) => r,
@@ -1328,10 +1307,18 @@ pub async fn download_googlechat_file(
13281307
return None;
13291308
}
13301309
let path = crate::store::store_media(&bytes).await?;
1310+
1311+
let att_type = if is_text && String::from_utf8(bytes.to_vec()).is_ok() {
1312+
"text_file"
1313+
} else {
1314+
"document"
1315+
};
1316+
let mime = if att_type == "text_file" { "text/plain" } else { "application/octet-stream" };
1317+
13311318
Some(crate::schema::Attachment {
1332-
attachment_type: "text_file".into(),
1319+
attachment_type: att_type.into(),
13331320
filename: content_name.to_string(),
1334-
mime_type: "text/plain".into(),
1321+
mime_type: mime.into(),
13351322
data: String::new(),
13361323
size: bytes.len() as u64,
13371324
path: Some(path),
@@ -1659,8 +1646,8 @@ mod tests {
16591646
}
16601647

16611648
#[test]
1662-
fn email_claim_accepts_chat_system_account() {
1663-
let claims = serde_json::json!({"email": "chat@system.gserviceaccount.com"});
1649+
fn email_claim_accepts_gsuite_addons_account() {
1650+
let claims = serde_json::json!({"email": "service-123456@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"});
16641651
assert!(verify_email_claim(&claims).is_ok());
16651652
}
16661653

@@ -2439,32 +2426,4 @@ mod tests {
24392426
.await;
24402427
assert!(result.is_none(), "oversized image must be rejected");
24412428
}
2442-
2443-
#[test]
2444-
fn parses_http_endpoint_url_top_level_envelope() {
2445-
let envelope: GoogleChatEnvelope = serde_json::from_value(serde_json::json!({
2446-
"message": {
2447-
"name": "spaces/AAAA/messages/BBBB",
2448-
"text": "hello",
2449-
"attachment": []
2450-
},
2451-
"user": {
2452-
"name": "users/123",
2453-
"displayName": "Test User",
2454-
"type": "HUMAN"
2455-
},
2456-
"space": {
2457-
"name": "spaces/AAAA",
2458-
"type": "DM"
2459-
}
2460-
}))
2461-
.unwrap();
2462-
assert!(envelope.chat.is_none());
2463-
assert!(envelope.message.is_some());
2464-
assert_eq!(envelope.message.unwrap().name, "spaces/AAAA/messages/BBBB");
2465-
assert!(envelope.user.is_some());
2466-
assert_eq!(envelope.user.unwrap().name, "users/123");
2467-
assert!(envelope.space.is_some());
2468-
assert_eq!(envelope.space.unwrap().name, "spaces/AAAA");
2469-
}
24702429
}

gateway/src/adapters/telegram.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -426,19 +426,16 @@ async fn download_telegram_media(
426426
})
427427
}
428428

429-
/// Download text document from Telegram → store to filesystem.
429+
/// Download document from Telegram → store to filesystem.
430+
/// Supports both text and binary files. Text files get attachment_type "text_file",
431+
/// binary files get "document".
430432
async fn download_telegram_document(
431433
client: &reqwest::Client,
432434
bot_token: &str,
433435
file_id: &str,
434436
file_name: &str,
435437
mime_type: &str,
436438
) -> Option<Attachment> {
437-
if !crate::media::is_text_extension(file_name) {
438-
tracing::debug!(file_name, "skipping non-text file attachment");
439-
return None;
440-
}
441-
442439
let get_file_url = format!("{TELEGRAM_API_BASE}/bot{}/getFile", bot_token);
443440
let resp = client.get(&get_file_url).query(&[("file_id", file_id)]).send().await.ok()?;
444441
let body: serde_json::Value = resp.json().await.ok()?;
@@ -465,17 +462,19 @@ async fn download_telegram_document(
465462
return None;
466463
}
467464

468-
// Validate UTF-8 — reject binary files
469-
if String::from_utf8(bytes.to_vec()).is_err() {
470-
warn!(file_id, file_name, "Telegram document is not valid UTF-8, skipping");
471-
return None;
472-
}
473-
474465
let path = store::store_media(&bytes).await?;
475-
info!(file_id, file_name, size = bytes.len(), "Telegram document stored");
466+
467+
// Determine attachment type: text_file if it's a recognized text format and valid UTF-8
468+
let att_type = if crate::media::is_text_extension(file_name) && String::from_utf8(bytes.to_vec()).is_ok() {
469+
"text_file"
470+
} else {
471+
"document"
472+
};
473+
474+
info!(file_id, file_name, size = bytes.len(), att_type, "Telegram document stored");
476475

477476
Some(Attachment {
478-
attachment_type: "text_file".into(),
477+
attachment_type: att_type.into(),
479478
filename: file_name.to_string(),
480479
mime_type: mime_type.to_string(),
481480
data: String::new(),

gateway/src/adapters/wecom.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,8 +1261,17 @@ async fn download_wecom_file(
12611261
}
12621262

12631263
if !is_text_file(filename) {
1264-
info!(filename, "wecom: skipping non-text file");
1265-
return None;
1264+
// Binary file — store as document
1265+
let path = crate::store::store_media(&bytes).await?;
1266+
info!(filename, size = bytes.len(), "wecom: binary file stored as document");
1267+
return Some(crate::schema::Attachment {
1268+
attachment_type: "document".into(),
1269+
filename: filename.to_string(),
1270+
mime_type: "application/octet-stream".into(),
1271+
data: String::new(),
1272+
size: bytes.len() as u64,
1273+
path: Some(path),
1274+
});
12661275
}
12671276

12681277
let text_content = match String::from_utf8(bytes.to_vec()) {

src/discord.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,16 @@ impl EventHandler for Handler {
785785
u64::from(attachment.size),
786786
&attachment.url,
787787
));
788+
} else if let Some(block) = media::download_to_disk(
789+
&attachment.url,
790+
&attachment.filename,
791+
attachment.content_type.as_deref().unwrap_or("application/octet-stream"),
792+
u64::from(attachment.size),
793+
&msg.id.to_string(),
794+
None,
795+
).await {
796+
debug!(filename = %attachment.filename, "file saved to disk");
797+
extra_blocks.push(block);
788798
}
789799
}
790800
Err(e) => {

src/gateway.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -785,7 +785,29 @@ pub async fn run_gateway_adapter(
785785
"audio" => {
786786
tracing::debug!(filename = %att.filename, "audio attachment skipped — STT not enabled");
787787
}
788-
_ => {}
788+
// document / unknown types: store to disk
789+
_ => {
790+
let bytes_result = if let Some(ref path) = att.path {
791+
tokio::fs::read(path).await.map_err(|e| e.to_string())
792+
} else if !att.data.is_empty() {
793+
use base64::Engine;
794+
base64::engine::general_purpose::STANDARD
795+
.decode(&att.data)
796+
.map_err(|e| e.to_string())
797+
} else {
798+
Err("no path or data".into())
799+
};
800+
if let Ok(bytes) = bytes_result {
801+
if let Some(block) = crate::media::store_to_disk(
802+
&bytes,
803+
&att.filename,
804+
&att.mime_type,
805+
&event.message_id,
806+
).await {
807+
extra_blocks.push(block);
808+
}
809+
}
810+
}
789811
}
790812
}
791813

src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ async fn main() -> anyhow::Result<()> {
187187
}
188188
});
189189

190+
// Spawn attachments eviction loop (download-to-disk cleanup)
191+
tokio::spawn(media::attachments_eviction_loop());
192+
190193
// Pre-build shared adapters for cron scheduler (avoids duplicate Http clients / rate-limit buckets)
191194
let shared_discord_adapter: Option<Arc<dyn adapter::ChatAdapter>> =
192195
cfg.discord.as_ref().map(|dc| {

0 commit comments

Comments
 (0)