Skip to content
Merged
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
78 changes: 78 additions & 0 deletions crates/openab-core/src/ambient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,12 @@ impl AmbientDispatcher {
.filter_map(|s| s.parse().ok())
.collect();
let flush_semaphore = Arc::new(Semaphore::new(config.max_concurrent_flushes.max(1)));
if config.enabled && !enabled_channels.is_empty() {
tracing::info!(
channels = ?enabled_channels,
"ambient: thread observation is default-on for configured channels"
);
}
Self {
config,
channels: Mutex::new(HashMap::new()),
Expand All @@ -286,6 +292,37 @@ impl AmbientDispatcher {
self.config.enabled && !self.enabled_channels.is_empty() && self.enabled_channels.contains(&channel_id)
}

/// Decide whether a message should be ambient-buffered.
///
/// - Top-level message directly in an ambient channel → yes (buffer keyed by
/// `channel_id`).
/// - Thread message → yes when the thread's `parent_id` is an ambient channel,
/// regardless of whether the bot owns the thread. Bot-owned threads are
/// also observed so the bot can passively follow conversation without
/// requiring an @mention. An @mention in any ambient context discards the
/// buffer and falls through to immediate dispatch — no double handling.
/// Threads are batched independently (keyed by the thread's `channel_id`).
///
/// Threads under an ambient channel are observed by default — most OpenAB
/// conversation happens in auto-created threads, not the parent channel.
///
/// Returns false when ambient is disabled or no channels are configured.
pub fn should_buffer(
&self,
channel_id: u64,
in_thread: bool,
_bot_owns_thread: bool,
parent_id: Option<u64>,
) -> bool {
if !self.config.enabled || self.enabled_channels.is_empty() {
return false;
}
if !in_thread {
return self.enabled_channels.contains(&channel_id);
}
parent_id.is_some_and(|p| self.enabled_channels.contains(&p))
}

/// Whether bot messages are allowed in the ambient buffer.
pub fn allow_bot_messages(&self) -> bool {
self.config.discord.allow_bot_messages
Expand Down Expand Up @@ -584,6 +621,47 @@ pub fn is_no_reply(response: &str) -> bool {
mod tests {
use super::*;

fn dispatcher(channels: &[&str], enabled: bool) -> AmbientDispatcher {
let config = AmbientConfig {
enabled,
discord: crate::config::AmbientDiscordConfig {
channels: channels.iter().map(|s| s.to_string()).collect(),
..Default::default()
},
..Default::default()
};
AmbientDispatcher::new(config)
}

#[test]
fn should_buffer_toplevel_message_in_ambient_channel() {
let d = dispatcher(&["100"], true);
assert!(d.should_buffer(100, false, false, None));
// Different channel → not buffered.
assert!(!d.should_buffer(200, false, false, None));
}

#[test]
fn should_buffer_thread_under_ambient_channel_by_default() {
let d = dispatcher(&["100"], true);
// Thread under an ambient channel, bot does not own it → buffer.
assert!(d.should_buffer(999, true, false, Some(100)));
// Thread whose parent is NOT an ambient channel → no.
assert!(!d.should_buffer(999, true, false, Some(200)));
// Thread the bot owns → ALSO buffered (bot passively observes its own
// threads; @mention triggers immediate dispatch with buffer discard).
assert!(d.should_buffer(999, true, true, Some(100)));
// Thread with no resolvable parent → no.
assert!(!d.should_buffer(999, true, false, None));
}

#[test]
fn should_buffer_false_when_ambient_disabled() {
let d = dispatcher(&["100"], false);
assert!(!d.should_buffer(100, false, false, None));
assert!(!d.should_buffer(999, true, false, Some(100)));
}

#[test]
fn is_no_reply_exact() {
assert!(is_no_reply("[NO_REPLY]"));
Expand Down
42 changes: 28 additions & 14 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,16 +551,18 @@ impl EventHandler for Handler {
// Thread detection: single to_channel() call for both allowed and
// non-allowed channels. Uses thread_metadata (not parent_id) to
// identify threads — see detect_thread() doc comments for rationale.
let (in_thread, bot_owns_thread, thread_parent_id, is_dm) = match msg
let (in_thread, bot_owns_thread, thread_parent_id, is_dm, is_structural_thread, structural_parent_id) = match msg
.channel_id
.to_channel(&ctx.http)
.await
{
Ok(serenity::model::channel::Channel::Guild(gc)) => {
let parent = gc.parent_id.map(|id| id.get().to_string());
let has_thread_metadata = gc.thread_metadata.is_some();
let parent_u64 = gc.parent_id.map(|id| id.get());
let result = detect_thread(
gc.thread_metadata.is_some(),
gc.parent_id.map(|id| id.get()),
has_thread_metadata,
parent_u64,
gc.owner_id.map(|id| id.get()),
bot_id.get(),
&self.allowed_channels,
Expand All @@ -571,29 +573,31 @@ impl EventHandler for Handler {
channel_id = %msg.channel_id,
parent_id = ?gc.parent_id,
owner_id = ?gc.owner_id,
has_thread_metadata = gc.thread_metadata.is_some(),
has_thread_metadata,
in_thread = result.0,
bot_owns = ?result.1,
"thread check"
);
(
result.0,
result.1.unwrap_or(false),
if result.0 { parent } else { None },
if has_thread_metadata { parent } else { None },
false,
has_thread_metadata,
if has_thread_metadata { parent_u64 } else { None },
)
}
Ok(serenity::model::channel::Channel::Private(_)) => {
tracing::debug!(channel_id = %msg.channel_id, "DM channel");
(false, false, None, true)
(false, false, None, true, false, None)
}
Ok(other) => {
tracing::debug!(channel_id = %msg.channel_id, kind = ?other, "not a guild thread");
(false, false, None, false)
(false, false, None, false, false, None)
}
Err(e) => {
tracing::debug!(channel_id = %msg.channel_id, error = %e, "to_channel failed");
(false, false, None, false)
(false, false, None, false, false, None)
}
};

Expand All @@ -603,16 +607,26 @@ impl EventHandler for Handler {
return;
}

if !is_dm && !in_allowed_channel && !in_thread {
// Check if message is in an ambient context (needed before early return).
// Uses structural thread detection (thread_metadata) decoupled from the
// normal dispatch allowlist, so ambient-only channels work correctly.
let in_ambient_context = self.ambient.as_ref().is_some_and(|ambient| {
ambient.should_buffer(channel_id, is_structural_thread, bot_owns_thread, structural_parent_id)
});

if !is_dm && !in_allowed_channel && !in_thread && !in_ambient_context {
return;
}

// --- Ambient Mode routing ---
// If the message is in an ambient-enabled channel, NOT a @mention,
// NOT in a thread, and NOT a DM → route to ambient dispatcher.
// @mention in an ambient channel → discard buffer + normal dispatch.
if let Some(ref ambient) = self.ambient {
if ambient.is_ambient_channel(channel_id) && !in_thread && !is_dm {
// Route to ambient when the message belongs to an ambient context:
// - a top-level message directly in an ambient channel, or
// - a message in a thread under an ambient channel (including
// bot-owned threads — the bot passively observes all threads).
// @mention in an ambient context → discard buffer + normal dispatch.
if in_ambient_context {
let ambient = self.ambient.as_ref().unwrap();
if !is_dm {
if is_mentioned {
// Discard ambient buffer — mention takes priority.
ambient.discard_buffer(&channel_id.to_string()).await;
Expand Down
15 changes: 14 additions & 1 deletion docs/ambient.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ max_concurrent_flushes = 3 # Global LLM concurrency limit
flush_timeout_seconds = 120 # Safety timeout per flush

[ambient.discord]
channels = ["1234567890"] # Channel IDs to monitor
channels = ["1234567890"] # Channel IDs to monitor (and their threads)
allow_bot_messages = false # Include other bots' messages in buffer
```

Expand All @@ -40,6 +40,19 @@ allow_bot_messages = false # Include other bots' messages in buffer
| `channels` | `[]` | Explicit channel allowlist (required). Empty = ambient disabled. |
| `allow_bot_messages` | `false` | Whether other bots' messages enter the ambient buffer. |

> **Threads are observed by default.** Messages in **threads** whose parent is a configured channel are buffered too (most OpenAB conversation happens in auto-created threads, not the parent channel). **Both** bot-owned and non-owned threads are observed — the bot passively follows all thread conversation under an ambient channel. An @mention in any thread discards its buffer and triggers immediate dispatch, so there is no double-reply. Each thread batches independently (keyed by the thread ID).

> **`[ambient.discord].channels` vs `[discord].allowed_channels`** — these are independent allowlists with an OR relationship:
>
> | Config | Purpose | Effect |
> |--------|---------|--------|
> | `[discord].allowed_channels` | Normal dispatch | Bot responds to @mentions and direct messages in these channels/threads |
> | `[ambient.discord].channels` | Passive observation | Bot silently buffers messages (no @mention required) and decides whether to reply |
>
> A channel can appear in one or both. Ambient observation does **not** require the channel to also be in `allowed_channels`.
>
> **@mention always works in ambient channels.** Even if a channel is only in `[ambient.discord].channels` (not in `allowed_channels`), an @mention still triggers immediate normal dispatch — the ambient buffer is discarded and the bot responds directly. The only difference between the two configs is behavior **without** a mention: `allowed_channels` ignores unmentioned messages entirely, while `ambient.discord.channels` passively observes them.

### Reserved fields (v2, not yet enforced)

| Field | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ session_ttl_minutes = 60
context_flushes = 3

[ambient.discord]
channels = [] # Channel ID allowlist (required)
channels = [] # Channel ID allowlist — and their threads (required)
allow_bot_messages = false
```

Expand Down
2 changes: 1 addition & 1 deletion docs/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Ambient mode allows the bot to passively listen to configured channels and respo
enabled = true

[ambient.discord]
channels = ["1234567890"] # Channel IDs to monitor
channels = ["1234567890"] # Channel IDs to monitor (and their threads)
```

When enabled:
Expand Down
Loading