Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c9c176b
feat(relay): accept @channel/@here notify tag and feed @channel menti…
LordMelkor Jul 27, 2026
e3ad574
fix(relay): make the mentions UNION dedupe on event columns alone (#3…
LordMelkor Jul 27, 2026
7702a2e
docs(test): note deferred execution of the NIP-CM e2e suite (#3146)
LordMelkor Jul 27, 2026
33e77b6
feat(cli): add --notify channel/here to messages send (#3146)
LordMelkor Jul 27, 2026
5319e52
feat(sdk,cli): channel-wide mention support for @channel/@here (#3146)
LordMelkor Jul 27, 2026
52783dc
feat(desktop): compose and send @channel/@here channel mentions (#3146)
LordMelkor Jul 27, 2026
cf9157e
feat(desktop): render and notify for @channel/@here mentions (#3146)
LordMelkor Jul 27, 2026
d388a74
docs(nips): add NIP-CM channel mentions spec (#3146)
LordMelkor Jul 27, 2026
40aea51
docs(nips): correct @here persistence and NIP-PL claims in NIP-CM (#3…
LordMelkor Jul 27, 2026
528a932
test(relay): drop the stale deferred-execution note on the NIP-CM e2e…
LordMelkor Jul 27, 2026
5e8c37e
test(desktop): add PR screenshot spec for @channel/@here mentions (#3…
LordMelkor Jul 27, 2026
4599592
fix(desktop): let direct mentions pierce channel mutes on both feed p…
LordMelkor Jul 27, 2026
d55af18
fix(relay): require channel membership to send @channel or @here (#3146)
LordMelkor Jul 27, 2026
36b205c
fix(desktop): carry notify tags through the edit tag overlay (#3146)
LordMelkor Jul 27, 2026
b4159d9
fix(relay): validate the notify tag before every early-return handler…
LordMelkor Jul 27, 2026
204fbbf
fix(desktop): dedupe live events delivered by two channel subscriptio…
LordMelkor Jul 27, 2026
2d163cb
fix(relay): gate the notify tag on WebSocket ephemeral events (#3146)
LordMelkor Jul 27, 2026
e839937
docs(nips): clarify that @here live-only means observation-time (#3146)
LordMelkor Jul 27, 2026
7d167bb
docs(db): record the single-index trade-off on the NIP-CM feed branch…
LordMelkor Jul 27, 2026
61c0b44
fix(sdk): mask tilde fences and multi-backtick spans when scanning me…
LordMelkor Jul 27, 2026
e50ff80
docs(desktop): clarify that @here live-only is observation-time (#3146)
LordMelkor Jul 27, 2026
fbb3a21
fix(db): register channel_notifications in the desired-state schema (…
LordMelkor Jul 27, 2026
733f1ef
test(db): drop count/index fragility from the embedded-migrator test …
LordMelkor Jul 27, 2026
cf5fd18
fix(db): don't backfill @channel mentions to members who joined later
LordMelkor Jul 28, 2026
52068eb
Merge remote-tracking branch 'origin/main' into feat/channel-mentions
LordMelkor Jul 28, 2026
585407f
fix(desktop,cli): request the bounded mentions feed for Inbox and fee…
LordMelkor Jul 28, 2026
4805337
fix(relay,cli,desktop): review round-2 — category label, CLI defaults…
LordMelkor Jul 28, 2026
47a5ffc
Merge remote-tracking branch 'origin/main' into feat/channel-mentions
LordMelkor Jul 28, 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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ reconnects preserve pending avatar verification work):
- `resetRenderScopedReactionHydration()` — reaction hydration cache
- `clearSearchHitEventCache()` — search result event cache
- `clearMarkdownNodeCache()` — markdown parse-node cache
- `resetSelfPresenceStatus()` — self presence signal read by the `@here` check

**If you add a new module-level cache, Map, or class instance that holds
community-scoped data, you must add its reset to `resetCommunityState()`.**
Expand Down
23 changes: 15 additions & 8 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3569,14 +3569,21 @@ pub(crate) async fn post_failure_notice(
parent_event_id: parent_id,
})
});
let builder =
match buzz_sdk::build_message(channel_id, content, thread_ref.as_ref(), &[], false, &[]) {
Ok(b) => b,
Err(e) => {
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");
return;
}
};
let builder = match buzz_sdk::build_message(
channel_id,
content,
thread_ref.as_ref(),
&[],
false,
None,
&[],
) {
Ok(b) => b,
Err(e) => {
tracing::warn!(channel = %channel_id, "failure notice: build failed: {e}");
return;
}
};
let event = match builder.sign_with_keys(&rest.keys) {
Ok(e) => e,
Err(e) => {
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ async fn publish_setup_nudge(
thread_ref.as_ref(),
&[&author_hex], // p-tag the asker
false,
None,
&[],
)
.map_err(|e| anyhow::anyhow!("failed to build setup nudge: {e}"))?;
Expand Down
2 changes: 2 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export BUZZ_RELAY_URL="https://relay.example.com"
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages send --channel <uuid> --content "deploy is done" --notify channel # @channel
buzz messages send --channel <uuid> --content "standup now" --notify here # @here
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages search --query "architecture"
Expand Down
125 changes: 106 additions & 19 deletions crates/buzz-cli/src/commands/feed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,31 @@ use crate::error::CliError;

const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"];

/// Get activity feed — query events mentioning our pubkey (via p-tag).
pub async fn cmd_get_feed(
client: &BuzzClient,
/// Feed types requested when `--types` is omitted.
///
/// Only the addressed-to-me feeds are on by default. `activity` (and its
/// `agent_activity` alias, which the relay canonicalizes to `activity`) is an
/// unbounded community firehose that would drown the mention and needs-action
/// rows an agent actually has to act on — request it explicitly with
/// `--types activity`.
const DEFAULT_FEED_TYPES: &[&str] = &["mentions", "needs_action"];

/// Build the `POST /query` filter for the activity feed.
///
/// `feed_types` routes the query to the relay's bounded feed queries — direct
/// `p`-tag mentions UNION NIP-CM `@channel` notifications, membership- and
/// visibility-scoped server-side. Without it the bridge treats this as a raw
/// `#p` filter, and marker-only `@channel` events (which carry no `p` tag)
/// never produce a feed row. The raw `#p`/`limit` fields stay as a graceful
/// fallback: the bridge ignores them when `feed_types` is present, while a
/// relay predating the extension drops the unknown field and still serves
/// direct mentions.
fn build_feed_filter(
my_pk: &str,
since: Option<i64>,
limit: Option<u32>,
limit: u32,
types: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);

) -> Result<serde_json::Value, CliError> {
let mut filter = serde_json::json!({
"#p": [my_pk],
"limit": limit
Expand All @@ -25,18 +39,37 @@ pub async fn cmd_get_feed(
filter["since"] = serde_json::json!(s);
}

if let Some(types_str) = types {
let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
for t in &type_list {
if !VALID_FEED_TYPES.contains(t) {
return Err(crate::error::CliError::Usage(format!(
"invalid feed type {t:?} — must be one of: {}",
VALID_FEED_TYPES.join(", ")
)));
filter["feed_types"] = match types {
Some(types_str) => {
let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
for t in &type_list {
if !VALID_FEED_TYPES.contains(t) {
return Err(CliError::Usage(format!(
"invalid feed type {t:?} — must be one of: {}",
VALID_FEED_TYPES.join(", ")
)));
}
}
serde_json::json!(type_list)
}
filter["feed_types"] = serde_json::json!(type_list);
}
None => serde_json::json!(DEFAULT_FEED_TYPES),
};

Ok(filter)
}

/// Get activity feed — mentions, needs-action, and activity rows addressed to us.
pub async fn cmd_get_feed(
client: &BuzzClient,
since: Option<i64>,
limit: Option<u32>,
types: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);

let filter = build_feed_filter(&my_pk, since, limit, types)?;

let resp = client.query(&filter).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
Expand Down Expand Up @@ -78,3 +111,57 @@ pub async fn dispatch(
} => cmd_get_feed(client, since, limit, types.as_deref(), format).await,
}
}

#[cfg(test)]
mod tests {
use super::*;

const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

#[test]
fn default_requests_the_addressed_to_me_feeds() {
let filter = build_feed_filter(PK, None, 20, None).expect("default filter builds");
assert_eq!(
filter["feed_types"],
serde_json::json!(["mentions", "needs_action"]),
"the default routes to the bounded addressed-to-me feeds (so marker-only @channel rows appear) and leaves the activity firehose to an explicit --types"
);
assert_eq!(filter["#p"], serde_json::json!([PK]));
assert_eq!(filter["limit"], serde_json::json!(20));
assert!(filter.get("since").is_none());
}

#[test]
fn explicit_types_are_passed_through() {
let filter = build_feed_filter(PK, Some(1_700_000_000), 5, Some("mentions,activity"))
.expect("explicit filter builds");
assert_eq!(
filter["feed_types"],
serde_json::json!(["mentions", "activity"])
);
assert_eq!(filter["#p"], serde_json::json!([PK]));
assert_eq!(filter["since"], serde_json::json!(1_700_000_000));
}

#[test]
fn invalid_type_is_a_usage_error() {
let err = build_feed_filter(PK, None, 20, Some("mentions,bogus"))
.expect_err("invalid type must be rejected");
match err {
CliError::Usage(msg) => assert!(msg.contains("bogus"), "message names the bad type"),
other => panic!("expected Usage error, got {other:?}"),
}
}

#[test]
fn p_tag_retained_for_every_type_selection() {
for types in [None, Some("mentions"), Some("needs_action,agent_activity")] {
let filter = build_feed_filter(PK, None, 20, types).expect("filter builds");
assert_eq!(
filter["#p"],
serde_json::json!([PK]),
"#p is the fallback for relays predating feed_types ({types:?})"
);
}
}
}
111 changes: 103 additions & 8 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use buzz_sdk::{DeleteMessageOptions, DiffMeta, ThreadRef, VoteDirection};
use buzz_sdk::{DeleteMessageOptions, DiffMeta, NotifyMode, ThreadRef, VoteDirection};
use nostr::PublicKey;
use uuid::Uuid;

Expand All @@ -9,8 +9,8 @@ use crate::validate::{
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions,
MENTION_CAP,
extract_at_mentions_with_known, extract_nostr_uris, extract_reserved_mention_tokens,
merge_mentions, strip_code_regions, MENTION_CAP,
};

/// Extract the thread root event ID from a Nostr tag array.
Expand Down Expand Up @@ -477,19 +477,58 @@ pub struct SendMessageParams {
pub kind: Option<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
/// Raw `--notify` value, parsed by [`parse_notify_mode`].
pub notify: Option<String>,
pub files: Vec<String>,
}

/// Parse the `--notify` flag value into a [`NotifyMode`].
///
/// Unknown values are a usage error (exit code 1) rather than a silent
/// downgrade — sending a message that quietly failed to notify anyone is the
/// worse outcome.
fn parse_notify_mode(value: Option<&str>) -> Result<Option<NotifyMode>, CliError> {
match value {
None => Ok(None),
Some(raw) => raw.parse::<NotifyMode>().map(Some).map_err(|_| {
CliError::Usage(format!(
"invalid --notify value '{raw}' (expected 'channel' or 'here')"
))
}),
}
}

/// Warning shown when content mentions `@channel`/`@here` without the flag.
///
/// Returns `None` when no reserved token is present outside code regions, so
/// an `@here` inside a code fence stays quiet.
fn unflagged_notify_warning(content: &str, notify: Option<NotifyMode>) -> Option<String> {
if notify.is_some() {
return None;
}
let tokens = extract_reserved_mention_tokens(&strip_code_regions(content));
let first = tokens.first()?;
Some(format!(
"warning: @{first} does not notify anyone unless --notify {first} is passed; sending without it"
))
}

pub async fn cmd_send_message(
client: &BuzzClient,
mut p: SendMessageParams,
) -> Result<(), CliError> {
// Reject a bad --notify value before consuming stdin, so a typo does not
// eat piped content the caller cannot replay.
let notify = parse_notify_mode(p.notify.as_deref())?;
// Allow '-' to read content from stdin. This keeps callers from having to
// jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv
// quoting — the source of countless self-inflicted command-substitution
// bugs for agent and human users alike.
p.content = read_or_stdin(&p.content)?;
validate_content_size(&p.content)?;
if let Some(warning) = unflagged_notify_warning(&p.content, notify) {
eprintln!("{warning}");
}
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
}
Expand Down Expand Up @@ -538,10 +577,14 @@ pub async fn cmd_send_message(
let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect();

let builder = match p.kind {
Some(45001) => {
buzz_sdk::build_forum_post(channel_uuid, &final_content, &mention_refs, &media_tags)
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?
}
Some(45001) => buzz_sdk::build_forum_post(
channel_uuid,
&final_content,
&mention_refs,
notify,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_forum_post failed: {e}")))?,
Some(45003) => {
let tr = thread_ref.as_ref().ok_or_else(|| {
CliError::Usage("--reply-to is required for forum comments (kind 45003)".into())
Expand All @@ -551,6 +594,7 @@ pub async fn cmd_send_message(
&final_content,
tr,
&mention_refs,
notify,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_forum_comment failed: {e}")))?
Expand All @@ -561,6 +605,7 @@ pub async fn cmd_send_message(
thread_ref.as_ref(),
&mention_refs,
p.broadcast,
notify,
&media_tags,
)
.map_err(|e| CliError::Other(format!("build_message failed: {e}")))?,
Expand Down Expand Up @@ -764,6 +809,7 @@ pub async fn dispatch(
kind,
reply_to,
broadcast,
notify,
files,
} => {
cmd_send_message(
Expand All @@ -774,6 +820,7 @@ pub async fn dispatch(
kind,
reply_to,
broadcast,
notify,
files,
},
)
Expand Down Expand Up @@ -876,7 +923,10 @@ pub async fn dispatch(

#[cfg(test)]
mod tests {
use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys};
use super::{
find_root_from_tags, match_profiles_by_name, parse_member_pubkeys, parse_notify_mode,
unflagged_notify_warning, CliError, NotifyMode,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile,
};
Expand Down Expand Up @@ -1164,4 +1214,49 @@ mod tests {
];
assert_eq!(match_profiles_by_name(&events, "Aaron").len(), 1);
}

#[test]
fn notify_flag_parses_both_modes() {
assert_eq!(parse_notify_mode(None).expect("no flag"), None);
assert_eq!(
parse_notify_mode(Some("channel")).expect("channel"),
Some(NotifyMode::Channel)
);
assert_eq!(
parse_notify_mode(Some("here")).expect("here"),
Some(NotifyMode::Here)
);
}

#[test]
fn notify_flag_rejects_unknown_value_as_usage_error() {
// Usage errors exit 1; anything else would mask a typo as a send failure.
let err = parse_notify_mode(Some("everyone")).unwrap_err();
assert!(matches!(err, CliError::Usage(_)), "got {err:?}");
assert!(err.to_string().contains("everyone"));
// Wire format is lowercase-only.
assert!(parse_notify_mode(Some("Channel")).is_err());
}

#[test]
fn literal_reserved_token_without_flag_warns() {
let warning = unflagged_notify_warning("ship it @channel", None).expect("warning");
assert!(warning.contains("@channel"));
assert!(warning.contains("--notify channel"));
assert!(unflagged_notify_warning("heads up @here", None)
.expect("warning")
.contains("--notify here"));
}

#[test]
fn no_warning_when_flag_passed_or_token_absent() {
assert!(unflagged_notify_warning("ship it @channel", Some(NotifyMode::Channel)).is_none());
assert!(unflagged_notify_warning("hello @alice", None).is_none());
}

#[test]
fn reserved_token_inside_code_region_does_not_warn() {
assert!(unflagged_notify_warning("run `git push @here`", None).is_none());
assert!(unflagged_notify_warning("```\n@channel\n```", None).is_none());
}
}
Loading