Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0d33ead
feat: pass audio attachments through to the agent
ShinyChang Jul 27, 2026
1272fe4
feat: give Slack video a fetchable URL via filestore
ShinyChang Jul 27, 2026
38c31de
chore: drop the unrelated Cargo.lock refresh
ShinyChang Jul 28, 2026
cd38142
refactor: pair the presigned URL with its note in one place
ShinyChang Jul 28, 2026
cdba4a1
fix: serve gateway audio with its real content type
ShinyChang Jul 29, 2026
44ff6a3
fix: keep each transcript next to the file it came from
ShinyChang Jul 29, 2026
c80593d
fix: clamp presigned_ttl to a one-minute floor
ShinyChang Jul 29, 2026
30e5969
docs: record the audio count bound and the Slack URL caveat
ShinyChang Jul 29, 2026
af5640e
fix: classify audio by extension when the platform omits the MIME
ShinyChang Jul 29, 2026
3dc4014
refactor: route both gateway audio paths through one outcome seam
ShinyChang Jul 29, 2026
dbef0e6
docs: correct the storage matrix to match what the adapters do
ShinyChang Jul 29, 2026
24f7d5d
refactor: collapse the gateway's two audio arms into one
ShinyChang Jul 29, 2026
e8fdb2e
fix: stop reporting a failed filestore as a missing one
ShinyChang Jul 29, 2026
c0119d1
docs: sync the remaining audio and filestore contracts
ShinyChang Jul 29, 2026
198cf2a
fix: keep an explicit non-audio MIME out of the audio path
ShinyChang Jul 29, 2026
4fef49d
fix: strip the Unicode separators that is_control leaves behind
ShinyChang Jul 29, 2026
86e1eb9
fix: tell Slack apart from a filestore that failed
ShinyChang Jul 29, 2026
d9cd754
docs: state the effective 20 MB gateway audio limit
ShinyChang Jul 29, 2026
f054f13
fix: stop one error variant from mislabelling three different failures
ShinyChang Jul 29, 2026
200f4b5
fix(media): stop a cased MIME from suppressing the audio fallback
ShinyChang Jul 29, 2026
5276f10
test(media): make three assertions check what they claim to check
ShinyChang Jul 29, 2026
f7bb32b
docs: reconcile the audio contracts this branch left disagreeing
ShinyChang Jul 29, 2026
ea5d6c2
fix(filestore): render a short presigned TTL instead of lengthening it
ShinyChang Jul 30, 2026
a44e8ea
refactor(filestore): keep the public upload API compatible and the re…
ShinyChang Jul 30, 2026
93b4e38
fix(media): tell the agent the truth about a store that failed
ShinyChang Jul 30, 2026
471cd64
docs: restate the voice-only contracts this branch changed
ShinyChang Jul 30, 2026
343ed45
fix(media): name the component a failed transfer actually blames
ShinyChang Jul 30, 2026
1a1db5e
docs: state the zero-second presigned_ttl exception
ShinyChang Jul 30, 2026
da9890e
fix(gateway): keep object storage off the WebSocket receive path
ShinyChang Jul 30, 2026
e39259a
fix(media): require a real extension before classifying audio by name
ShinyChang Jul 30, 2026
a03036a
docs(discord): put the turn-limit doc back above its own constant
ShinyChang Jul 30, 2026
ebba458
fix(gateway): hold receipt order and reset boundaries across delayed …
ShinyChang Jul 30, 2026
8c4b076
Merge upstream/main into feat/audio-passthrough
ShinyChang Jul 30, 2026
487e59d
fix(gateway): fence the dispatcher handoff against a concurrent reset
ShinyChang Jul 30, 2026
9c83db9
fix(gateway): cancel discarded preparation and read sources before qu…
ShinyChang Jul 30, 2026
7c0bc5f
fix(gateway): charge the source budget for real bytes and sanitize th…
ShinyChang Jul 30, 2026
6e07f6f
fix(gateway): keep the attachment budget valid through assembly and q…
ShinyChang Jul 30, 2026
0587c93
fix(gateway): release shed payloads and charge what each path really …
ShinyChang Jul 30, 2026
a90bb6c
fix(gateway): account for inline input and bound how much work is adm…
ShinyChang Jul 30, 2026
f3946d7
fix(gateway): give unified ingress the same limits and charge rendere…
ShinyChang Jul 31, 2026
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
166 changes: 116 additions & 50 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ use std::sync::LazyLock;
use std::sync::{Arc, OnceLock};
use tracing::{debug, error, info, warn};

