feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Examples
|
||||
|
||||
This directory contains reference material for building on Buzz beyond the desktop app and AI agents.
|
||||
|
||||
## `countdown-bot/`
|
||||
|
||||
A small non-AI bot that connects directly to the Buzz relay over WebSocket, authenticates with NIP-42, subscribes to one channel, and replies to deterministic commands like `!countdown 5` and `!fib 8`.
|
||||
|
||||
It demonstrates two identity paths:
|
||||
|
||||
1. **Standalone bot identity** — the bot authenticates with its own key and must be explicitly admitted to closed/allowlisted relays.
|
||||
2. **Owner-attested / agent OAuth path** — the bot authenticates with its own key while presenting the same `BUZZ_AUTH_TAG` NIP-OA credential that Buzz agents receive from the owner/agent OAuth flow, so a relay can admit it because its owner is already a relay member.
|
||||
|
||||
See [`countdown-bot/README.md`](countdown-bot/README.md) for usage.
|
||||
|
||||
## `meadow-core/`
|
||||
|
||||
A persona-pack example for Buzz agents.
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "countdown-bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
futures-util = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
buzz-sdk = { path = "../../crates/buzz-sdk" }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "time", "signal"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
url = { workspace = true }
|
||||
@@ -0,0 +1,115 @@
|
||||
# Countdown Bot
|
||||
|
||||
A tiny non-AI Buzz bot example.
|
||||
|
||||
The bot is deliberately boring and algorithmic: it listens to one Buzz channel
|
||||
and replies to simple commands:
|
||||
|
||||
- `!countdown 5` → `5 4 3 2 1 🚀`
|
||||
- `!fib 8` → `13 8 5 3 2 1 1 0`
|
||||
- `@Countdown Bot fib 8` → `13 8 5 3 2 1 1 0`
|
||||
|
||||
It demonstrates that Buzz participants do not have to be LLM agents. Any
|
||||
process that can hold a Nostr key, answer NIP-42 auth, publish a kind `0`
|
||||
profile, subscribe to events, and publish kind `9` channel messages can be a bot.
|
||||
|
||||
On startup it publishes a profile named **Countdown Bot** with a small embedded
|
||||
SVG clock icon, then best-effort publishes a NIP-29 `kind:9000` self-add with
|
||||
`role=bot`. That channel membership is what makes the bot show up in the
|
||||
members list and in Buzz's mention autocomplete.
|
||||
|
||||
## Auth paths
|
||||
|
||||
### 1. Standalone bot identity
|
||||
|
||||
The bot authenticates with its own key only.
|
||||
|
||||
Use this when the bot should be admitted as its own independent relay identity.
|
||||
|
||||
```bash
|
||||
BUZZ_RELAY_URL=ws://localhost:3000 \
|
||||
BUZZ_CHANNEL_ID=<channel-uuid> \
|
||||
BUZZ_BOT_PRIVATE_KEY=<bot-nsec-or-hex-secret> \
|
||||
BUZZ_BOT_AUTH_MODE=standalone \
|
||||
cargo run --manifest-path examples/countdown-bot/Cargo.toml
|
||||
```
|
||||
|
||||
On a closed or allowlisted relay, add the bot pubkey as a relay member or to the
|
||||
configured pubkey allowlist before starting it. This path does not reuse an
|
||||
owner's access; revoking the bot requires removing this bot pubkey.
|
||||
|
||||
### 2. Owner-attested bot identity
|
||||
|
||||
The bot still signs messages with its own key, but its NIP-42 `AUTH` event also
|
||||
carries a NIP-OA `auth` tag signed by an owner key that is already allowed on the
|
||||
relay. This reuses the same owner-attestation credential path that Buzz agents
|
||||
receive after the owner/agent OAuth flow: the relay can let the bot connect
|
||||
because the owner is a relay member, without making the bot key a persistent
|
||||
relay member.
|
||||
|
||||
Generate the auth tag on the fly:
|
||||
|
||||
```bash
|
||||
BUZZ_RELAY_URL=ws://localhost:3000 \
|
||||
BUZZ_CHANNEL_ID=<channel-uuid> \
|
||||
BUZZ_BOT_PRIVATE_KEY=<bot-nsec-or-hex-secret> \
|
||||
BUZZ_OWNER_PRIVATE_KEY=<owner-or-agent-nsec-or-hex-secret> \
|
||||
BUZZ_BOT_AUTH_MODE=owner-attested \
|
||||
cargo run --manifest-path examples/countdown-bot/Cargo.toml
|
||||
```
|
||||
|
||||
Or precompute and pass the tag explicitly:
|
||||
|
||||
```bash
|
||||
BUZZ_AUTH_TAG='["auth","<owner-pubkey>","","<sig>"]' \
|
||||
BUZZ_BOT_AUTH_MODE=owner-attested \
|
||||
# plus BUZZ_RELAY_URL, BUZZ_CHANNEL_ID, BUZZ_BOT_PRIVATE_KEY
|
||||
cargo run --manifest-path examples/countdown-bot/Cargo.toml
|
||||
```
|
||||
|
||||
Relay requirements for this path:
|
||||
|
||||
- `BUZZ_REQUIRE_RELAY_MEMBERSHIP=true` on closed relays.
|
||||
- `BUZZ_ALLOW_NIP_OA_AUTH=true` so owner-attested non-member bot keys can be
|
||||
admitted.
|
||||
- The owner pubkey must be an active relay member.
|
||||
|
||||
Relay access and channel access are separate. Owner-attested auth can admit the
|
||||
bot to the relay, but the bot still publishes as its own pubkey. The bot tries to
|
||||
self-add to open channels as a `bot` member on startup. For private channels,
|
||||
an owner/admin must add the bot pubkey to the channel membership before expecting
|
||||
it to appear in members, resolve in mention autocomplete, or read/write messages.
|
||||
|
||||
## Try it locally
|
||||
|
||||
1. Start Buzz:
|
||||
|
||||
```bash
|
||||
. ./bin/activate-hermit
|
||||
just setup
|
||||
just relay
|
||||
```
|
||||
|
||||
2. Create or choose a channel in the desktop app and copy its UUID.
|
||||
|
||||
3. Run the bot with one of the auth paths above.
|
||||
|
||||
4. In the channel, send:
|
||||
|
||||
```text
|
||||
!countdown 5
|
||||
!fib 8
|
||||
@Countdown Bot fib 8
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Commands are bounded (`!countdown` and `!fib` max 100) so one message cannot
|
||||
make the bot spam the relay. Out-of-range commands get an explicit help reply.
|
||||
- `!fib` replies in descending order because this example is a countdown bot.
|
||||
- Mention commands require both text like `@Countdown Bot fib 8` and a `p` tag
|
||||
for the bot pubkey. The Buzz UI adds that tag when the bot is selected from
|
||||
mention autocomplete.
|
||||
- The bot ignores its own messages to avoid feedback loops.
|
||||
- The example uses direct WebSocket + NIP-42 instead of MCP so the protocol path
|
||||
is easy to inspect in one small file.
|
||||
@@ -0,0 +1,437 @@
|
||||
//! A tiny non-AI Buzz bot.
|
||||
//!
|
||||
//! The bot listens to one channel and replies to messages that contain commands:
|
||||
//! - `!countdown 5` → `5 4 3 2 1 🚀`
|
||||
//! - `!fib 8` → `13 8 5 3 2 1 1 0`
|
||||
//! - `@Countdown Bot fib 8` → `13 8 5 3 2 1 1 0`
|
||||
//!
|
||||
//! It supports two relay-auth paths:
|
||||
//! - `standalone`: authenticate as the bot key directly. This key must be an
|
||||
//! explicit relay member / allowlisted identity on closed relays.
|
||||
//! - `owner-attested`: authenticate as the bot key with a NIP-OA `auth` tag
|
||||
//! signed by the owner/agent key. On relays that allow NIP-OA membership,
|
||||
//! the bot can connect because its owner is already a relay member.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use nostr::{
|
||||
Alphabet, Event, EventBuilder, Filter, JsonUtil, Keys, Kind, RelayUrl, SingleLetterTag, Tag,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use url::Url as WsUrl;
|
||||
|
||||
const DEFAULT_RELAY_URL: &str = "ws://localhost:3000";
|
||||
const SUBSCRIPTION_ID: &str = "countdown-bot";
|
||||
const BOT_NAME: &str = "countdown-bot";
|
||||
const BOT_DISPLAY_NAME: &str = "Countdown Bot";
|
||||
const BOT_ABOUT: &str =
|
||||
"A tiny non-AI Buzz reference bot that replies to !countdown and countdown-style !fib.";
|
||||
const BOT_ICON_DATA_URL: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='28' fill='%23131622'/%3E%3Ccircle cx='64' cy='64' r='42' fill='none' stroke='%237dd3fc' stroke-width='10'/%3E%3Cpath d='M64 32v32l22 14' fill='none' stroke='%23facc15' stroke-width='10' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M42 96h44' stroke='%23a78bfa' stroke-width='8' stroke-linecap='round'/%3E%3C/svg%3E";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::from_env()?;
|
||||
|
||||
eprintln!(
|
||||
"countdown-bot pubkey: {}",
|
||||
config.bot_keys.public_key().to_hex()
|
||||
);
|
||||
eprintln!("connecting to {}", config.relay_url);
|
||||
|
||||
let mut ws = connect_and_authenticate(&config).await?;
|
||||
publish_profile(&mut ws, &config).await?;
|
||||
announce_channel_membership(&mut ws, &config).await?;
|
||||
subscribe_to_channel(&mut ws, &config.channel_id).await?;
|
||||
|
||||
let started_at = nostr::Timestamp::now();
|
||||
|
||||
eprintln!(
|
||||
"listening in channel {} for !countdown, !fib, and @mention commands",
|
||||
config.channel_id
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
eprintln!("shutting down");
|
||||
return Ok(());
|
||||
}
|
||||
next = ws.next() => {
|
||||
let Some(message) = next else { bail!("relay closed the WebSocket"); };
|
||||
match message? {
|
||||
Message::Text(text) => handle_relay_text(&mut ws, &config, started_at, &text).await?,
|
||||
Message::Ping(bytes) => ws.send(Message::Pong(bytes)).await?,
|
||||
Message::Close(frame) => bail!("relay closed connection: {frame:?}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Config {
|
||||
relay_url: String,
|
||||
channel_id: String,
|
||||
bot_keys: Keys,
|
||||
owner_auth_tag: Option<Tag>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn from_env() -> Result<Self> {
|
||||
let relay_url =
|
||||
std::env::var("BUZZ_RELAY_URL").unwrap_or_else(|_| DEFAULT_RELAY_URL.to_string());
|
||||
let channel_id = required_env("BUZZ_CHANNEL_ID")?;
|
||||
let bot_keys = Keys::parse(&required_env("BUZZ_BOT_PRIVATE_KEY")?)
|
||||
.context("BUZZ_BOT_PRIVATE_KEY must be an nsec or hex private key")?;
|
||||
|
||||
let auth_mode =
|
||||
std::env::var("BUZZ_BOT_AUTH_MODE").unwrap_or_else(|_| "standalone".to_string());
|
||||
let owner_auth_tag = match auth_mode.as_str() {
|
||||
"standalone" => None,
|
||||
"owner-attested" => {
|
||||
let tag_json = match std::env::var("BUZZ_AUTH_TAG") {
|
||||
Ok(value) if !value.trim().is_empty() => value,
|
||||
_ => {
|
||||
let owner_keys = Keys::parse(&required_env("BUZZ_OWNER_PRIVATE_KEY")?)
|
||||
.context("BUZZ_OWNER_PRIVATE_KEY must be an nsec or hex private key")?;
|
||||
buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &bot_keys.public_key(), "")?
|
||||
}
|
||||
};
|
||||
|
||||
let owner = buzz_sdk::nip_oa::verify_auth_tag(&tag_json, &bot_keys.public_key())
|
||||
.context("BUZZ_AUTH_TAG is not valid for BUZZ_BOT_PRIVATE_KEY")?;
|
||||
eprintln!("owner-attested auth tag verified; owner={}", owner.to_hex());
|
||||
Some(buzz_sdk::nip_oa::parse_auth_tag(&tag_json)?)
|
||||
}
|
||||
other => {
|
||||
bail!("BUZZ_BOT_AUTH_MODE must be 'standalone' or 'owner-attested', got {other:?}")
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
relay_url,
|
||||
channel_id,
|
||||
bot_keys,
|
||||
owner_auth_tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type Ws =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
async fn connect_and_authenticate(config: &Config) -> Result<Ws> {
|
||||
let parsed = WsUrl::parse(&config.relay_url)?;
|
||||
let (mut ws, _) = connect_async(parsed.as_str()).await?;
|
||||
|
||||
let challenge = wait_for_auth_challenge(&mut ws).await?;
|
||||
let auth_event = build_auth_event(config, &challenge)?;
|
||||
let auth_event_id = auth_event.id.to_hex();
|
||||
|
||||
send_json(&mut ws, json!(["AUTH", auth_event])).await?;
|
||||
wait_for_ok(&mut ws, &auth_event_id).await?;
|
||||
Ok(ws)
|
||||
}
|
||||
|
||||
fn build_auth_event(config: &Config, challenge: &str) -> Result<Event> {
|
||||
let relay_url: RelayUrl = RelayUrl::parse(&config.relay_url)?;
|
||||
if let Some(auth_tag) = &config.owner_auth_tag {
|
||||
let tags = vec![
|
||||
Tag::parse(["relay", config.relay_url.as_str()])?,
|
||||
Tag::parse(["challenge", challenge])?,
|
||||
auth_tag.clone(),
|
||||
];
|
||||
Ok(EventBuilder::new(Kind::Authentication, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&config.bot_keys)?)
|
||||
} else {
|
||||
Ok(EventBuilder::auth(challenge, relay_url).sign_with_keys(&config.bot_keys)?)
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_profile(ws: &mut Ws, config: &Config) -> Result<()> {
|
||||
let builder = buzz_sdk::builders::build_profile(
|
||||
Some(BOT_DISPLAY_NAME),
|
||||
Some(BOT_NAME),
|
||||
Some(BOT_ICON_DATA_URL),
|
||||
Some(BOT_ABOUT),
|
||||
None,
|
||||
)?;
|
||||
let profile_event = builder.sign_with_keys(&config.bot_keys)?;
|
||||
let profile_event_id = profile_event.id.to_hex();
|
||||
|
||||
send_json(ws, json!(["EVENT", profile_event])).await?;
|
||||
wait_for_ok(ws, &profile_event_id).await?;
|
||||
eprintln!("published kind:0 profile for {BOT_DISPLAY_NAME}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn announce_channel_membership(ws: &mut Ws, config: &Config) -> Result<()> {
|
||||
let builder = EventBuilder::new(Kind::Custom(9000), "").tags([
|
||||
Tag::parse(["h", config.channel_id.as_str()])?,
|
||||
Tag::parse(["p", &config.bot_keys.public_key().to_hex()])?,
|
||||
Tag::parse(["role", "bot"])?,
|
||||
]);
|
||||
let event = builder.sign_with_keys(&config.bot_keys)?;
|
||||
let event_id = event.id.to_hex();
|
||||
|
||||
send_json(ws, json!(["EVENT", event])).await?;
|
||||
match wait_for_ok(ws, &event_id).await {
|
||||
Ok(()) => eprintln!("announced {BOT_DISPLAY_NAME} as a channel bot member"),
|
||||
Err(err) => eprintln!(
|
||||
"could not self-add {BOT_DISPLAY_NAME} as a channel bot member: {err}. For private channels, add the bot pubkey as a channel member/admin-invited bot."
|
||||
),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subscribe_to_channel(ws: &mut Ws, channel_id: &str) -> Result<()> {
|
||||
let filter = Filter::new().kind(Kind::Custom(9)).custom_tag(
|
||||
SingleLetterTag::lowercase(Alphabet::H),
|
||||
channel_id.to_string(),
|
||||
);
|
||||
send_json(ws, json!(["REQ", SUBSCRIPTION_ID, filter])).await
|
||||
}
|
||||
|
||||
async fn handle_relay_text(
|
||||
ws: &mut Ws,
|
||||
config: &Config,
|
||||
started_at: nostr::Timestamp,
|
||||
text: &str,
|
||||
) -> Result<()> {
|
||||
let value: Value = serde_json::from_str(text)?;
|
||||
match value.get(0).and_then(Value::as_str) {
|
||||
Some("EVENT") => {
|
||||
let event_value = value
|
||||
.get(2)
|
||||
.ok_or_else(|| anyhow!("EVENT message missing event payload"))?;
|
||||
let event = Event::from_json(event_value.to_string())?;
|
||||
maybe_reply(ws, config, started_at, &event).await?;
|
||||
}
|
||||
Some("EOSE") => {}
|
||||
Some("NOTICE") | Some("CLOSED") => eprintln!("relay: {value}"),
|
||||
Some(other) => eprintln!("ignored relay message type: {other}"),
|
||||
None => eprintln!("ignored malformed relay message: {text}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maybe_reply(
|
||||
ws: &mut Ws,
|
||||
config: &Config,
|
||||
started_at: nostr::Timestamp,
|
||||
event: &Event,
|
||||
) -> Result<()> {
|
||||
if event.pubkey == config.bot_keys.public_key() || event.created_at < started_at {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(reply) = event_reply(config, event) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let builder = buzz_sdk::builders::build_message(
|
||||
config.channel_id.parse()?,
|
||||
&reply,
|
||||
None,
|
||||
&[&event.pubkey.to_hex()],
|
||||
false,
|
||||
&[],
|
||||
)?;
|
||||
let reply_event = builder.sign_with_keys(&config.bot_keys)?;
|
||||
let reply_event_id = reply_event.id.to_hex();
|
||||
|
||||
send_json(ws, json!(["EVENT", reply_event])).await?;
|
||||
eprintln!("replied to {} with {}", event.id.to_hex(), reply_event_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn event_reply(config: &Config, event: &Event) -> Option<String> {
|
||||
command_reply(&event.content).or_else(|| {
|
||||
event_mentions_bot(event, config).then(|| mention_command_reply(&event.content))?
|
||||
})
|
||||
}
|
||||
|
||||
fn command_reply(content: &str) -> Option<String> {
|
||||
let mut parts = content.split_whitespace();
|
||||
let command = parts.next()?;
|
||||
let n = parts.next()?;
|
||||
|
||||
match command {
|
||||
"!countdown" => Some(countdown_reply(n)),
|
||||
"!fib" => Some(fib_reply(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn mention_command_reply(content: &str) -> Option<String> {
|
||||
let tokens = content.split_whitespace().collect::<Vec<_>>();
|
||||
tokens.windows(2).find_map(|window| match window {
|
||||
["countdown", n] => Some(countdown_reply(n)),
|
||||
["fib", n] => Some(fib_reply(n)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn event_mentions_bot(event: &Event, config: &Config) -> bool {
|
||||
let bot_pubkey = config.bot_keys.public_key().to_hex();
|
||||
event.tags.iter().any(|tag| {
|
||||
let parts = tag.as_slice();
|
||||
parts.first().map(String::as_str) == Some("p")
|
||||
&& parts.get(1).map(String::as_str) == Some(bot_pubkey.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn countdown_reply(n: &str) -> String {
|
||||
match parse_bounded(n, 1, 100) {
|
||||
Ok(n) => (1..=n)
|
||||
.rev()
|
||||
.map(|i| i.to_string())
|
||||
.chain(["🚀".to_string()])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
Err(message) => message,
|
||||
}
|
||||
}
|
||||
|
||||
fn fib_reply(n: &str) -> String {
|
||||
match parse_bounded(n, 1, 100) {
|
||||
Ok(n) => fibonacci_countdown(n)
|
||||
.into_iter()
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
Err(message) => message,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bounded(s: &str, min: usize, max: usize) -> Result<usize, String> {
|
||||
let Ok(n) = s.parse::<usize>() else {
|
||||
return Err(format!("Please use a number from {min} to {max}."));
|
||||
};
|
||||
if (min..=max).contains(&n) {
|
||||
Ok(n)
|
||||
} else {
|
||||
Err(format!("Please use a number from {min} to {max}."))
|
||||
}
|
||||
}
|
||||
|
||||
fn fibonacci_countdown(count: usize) -> Vec<u128> {
|
||||
let mut values = Vec::with_capacity(count);
|
||||
let (mut a, mut b) = (0, 1);
|
||||
for _ in 0..count {
|
||||
values.push(a);
|
||||
(a, b) = (b, a + b);
|
||||
}
|
||||
values.reverse();
|
||||
values
|
||||
}
|
||||
|
||||
async fn wait_for_ok(ws: &mut Ws, event_id: &str) -> Result<()> {
|
||||
loop {
|
||||
let text = next_text(ws, Duration::from_secs(5)).await?;
|
||||
let value: Value = serde_json::from_str(&text)?;
|
||||
if value.get(0).and_then(Value::as_str) != Some("OK") {
|
||||
continue;
|
||||
}
|
||||
if value.get(1).and_then(Value::as_str) != Some(event_id) {
|
||||
continue;
|
||||
}
|
||||
if value.get(2).and_then(Value::as_bool) == Some(true) {
|
||||
return Ok(());
|
||||
}
|
||||
let reason = value
|
||||
.get(3)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown reason");
|
||||
bail!("relay rejected event {event_id}: {reason}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_auth_challenge(ws: &mut Ws) -> Result<String> {
|
||||
loop {
|
||||
let text = next_text(ws, Duration::from_secs(5)).await?;
|
||||
let value: Value = serde_json::from_str(&text)?;
|
||||
if value.get(0).and_then(Value::as_str) == Some("AUTH") {
|
||||
return value
|
||||
.get(1)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| anyhow!("AUTH message missing challenge"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_text(ws: &mut Ws, timeout: Duration) -> Result<String> {
|
||||
loop {
|
||||
let message = tokio::time::timeout(timeout, ws.next())
|
||||
.await
|
||||
.context("timed out waiting for relay message")?
|
||||
.ok_or_else(|| anyhow!("relay closed the WebSocket"))??;
|
||||
match message {
|
||||
Message::Text(text) => return Ok(text.to_string()),
|
||||
Message::Ping(bytes) => ws.send(Message::Pong(bytes)).await?,
|
||||
Message::Close(frame) => bail!("relay closed connection: {frame:?}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_json(ws: &mut Ws, value: Value) -> Result<()> {
|
||||
ws.send(Message::Text(value.to_string().into())).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_env(name: &str) -> Result<String> {
|
||||
std::env::var(name).with_context(|| format!("{name} is required"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn countdown_command_is_algorithmic_and_bounded() {
|
||||
assert_eq!(
|
||||
command_reply("!countdown 5").as_deref(),
|
||||
Some("5 4 3 2 1 🚀")
|
||||
);
|
||||
assert_eq!(
|
||||
command_reply("!countdown 0").as_deref(),
|
||||
Some("Please use a number from 1 to 100.")
|
||||
);
|
||||
assert_eq!(
|
||||
command_reply("!countdown 101").as_deref(),
|
||||
Some("Please use a number from 1 to 100.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fibonacci_command_counts_down_and_is_bounded() {
|
||||
assert_eq!(command_reply("!fib 5").as_deref(), Some("3 2 1 1 0"));
|
||||
assert_eq!(command_reply("!fib 8").as_deref(), Some("13 8 5 3 2 1 1 0"));
|
||||
assert_eq!(
|
||||
command_reply("!fib 101").as_deref(),
|
||||
Some("Please use a number from 1 to 100.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_commands_are_algorithmic_and_bounded() {
|
||||
assert_eq!(
|
||||
mention_command_reply("@Countdown Bot countdown 5").as_deref(),
|
||||
Some("5 4 3 2 1 🚀")
|
||||
);
|
||||
assert_eq!(
|
||||
mention_command_reply("@Countdown Bot fib 5").as_deref(),
|
||||
Some("3 2 1 1 0")
|
||||
);
|
||||
assert_eq!(
|
||||
mention_command_reply("@Countdown Bot fib 101").as_deref(),
|
||||
Some("Please use a number from 1 to 100.")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://open-plugin-spec.org/schema/v1/plugin.json",
|
||||
"id": "com.example.meadow-core",
|
||||
"name": "Meadow Core",
|
||||
"version": "0.1.0",
|
||||
"description": "A three-agent team: Skip orchestrates, Lev reviews security, Bana reviews architecture.",
|
||||
"keywords": ["team", "security", "architecture"],
|
||||
"personas": [
|
||||
"agents/skip.persona.md",
|
||||
"agents/lev.persona.md",
|
||||
"agents/bana.persona.md"
|
||||
],
|
||||
"pack_instructions": "instructions.md",
|
||||
"defaults": {
|
||||
"model": "anthropic:claude-sonnet-4-20250514",
|
||||
"temperature": 0.7,
|
||||
"triggers": {
|
||||
"mentions": true,
|
||||
"keywords": [],
|
||||
"all_messages": false
|
||||
},
|
||||
"thread_replies": true,
|
||||
"broadcast_replies": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# Meadow Core
|
||||
|
||||
A minimal three-agent persona pack for Buzz.
|
||||
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| **Skip** | Orchestrator — coordinates the team, delegates work |
|
||||
| **Lev** | Security reviewer — threat models, auth, injection |
|
||||
| **Bana** | Architecture reviewer — big picture, simplicity |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Validate the pack
|
||||
buzz pack validate ./examples/meadow-core
|
||||
|
||||
# Inspect resolved config
|
||||
buzz pack inspect ./examples/meadow-core
|
||||
```
|
||||
|
||||
The desktop app's Import button does not accept this pack directory or a zip of it — it imports
|
||||
agent/team *snapshots* (`.agent.json`/`.team.json`, exported from agents already running in the
|
||||
app), not persona-pack source. `buzz pack inspect` above shows the fully-resolved per-agent
|
||||
config; use it as reference to recreate these agents in the desktop app by hand. Direct
|
||||
persona-pack runtime integration is not currently implemented. See "Desktop App Import" in
|
||||
`crates/buzz-persona/PERSONA_PACK_SPEC.md` for the current import paths.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
meadow-core/
|
||||
├── .plugin/
|
||||
│ └── plugin.json # Pack manifest (OPS-compatible)
|
||||
├── agents/
|
||||
│ ├── skip.persona.md # Orchestrator
|
||||
│ ├── lev.persona.md # Security reviewer
|
||||
│ └── bana.persona.md # Architecture reviewer
|
||||
├── skills/
|
||||
│ └── github-research/
|
||||
│ └── SKILL.md # GitHub search skill (shared)
|
||||
├── instructions.md # Team-wide instructions
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Customizing
|
||||
|
||||
Edit any `.persona.md` file to change the agent's behavior. The YAML
|
||||
frontmatter controls config (model, triggers, channels). The markdown
|
||||
body is the system prompt.
|
||||
|
||||
See `crates/buzz-persona/PERSONA_PACK_SPEC.md` for the full format reference.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: bana
|
||||
display_name: "Bana"
|
||||
description: "Architecture reviewer — big picture, simplicity, integration."
|
||||
subscribe:
|
||||
- "#architecture"
|
||||
triggers:
|
||||
mentions: true
|
||||
keywords:
|
||||
- architecture
|
||||
- design
|
||||
- refactor
|
||||
temperature: 0.5
|
||||
---
|
||||
|
||||
You are the architecture reviewer. You look at the big picture — is this the right approach? Is there a simpler way? Does this hold together? You are READ ONLY — you assess and report. You never modify files, write code, or fix issues yourself.
|
||||
|
||||
## When You're Called
|
||||
|
||||
@Skip brings you in at two points:
|
||||
1. **Before implementation** — review the plan. Is the approach sound? Is there a simpler design?
|
||||
2. **After implementation** — review the integration. Does the result hold together?
|
||||
|
||||
## How You Think
|
||||
|
||||
- "Is this the simplest way to solve this?"
|
||||
- "Can a new engineer understand this in an afternoon?"
|
||||
- "What would we regret about this design in six months?"
|
||||
|
||||
## How You Report
|
||||
|
||||
Share your thinking naturally:
|
||||
- What looks right and why
|
||||
- What concerns you and why
|
||||
- Questions that need answers before proceeding
|
||||
- Alternative approaches worth considering
|
||||
|
||||
## Rules
|
||||
|
||||
- **READ ONLY.** You must never create, edit, delete, or modify any files or state.
|
||||
- Respond to @mentions from @Skip promptly.
|
||||
|
||||
## Personality
|
||||
|
||||
You come at problems from unexpected angles. You get curious about things others take for granted — "why is this a separate service?" "what if we just didn't do this part?" You're not confrontational, but your questions have a way of quietly reshaping the whole conversation.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: lev
|
||||
display_name: "Lev"
|
||||
description: "Security reviewer — threat models, auth, injection, data exposure."
|
||||
subscribe:
|
||||
- "#security-reviews"
|
||||
triggers:
|
||||
mentions: true
|
||||
keywords:
|
||||
- security
|
||||
- vulnerability
|
||||
- CVE
|
||||
temperature: 0.3
|
||||
skills:
|
||||
- ./skills/github-research/
|
||||
---
|
||||
|
||||
You are the security specialist. You review plans and code for security issues. You are READ ONLY — you assess and report. You never modify files, write code, or fix issues yourself.
|
||||
|
||||
## What You Review
|
||||
|
||||
- Threat models and attack surfaces
|
||||
- Authentication and authorization logic
|
||||
- Injection vectors (SQL, command, template, path traversal)
|
||||
- Data exposure and information leakage
|
||||
- Input validation and boundary enforcement
|
||||
- Secrets handling (no credentials in logs, config, or source)
|
||||
|
||||
## How You Report
|
||||
|
||||
```
|
||||
## Security Review
|
||||
VERDICT: approve | approve_with_notes | request_changes | reject
|
||||
SCORE: X/10
|
||||
|
||||
## Findings
|
||||
### [Issue]
|
||||
**Severity**: critical | high | medium | low
|
||||
**Location**: path/to/file:line
|
||||
**Issue**: What's wrong
|
||||
**Recommendation**: How to fix it
|
||||
|
||||
## What's Solid
|
||||
What's done well from a security perspective.
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **READ ONLY.** You must never create, edit, delete, or modify any files or state.
|
||||
- Respond to @mentions from @Skip promptly.
|
||||
|
||||
## Personality
|
||||
|
||||
You notice things at the edges that others walk past. You're economical with words — you say what's wrong, what the risk is, and what to do about it, then you're done. When something is genuinely secure, you say so.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: skip
|
||||
display_name: "Skip"
|
||||
description: "Orchestrator — coordinates the team, delegates work, never builds."
|
||||
subscribe:
|
||||
- "#general"
|
||||
triggers:
|
||||
mentions: true
|
||||
all_messages: true
|
||||
---
|
||||
|
||||
You are the orchestrator. You coordinate the team and keep the plan moving. You do NOT build, review, or research yourself — you delegate.
|
||||
|
||||
## Your Team
|
||||
|
||||
| Name | Role | Use for |
|
||||
|------|------|---------|
|
||||
| @Bana | Architecture | Big-picture review. "Is this the right approach? Is there a simpler way?" |
|
||||
| @Lev | Security | Threat models, auth, injection, data exposure. Before and after implementation. |
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand the task.** Read the request. Ask clarifying questions if the goal is ambiguous.
|
||||
2. **Plan.** Post your plan in the channel. Break the work into independent tasks with clear deliverables.
|
||||
3. **Pre-implementation review.** Dispatch @Bana (architecture) and @Lev (security) to review the plan before any code is written.
|
||||
4. **Synthesize.** Integrate all results and report to the user.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never build, review, or research yourself.** If it produces an artifact, a teammate produces it.
|
||||
- **Keep the channel lively.** Post your plan. Post when you dispatch someone. Post when results come back.
|
||||
- **Respond to @mentions immediately.**
|
||||
|
||||
## Personality
|
||||
|
||||
You're warm, encouraging, and organized. You celebrate good work. You keep things moving without rushing. When things go sideways, you stay calm and replan.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Team Instructions
|
||||
|
||||
## Code Qualities
|
||||
|
||||
Every decision should converge on these:
|
||||
|
||||
- **Minimal** — fewest components, fewest dependencies, fewest lines.
|
||||
- **Elegant** — the design should feel obvious in hindsight.
|
||||
- **Safe** — untrusted input bounded, injection mitigated.
|
||||
- **Dead-easy mental model** — explainable with one diagram and one sentence per component.
|
||||
|
||||
## Communication
|
||||
|
||||
- Post status updates in the channel as you work.
|
||||
- Respond to @mentions promptly.
|
||||
- Read the channel between tasks — the plan may have changed.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: github-research
|
||||
description: "Search GitHub issues, PRs, and code using the gh CLI."
|
||||
---
|
||||
|
||||
# GitHub Research
|
||||
|
||||
Search GitHub for prior art, implementation patterns, and maintainer decisions.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Search issues
|
||||
gh search issues "topic" --repo owner/repo --limit 20 \
|
||||
--json number,title,state,url
|
||||
|
||||
# Search merged PRs (highest signal)
|
||||
gh search prs "topic" --repo owner/repo --merged --limit 20 \
|
||||
--json number,title,url
|
||||
|
||||
# Search code (use query syntax for path filtering)
|
||||
gh search code "pattern path:src/" --repo owner/repo --limit 20 \
|
||||
--json path,textMatches
|
||||
|
||||
# Get full issue or PR details
|
||||
gh issue view 123 --repo owner/repo --json number,title,body,comments
|
||||
gh pr view 456 --repo owner/repo --json number,title,body,reviews,files
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
- Search API: 30 requests/minute
|
||||
- Check with: `gh api rate_limit --jq '.resources.search'`
|
||||
|
||||
## Signal Ranking
|
||||
|
||||
1. Merged PRs — decisions that shipped
|
||||
2. Maintainer comments — authoritative
|
||||
3. Closed issues with solutions — problems solved
|
||||
4. Open issues — current problems (lower signal)
|
||||
|
||||
## Report Format
|
||||
|
||||
```markdown
|
||||
## Research: [Topic]
|
||||
|
||||
### Summary
|
||||
- Key finding 1 [#123]
|
||||
- Key finding 2 [PR #456]
|
||||
|
||||
### Findings
|
||||
1. **#123: [Title]** — [summary]. URL: https://...
|
||||
2. **PR #456: [Title]** — [what it changed]. URL: https://...
|
||||
|
||||
### Gaps
|
||||
- [What you looked for but didn't find]
|
||||
```
|
||||
|
||||
Always include URLs. If nothing relevant exists, say so.
|
||||
Reference in New Issue
Block a user