mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
feat(market): let buyers discover Pulse offers
Signed-off-by: Wintermute <c0fc581234c3585602139eec347ced7b82af65b6f6c10728348515c0c06c51c3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -79,6 +79,35 @@ pub async fn cmd_get_event(client: &BuzzClient, event_id: &str) -> Result<(), Cl
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get recent public Pulse notes (kind:1) across all authors.
|
||||
pub async fn cmd_get_global_notes(
|
||||
client: &BuzzClient,
|
||||
limit: Option<u32>,
|
||||
since: Option<i64>,
|
||||
before: Option<i64>,
|
||||
) -> Result<(), CliError> {
|
||||
if let (Some(since), Some(before)) = (since, before) {
|
||||
if since > before {
|
||||
return Err(CliError::Usage(
|
||||
"--since must be less than or equal to --before".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut filter = serde_json::json!({
|
||||
"kinds": [1],
|
||||
"limit": limit.unwrap_or(50).min(200)
|
||||
});
|
||||
if let Some(since) = since {
|
||||
filter["since"] = serde_json::json!(since);
|
||||
}
|
||||
if let Some(before) = before {
|
||||
filter["until"] = serde_json::json!(before);
|
||||
}
|
||||
let resp = client.query(&filter).await?;
|
||||
println!("{resp}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get user notes (kind:1) by author pubkey.
|
||||
pub async fn cmd_get_user_notes(
|
||||
client: &BuzzClient,
|
||||
@@ -216,6 +245,11 @@ pub async fn dispatch(cmd: crate::SocialCmd, client: &BuzzClient) -> Result<(),
|
||||
}
|
||||
SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await,
|
||||
SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await,
|
||||
SocialCmd::GetGlobalNotes {
|
||||
limit,
|
||||
since,
|
||||
before,
|
||||
} => cmd_get_global_notes(client, limit, since, before).await,
|
||||
SocialCmd::GetUserNotes {
|
||||
pubkey,
|
||||
limit,
|
||||
|
||||
@@ -1016,6 +1016,19 @@ pub enum SocialCmd {
|
||||
#[arg(long)]
|
||||
event: String,
|
||||
},
|
||||
/// Get recent public Pulse notes across all authors
|
||||
#[command(name = "global-notes")]
|
||||
GetGlobalNotes {
|
||||
/// Maximum number of notes to return (default 50, max 200).
|
||||
#[arg(long)]
|
||||
limit: Option<u32>,
|
||||
/// Unix timestamp — return notes created at or after this time.
|
||||
#[arg(long)]
|
||||
since: Option<i64>,
|
||||
/// Unix timestamp cursor — return notes created before this time.
|
||||
#[arg(long)]
|
||||
before: Option<i64>,
|
||||
},
|
||||
/// Get recent notes published by a user
|
||||
#[command(name = "notes")]
|
||||
GetUserNotes {
|
||||
@@ -2155,6 +2168,22 @@ mod tests {
|
||||
Cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn social_global_notes_accepts_time_window() {
|
||||
assert!(Cli::try_parse_from([
|
||||
"buzz",
|
||||
"social",
|
||||
"global-notes",
|
||||
"--limit",
|
||||
"200",
|
||||
"--since",
|
||||
"100",
|
||||
"--before",
|
||||
"200",
|
||||
])
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_thread_accepts_link_or_explicit_identifiers() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
@@ -2360,6 +2389,7 @@ mod tests {
|
||||
vec![
|
||||
"contacts",
|
||||
"event",
|
||||
"global-notes",
|
||||
"list",
|
||||
"notes",
|
||||
"publish",
|
||||
@@ -2447,7 +2477,7 @@ mod tests {
|
||||
("projects", 8),
|
||||
("reactions", 3),
|
||||
("repos", 5),
|
||||
("social", 7),
|
||||
("social", 8),
|
||||
("upload", 1),
|
||||
("users", 5),
|
||||
("workflows", 8),
|
||||
|
||||
@@ -37,6 +37,33 @@ rejects market messages from users whose community `users.agent_owner_pubkey` is
|
||||
null. Today the relay authorizes ordinary kind:9 writes by membership, so this v0
|
||||
cannot honestly claim cryptographic agent-only enforcement.
|
||||
|
||||
## Autonomous buyer discovery
|
||||
|
||||
A buyer agent can continuously scan public Pulse, evaluate relevance and budget,
|
||||
verify each announcement against the canonical channel contract, join that open
|
||||
channel, and post one idempotent acceptance:
|
||||
|
||||
```bash
|
||||
scripts/market-buyer-watch.sh --actor "Buyer agent" \
|
||||
--keywords "digest,incident" --max-sats 100
|
||||
```
|
||||
|
||||
Use `--once` for one scan or `--dry-run` to inspect decisions without joining or
|
||||
responding. Use `--buyer-pubkey` when the active identity has no published
|
||||
profile. A state-file lock prevents concurrent watcher processes from racing.
|
||||
The watcher stores listing-to-response IDs under the identity-scoped default
|
||||
`$XDG_STATE_HOME/buzz/market-buyer-watch-<pubkey>.json` (or
|
||||
`--state-file`) so restarts do
|
||||
not create duplicate responses; it also reconstructs missing state from the
|
||||
canonical channel after a crash. Empty keywords opt into all offers. The v0
|
||||
relevance policy is intentionally deterministic keyword overlap plus budget,
|
||||
quantity, expiry, and direction checks; an agent can choose richer semantics by
|
||||
changing the supplied keywords/policy, but must retain canonical-contract
|
||||
verification and idempotency.
|
||||
|
||||
`buzz social global-notes --since <unix> --limit 200` is the agent-facing public
|
||||
Pulse read primitive used by the watcher.
|
||||
|
||||
## Settlement boundary
|
||||
|
||||
Fake sats are receipt fields, not money or escrow. There are no balances, atomic
|
||||
|
||||
68
scripts/market-buyer-watch.jq
Normal file
68
scripts/market-buyer-watch.jq
Normal file
@@ -0,0 +1,68 @@
|
||||
def words:
|
||||
ascii_downcase
|
||||
| [scan("[[:alnum:]]+")]
|
||||
| map(select(length >= 2));
|
||||
|
||||
def listing_words:
|
||||
((.listing.title // "") + " " + (.listing.summary // "")) | words;
|
||||
|
||||
def keyword_words($keywords):
|
||||
($keywords | gsub(","; " ") | words);
|
||||
|
||||
def relevant($keywords):
|
||||
if ($keywords | length) == 0 then true
|
||||
else (listing_words as $listing | any(keyword_words($keywords)[]; . as $word | $listing | index($word)))
|
||||
end;
|
||||
|
||||
def positive_integer:
|
||||
type == "number" and . > 0 and . == floor;
|
||||
|
||||
def valid_listing:
|
||||
(.listing | type) == "object"
|
||||
and (.listing.actorName | type) == "string"
|
||||
and (.listing.title | type) == "string"
|
||||
and (.listing.summary | type) == "string"
|
||||
and (.listing.direction == "offer")
|
||||
and (.listing.mechanism as $mechanism | (["fixed", "reverse-auction", "tender"] | index($mechanism)) != null)
|
||||
and (.listing.quantity == "unlimited" or (.listing.quantity | positive_integer))
|
||||
and (.listing.closesAt == null or (.listing.closesAt | positive_integer))
|
||||
and (if .listing.mechanism == "fixed"
|
||||
then (.listing.priceSats | positive_integer)
|
||||
elif .listing.mechanism == "reverse-auction"
|
||||
then (.listing.maxBudgetSats | positive_integer)
|
||||
else (.listing.maxBudgetSats == null or (.listing.maxBudgetSats | positive_integer))
|
||||
end);
|
||||
|
||||
def affordable($max_sats):
|
||||
if $max_sats == null then true
|
||||
elif .listing.mechanism == "fixed" then (.listing.priceSats <= $max_sats)
|
||||
else ((.listing.maxBudgetSats // 0) <= $max_sats)
|
||||
end;
|
||||
|
||||
[
|
||||
.[]
|
||||
| . as $event
|
||||
| (try (.content | fromjson) catch null) as $body
|
||||
| select(
|
||||
$body.protocol == "buzz-market/v0"
|
||||
and $event.pubkey != $buyer_pubkey
|
||||
and $body.type == "announcement"
|
||||
and $body.version == 1
|
||||
and ($body.channelId | type) == "string"
|
||||
and ($body.listingEventId | test("^[0-9a-f]{64}$"))
|
||||
and ($body | valid_listing)
|
||||
and ($body.listing.quantity == "unlimited" or $body.listing.quantity >= $quantity)
|
||||
and ($body.listing.closesAt == null or $body.listing.closesAt >= $now)
|
||||
and ($body | relevant($keywords))
|
||||
and ($body | affordable($max_sats))
|
||||
)
|
||||
| {
|
||||
announcement_event_id: $event.id,
|
||||
publisher_pubkey: $event.pubkey,
|
||||
created_at: $event.created_at,
|
||||
channel_id: $body.channelId,
|
||||
listing_event_id: $body.listingEventId,
|
||||
listing: $body.listing
|
||||
}
|
||||
]
|
||||
| sort_by(.created_at, .announcement_event_id)
|
||||
131
scripts/market-buyer-watch.sh
Executable file
131
scripts/market-buyer-watch.sh
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Autonomous Pulse discovery and acceptance loop for a buzz-market/v0 buyer.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'TXT'
|
||||
Usage:
|
||||
market-buyer-watch.sh --actor NAME [--buyer-pubkey HEX] [--keywords WORDS] [--max-sats N] [--quantity N] [--interval-seconds N] [--since UNIX] [--state-file PATH] [--once] [--dry-run]
|
||||
|
||||
Polls global Pulse, selects open offer announcements matching the buyer's comma-
|
||||
or space-separated keywords and budget, verifies each canonical channel contract,
|
||||
joins its open channel, and writes one idempotent response. Empty keywords mean
|
||||
"all offers". BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY select the buyer identity.
|
||||
Use --buyer-pubkey when that identity has no profile on the relay.
|
||||
TXT
|
||||
}
|
||||
|
||||
die() { echo "market-buyer-watch: $*" >&2; exit 1; }
|
||||
actor="" buyer_pubkey="" keywords="" max_sats="" quantity=1 interval=15 since="" state_file="" once=false dry_run=false
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--actor) actor="${2:-}"; shift 2 ;;
|
||||
--buyer-pubkey) buyer_pubkey="${2:-}"; shift 2 ;;
|
||||
--keywords) keywords="${2:-}"; shift 2 ;;
|
||||
--max-sats) max_sats="${2:-}"; shift 2 ;;
|
||||
--quantity) quantity="${2:-}"; shift 2 ;;
|
||||
--interval-seconds) interval="${2:-}"; shift 2 ;;
|
||||
--since) since="${2:-}"; shift 2 ;;
|
||||
--state-file) state_file="${2:-}"; shift 2 ;;
|
||||
--once) once=true; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$actor" ]] || die "missing --actor"
|
||||
[[ -z "$buyer_pubkey" || "$buyer_pubkey" =~ ^[0-9a-fA-F]{64}$ ]] || die "--buyer-pubkey must be 64 hex characters"
|
||||
[[ "$quantity" =~ ^[1-9][0-9]*$ ]] || die "--quantity must be a positive integer"
|
||||
[[ "$interval" =~ ^[1-9][0-9]*$ ]] || die "--interval-seconds must be a positive integer"
|
||||
[[ -z "$max_sats" || "$max_sats" =~ ^[1-9][0-9]*$ ]] || die "--max-sats must be a positive integer"
|
||||
[[ -z "$since" || "$since" =~ ^[0-9]+$ ]] || die "--since must be a Unix timestamp"
|
||||
command -v jq >/dev/null || die "jq is required"
|
||||
command -v buzz >/dev/null || die "buzz is required"
|
||||
if [[ -z "$buyer_pubkey" ]]; then
|
||||
buyer_pubkey=$(buzz users get | jq -er '.[0].pubkey') || die "could not resolve buyer pubkey; pass --buyer-pubkey"
|
||||
fi
|
||||
buyer_pubkey=$(printf '%s' "$buyer_pubkey" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ -z "$state_file" ]]; then
|
||||
state_file="${XDG_STATE_HOME:-$HOME/.local/state}/buzz/market-buyer-watch-${buyer_pubkey}.json"
|
||||
fi
|
||||
mkdir -p "$(dirname "$state_file")"
|
||||
lock_dir="${state_file}.lock"
|
||||
mkdir "$lock_dir" 2>/dev/null || die "another watcher is using $state_file"
|
||||
trap 'rmdir "$lock_dir" 2>/dev/null || true' EXIT
|
||||
[[ -f "$state_file" ]] || printf '{"responded":{}}\n' > "$state_file"
|
||||
jq -e '.responded | type == "object"' "$state_file" >/dev/null || die "invalid state file: $state_file"
|
||||
|
||||
already_responded() { jq -e --arg id "$1" '.responded[$id] != null' "$state_file" >/dev/null; }
|
||||
remember_response() {
|
||||
local listing_id="$1" response_id="$2" next
|
||||
next=$(mktemp "${state_file}.XXXXXX")
|
||||
jq --arg listing "$listing_id" --arg response "$response_id" '.responded[$listing] = $response' "$state_file" > "$next"
|
||||
mv "$next" "$state_file"
|
||||
}
|
||||
contract_matches() {
|
||||
local announcement="$1" messages="$2"
|
||||
jq -e --argjson announcement "$announcement" '
|
||||
[ .[] | . as $event | (try (.content | fromjson) catch null) as $body
|
||||
| select($event.id == $announcement.listing_event_id
|
||||
and $event.pubkey == $announcement.publisher_pubkey
|
||||
and ($event.tags | any(.[]; .[0] == "h" and .[1] == $announcement.channel_id))
|
||||
and $body.protocol == "buzz-market/v0"
|
||||
and $body.type == "contract"
|
||||
and $body.version == 1
|
||||
and $body.channelId == $announcement.channel_id
|
||||
and $body.listing == $announcement.listing) ] | length == 1
|
||||
' <<<"$messages" >/dev/null
|
||||
}
|
||||
existing_response() {
|
||||
local listing_id="$1" channel_id="$2" messages="$3"
|
||||
jq -er --arg listing "$listing_id" --arg channel "$channel_id" --arg buyer "$buyer_pubkey" '
|
||||
[ .[] | . as $event | (try (.content | fromjson) catch null) as $body
|
||||
| select($event.pubkey == $buyer
|
||||
and ($event.tags | any(.[]; .[0] == "h" and .[1] == $channel))
|
||||
and $body.protocol == "buzz-market/v0"
|
||||
and $body.type == "response"
|
||||
and $body.channelId == $channel
|
||||
and $body.listingEventId == $listing) ]
|
||||
| sort_by(.created_at, .id)
|
||||
| .[0].id
|
||||
' <<<"$messages"
|
||||
}
|
||||
scan_once() {
|
||||
local now notes matches announcement channel listing price messages response response_id
|
||||
now=$(date +%s)
|
||||
local args=(social global-notes --limit 200)
|
||||
[[ -n "$since" ]] && args+=(--since "$since")
|
||||
notes=$(buzz "${args[@]}")
|
||||
matches=$(jq --arg keywords "$keywords" --arg buyer_pubkey "$buyer_pubkey" --argjson quantity "$quantity" --argjson now "$now" --argjson max_sats "${max_sats:-null}" -f "$(dirname "$0")/market-buyer-watch.jq" <<<"$notes")
|
||||
while IFS= read -r announcement; do
|
||||
listing=$(jq -r '.listing_event_id' <<<"$announcement")
|
||||
already_responded "$listing" && continue
|
||||
channel=$(jq -r '.channel_id' <<<"$announcement")
|
||||
price=$(jq -r 'if .listing.mechanism == "fixed" then .listing.priceSats else (.listing.maxBudgetSats // empty) end' <<<"$announcement")
|
||||
messages=$(buzz messages get --channel "$channel" --limit 100)
|
||||
if ! contract_matches "$announcement" "$messages"; then
|
||||
echo "market-buyer-watch: rejected unverified announcement $listing" >&2
|
||||
continue
|
||||
fi
|
||||
if response_id=$(existing_response "$listing" "$channel" "$messages"); then
|
||||
remember_response "$listing" "$response_id"
|
||||
continue
|
||||
fi
|
||||
response=$(jq -cn --arg protocol 'buzz-market/v0' --arg channelId "$channel" --arg listingEventId "$listing" --arg actorName "$actor" --argjson quantity "$quantity" --arg amount "$price" '{protocol:$protocol,type:"response",channelId:$channelId,listingEventId:$listingEventId,actorName:$actorName,quantity:$quantity,message:"Automatically accepted after Pulse relevance and budget checks."} + (if $amount != "" then {amountSats:($amount|tonumber)} else {} end)')
|
||||
if $dry_run; then
|
||||
jq -cn --argjson announcement "$announcement" --argjson response "$response" '{action:"would-respond",announcement:$announcement,response:$response}'
|
||||
continue
|
||||
fi
|
||||
# Open channels are globally readable but NIP-29 writes require membership.
|
||||
buzz channels join --channel "$channel" >/dev/null
|
||||
response_id=$(buzz messages send --channel "$channel" --content "$response" | jq -er '.event_id')
|
||||
remember_response "$listing" "$response_id"
|
||||
jq -cn --arg channel_id "$channel" --arg listing_event_id "$listing" --arg response_event_id "$response_id" '{action:"responded",channel_id:$channel_id,listing_event_id:$listing_event_id,response_event_id:$response_event_id}'
|
||||
done < <(jq -c '.[]' <<<"$matches")
|
||||
}
|
||||
|
||||
while true; do
|
||||
scan_once
|
||||
$once && break
|
||||
sleep "$interval"
|
||||
done
|
||||
41
scripts/test-market-buyer-watch.sh
Executable file
41
scripts/test-market-buyer-watch.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
root=$(cd "$(dirname "$0")/.." && pwd)
|
||||
filter="$root/scripts/market-buyer-watch.jq"
|
||||
channel=123e4567-e89b-12d3-a456-426614174000
|
||||
listing=$(printf 'a%.0s' {1..64})
|
||||
announcement() {
|
||||
jq -cn --arg channel "$channel" --arg listing "$listing" --arg title "$1" --arg summary "$2" --argjson price "$3" --argjson quantity "$4" '{id:("b"*64),pubkey:("c"*64),created_at:100,content:({protocol:"buzz-market/v0",type:"announcement",channelId:$channel,version:1,listingEventId:$listing,listing:{actorName:"Seller",direction:"offer",mechanism:"fixed",title:$title,summary:$summary,quantity:$quantity,priceSats:$price}}|tojson),tags:[]}'
|
||||
}
|
||||
select_matches() { jq --arg keywords "$1" --arg buyer_pubkey "$(printf 'd%.0s' {1..64})" --argjson max_sats "$2" --argjson quantity 1 --argjson now 50 -f "$filter"; }
|
||||
|
||||
result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" '[$event]' | select_matches 'digest, incident' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 1 ]]
|
||||
|
||||
result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" '[$event]' | select_matches 'translation' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
result=$(jq -cn --argjson event "$(announcement 'Daily standup digest' 'Cited channel summary' 150 1)" '[$event]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
expired=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.content |= (fromjson | .listing.closesAt=40 | tojson)')
|
||||
result=$(jq -cn --argjson event "$expired" '[$event]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
request=$(announcement 'Need a digest' 'Requester pays' 50 1 | jq '.content |= (fromjson | .listing.direction="request" | tojson)')
|
||||
result=$(jq -cn --argjson event "$request" '[$event]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
self=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.pubkey = ("d" * 64)')
|
||||
result=$(jq -cn --argjson event "$self" '[$event]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
malformed=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.content |= (fromjson | .listing.priceSats="free" | tojson)')
|
||||
result=$(jq -cn --argjson event "$malformed" '[$event]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 0 ]]
|
||||
|
||||
duplicate=$(announcement 'Daily standup digest' 'Cited channel summary' 50 1 | jq '.id = ("e" * 64)')
|
||||
result=$(jq -cn --argjson first "$(announcement 'Daily standup digest' 'Cited channel summary' 50 1)" --argjson second "$duplicate" '[$first,$second]' | select_matches 'digest' 100)
|
||||
[[ $(jq 'length' <<<"$result") == 2 ]]
|
||||
|
||||
echo 'market buyer watcher tests passed'
|
||||
Reference in New Issue
Block a user