/// Named so a test can pin it: the agent fetches this link unaided, which is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 - Reattach the constant documentation

The existing first two doc-comment lines describe the consecutive-bot-turn guard, but these new lines extend that block so it now documents DISCORD_CDN_NOTE; MAX_CONSECUTIVE_BOT_TURNS has no documentation.

Requested change: keep only the CDN explanation above DISCORD_CDN_NOTE and move the turn-limit explanation directly above MAX_CONSECUTIVE_BOT_TURNS.

/// what makes a size or upload failure safe to report without a store note.
pub(crate) const DISCORD_CDN_NOTE: &str = "Discord CDN URL, expires ~24h";

/// Hard cap on consecutive bot messages in a channel or thread.
/// Prevents runaway loops between multiple bots in "all" mode.
const MAX_CONSECUTIVE_BOT_TURNS: u32 = 1000;
Expand Down Expand Up @@ -872,10 +876,12 @@ impl EventHandler for Handler {
const TEXT_FILE_COUNT_CAP: u32 = 5;

for attachment in &msg.attachments {
let mime = attachment.content_type.as_deref().unwrap_or("");
if media::is_audio_mime(mime) {
if let Some(mime_clean) =
media::audio_mime(&attachment.filename, attachment.content_type.as_deref())
{
let mime_clean = mime_clean.as_str();
let mut stt_line: Option<String> = None;
if self.stt_config.enabled {
let mime_clean = mime.split(';').next().unwrap_or(mime).trim();
match media::download_and_transcribe(
&attachment.url,
&attachment.filename,
Expand All @@ -888,12 +894,7 @@ impl EventHandler for Handler {
{
Some(transcript) => {
debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected");
extra_blocks.insert(
0,
ContentBlock::Text {
text: format!("[Voice message transcript]: {transcript}"),
},
);
stt_line = Some(format!("[Voice message transcript]: {transcript}"));
echo_entries.push(crate::stt::EchoEntry::Success(transcript));
}
None => {
Expand All @@ -902,10 +903,47 @@ impl EventHandler for Handler {
}
}
} else {
tracing::warn!(filename = %attachment.filename, "skipping audio attachment (STT disabled)");
debug!(filename = %attachment.filename, "audio attachment not transcribed (STT disabled)");
let msg_ref = discord_msg_ref(&msg);
let _ = adapter.add_reaction(&msg_ref, "🎤").await;
}

// Passthrough runs whichever way STT went: a transcript is an
// extra block, never a substitute for the file itself.
#[cfg(feature = "filestore")]
let stored = match self.filestore {
Some(ref fs) => Some(
media::download_and_presign_attachment(
&attachment.url,
&attachment.filename,
u64::from(attachment.size),
Some(mime_clean),
None,
fs,
)
.await,
),
None => None,
};
#[cfg(not(feature = "filestore"))]
let stored: Option<media::StoredAttachmentResult> = None;

let (url, note, size) = media::attachment_url_note_size(
stored.as_ref(),
attachment.url.as_str(),
u64::from(attachment.size),
media::PlatformUrl::Fetchable {
note: DISCORD_CDN_NOTE,
},
);
extra_blocks.extend(media::audio_attachment_blocks(
&attachment.filename,
mime_clean,
size,
Some(url),
Some(&note),
stt_line.as_deref(),
));
} else if media::is_text_file(&attachment.filename, attachment.content_type.as_deref())
{
if text_file_count >= TEXT_FILE_COUNT_CAP {
Expand Down Expand Up @@ -961,11 +999,15 @@ impl EventHandler for Handler {
Ok(block) => {
debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment");
extra_blocks.push(block);
let (safe_filename, safe_mime) = media::sanitize_attachment_meta(
&attachment.filename,
attachment.content_type.as_deref().unwrap_or("unknown"),
);
extra_blocks.push(ContentBlock::Text {
text: format!(
"[Image attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {} (expires ~24h)",
attachment.filename,
attachment.content_type.as_deref().unwrap_or("unknown"),
safe_filename,
safe_mime,
attachment.size,
attachment.url,
),
Expand All @@ -977,11 +1019,13 @@ impl EventHandler for Handler {
attachment.content_type.as_deref(),
) {
debug!(url = %attachment.url, filename = %attachment.filename, "adding video attachment link");
extra_blocks.push(video_attachment_block(
// Discord CDN links need no credentials, so no note is warranted.
extra_blocks.push(media::video_attachment_block(
&attachment.filename,
attachment.content_type.as_deref(),
u64::from(attachment.size),
&attachment.url,
None,
));
}
// For all other unsupported formats (PDF, ZIP, binary, etc.):
Expand Down Expand Up @@ -3006,23 +3050,6 @@ fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet<u6
out.trim().to_string()
}

fn video_attachment_block(
filename: &str,
content_type: Option<&str>,
size: u64,
url: &str,
) -> ContentBlock {
ContentBlock::Text {
text: format!(
"[Video attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {}",
filename,
content_type.unwrap_or("unknown"),
size,
url
),
}
}

/// Build a `SenderContext` for Discord messages.
///
/// Pure function extracted from `EventHandler::message` for testability.
Expand Down Expand Up @@ -3267,6 +3294,65 @@ fn truncate_to_utf16_budget(body: &str, prefix: &str, suffix: &str, limit: usize

#[cfg(test)]
mod tests {

/// The four store outcomes this adapter can hand the agent. Pins the note
/// constant too: an Accepted Residual Risk rests on its exact wording.
#[test]
fn discord_renders_every_store_outcome_and_only_hides_a_safe_failure() {
use crate::media::{AudioStoreError, PlatformUrl, StoredAttachment};
// Pinned literally: the rows below compare against the constant, so an
// emptied or repurposed value would satisfy them tautologically.
assert_eq!(super::DISCORD_CDN_NOTE, "Discord CDN URL, expires ~24h");
let platform = PlatformUrl::Fetchable {
note: super::DISCORD_CDN_NOTE,
};
let call = |stored: Option<&Result<StoredAttachment, AudioStoreError>>| {
let (u, n, sz) = crate::media::attachment_url_note_size(
stored,
"https://platform.example/file",
1_024,
platform,
);
(u.to_string(), n, sz)
};

// Stored: the presigned url wins, and so does the count measured while
// streaming, because Discord's reported size is advisory.
let ok = Ok(StoredAttachment {
url: "https://s3.example/presigned".into(),
note: "presigned URL, expires in 60 minutes".into(),
measured_bytes: 5_242_880,
});
let (u, n, sz) = call(Some(&ok));
assert_eq!(u, "https://s3.example/presigned");
assert_eq!(n, "presigned URL, expires in 60 minutes");
assert_eq!(sz, 5_242_880, "the measured count must win over 1_024");

// No filestore configured at all.
let (u, n, sz) = call(None);
assert_eq!(u, "https://platform.example/file");
assert_eq!(n, super::DISCORD_CDN_NOTE);
assert_eq!(sz, 1_024);

for err in [AudioStoreError::TooLarge, AudioStoreError::UploadFailed] {
let (u, n, _) = call(Some(&Err(err)));
assert_eq!(u, "https://platform.example/file", "{err:?}");
assert_eq!(
n,
super::DISCORD_CDN_NOTE,
"the CDN link still works, so no store note: {err:?}"
);
}

// DownloadFailed is the one outcome produced only after the bot fetched
// this very url and failed, so it may never read as a working url.
let (u, n, _) = call(Some(&Err(AudioStoreError::DownloadFailed)));
assert_eq!(u, "https://platform.example/file");
assert!(
n.contains("did not return the bytes"),
"DownloadFailed must say the platform withheld the bytes: {n}"
);
}
use super::*;
use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT, BOT_TURN_LIMIT_WARNING_PREFIX};

Expand Down Expand Up @@ -3565,26 +3651,6 @@ mod tests {
assert_eq!(result, "check @(role)");
}

#[test]
fn video_attachment_block_includes_actionable_metadata() {
let block = video_attachment_block(
"demo.mp4",
Some("video/mp4"),
12345,
"https://cdn.discordapp.com/attachments/demo.mp4",
);

let ContentBlock::Text { text } = block else {
panic!("video attachments must be forwarded as text metadata");
};

assert!(text.contains("[Video attachment]"));
assert!(text.contains("filename: demo.mp4"));
assert!(text.contains("content_type: video/mp4"));
assert!(text.contains("size_bytes: 12345"));
assert!(text.contains("url: https://cdn.discordapp.com/attachments/demo.mp4"));
}

#[test]
fn image_attachment_block_includes_url_and_metadata() {
// Simulates the format string used in the image attachment handler.
Expand Down
Loading
Loading