diff --git a/boostagram.py b/boostagram.py new file mode 100755 index 0000000..88295b3 --- /dev/null +++ b/boostagram.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Send Podcasting 2.0 boostagrams via AlbyHub.""" + +import argparse +import json +import subprocess +import sys +import urllib.request +import xml.etree.ElementTree as ET + +PODCAST_NS = "https://podcastindex.org/namespace/1.0" +ALBY_CLI = ["npx", "@getalby/cli"] + + +def fetch_feed(url): + req = urllib.request.Request(url, headers={"User-Agent": "boostagram/1.0"}) + with urllib.request.urlopen(req, timeout=30) as resp: + return ET.fromstring(resp.read()) + + +def find_value_block(root, episode_query): + channel = root.find("channel") + if channel is None: + return None, None, None + + channel_title = channel.findtext("title", "Unknown Podcast") + items = channel.findall("item") + if not items: + vb = channel.find(f"{{{PODCAST_NS}}}value") + return vb, channel_title, None + + if episode_query: + match = None + for item in items: + title = item.findtext("title", "") + if episode_query.lower() in title.lower(): + match = item + break + if match: + vb = match.find(f"{{{PODCAST_NS}}}value") + if vb is not None: + return vb, channel_title, match + print(f"warning: no episode matching '{episode_query}', using latest", + file=sys.stderr) + + latest = items[0] + vb = latest.find(f"{{{PODCAST_NS}}}value") + if vb is not None: + return vb, channel_title, latest + + vb = channel.find(f"{{{PODCAST_NS}}}value") + return vb, channel_title, latest + + +def parse_recipients(value_block): + recipients = [] + for r in value_block.findall(f"{{{PODCAST_NS}}}valueRecipient"): + addr = r.get("address", "") + if not addr: + continue + recipients.append({ + "name": r.get("name", "Unknown"), + "type": r.get("type", "node"), + "address": addr, + "split": int(r.get("split", "1")), + "customKey": r.get("customKey"), + "customValue": r.get("customValue"), + }) + return recipients + + +def build_boostagram(feed_url, channel_title, episode_item, recipient, + amount_sats, message, sender, ts, app_name): + msats = amount_sats * 1000 + boost = { + "app_name": app_name, + "action": "boost", + "value_msat_total": msats, + "value_msat": msats, + "podcast": channel_title, + "url": feed_url, + "name": recipient["name"], + "sender_name": sender, + } + if message: + boost["message"] = message + if ts is not None: + boost["ts"] = ts + if episode_item is not None: + title = episode_item.findtext("title") + if title: + boost["episode"] = title + guid = episode_item.findtext(f"{{{PODCAST_NS}}}guid") + if guid: + boost["episode_guid"] = guid + elif episode_item.findtext("guid"): + boost["episode_guid"] = episode_item.findtext("guid") + return boost + + +def send_keysend(pubkey, sats, tlv_records): + cmd = ALBY_CLI + [ + "pay-keysend", + "-p", pubkey, + "--amount", str(sats), + "--currency", "BTC", + "--unit", "sats", + "--network", "lightning", + "--tlv-records", json.dumps(tlv_records), + ] + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + err = result.stderr.strip() + try: + err = json.loads(err).get("error", err) + except (json.JSONDecodeError, AttributeError): + pass + raise RuntimeError(err) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + return {"raw": result.stdout} + + +def main(): + parser = argparse.ArgumentParser(description="Send a Podcasting 2.0 boostagram") + parser.add_argument("--feed", required=True, help="Podcast RSS feed URL") + parser.add_argument("--amount", required=True, type=int, help="Total sats to boost") + parser.add_argument("--message", default="", help="Boostagram message") + parser.add_argument("--sender", required=True, help="Your display name") + parser.add_argument("--episode", default=None, help="Episode title (default: latest)") + parser.add_argument("--ts", type=int, default=None, help="Playback position in seconds") + parser.add_argument("--app-name", default="boostagram", help="App name for boostagram") + args = parser.parse_args() + + if args.amount <= 0: + print("error: --amount must be positive", file=sys.stderr) + sys.exit(1) + + root = fetch_feed(args.feed) + value_block, channel_title, episode_item = find_value_block(root, args.episode) + + if value_block is None: + print("error: no block found in feed", file=sys.stderr) + sys.exit(1) + + recipients = parse_recipients(value_block) + if not recipients: + print("error: no recipients found in value block", file=sys.stderr) + sys.exit(1) + + total_shares = sum(r["split"] for r in recipients) + if total_shares == 0: + print("error: total splits sum to zero", file=sys.stderr) + sys.exit(1) + + episode_title = episode_item.findtext("title", "latest") if episode_item is not None else "latest" + print(f"boosting {channel_title} - {episode_title}") + print(f"total: {args.amount} sats across {len(recipients)} recipients\n") + + ok = 0 + for r in recipients: + split_sats = max(1, args.amount * r["split"] // total_shares) + boostagram = build_boostagram( + args.feed, channel_title, episode_item, r, + split_sats, args.message, args.sender, args.ts, args.app_name, + ) + boost_json = json.dumps(boostagram, ensure_ascii=False) + boost_hex = boost_json.encode("utf-8").hex() + + tlv = [{"type": 7629169, "value": boost_hex}] + + if r["customKey"]: + custom_hex = r["customValue"].encode("utf-8").hex() if r["customValue"] else "" + tlv.append({"type": int(r["customKey"]), "value": custom_hex}) + + print(f" {r['name']}: {split_sats} sats") + try: + result = send_keysend(r["address"], split_sats, tlv) + preimage = result.get("preimage", "?") + print(f" -> ok (preimage: {preimage[:16]}...)") + ok += 1 + except RuntimeError as e: + print(f" -> failed: {e}") + + print(f"\n{ok}/{len(recipients)} payments succeeded") + sys.exit(0 if ok > 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/rm.md b/rm.md index e69de29..6f02519 100644 --- a/rm.md +++ b/rm.md @@ -0,0 +1,128 @@ +# Alby Command-line Boosting App + +# boostagram + +A command-line tool for sending [Podcasting 2.0](https://podcastindex.org/) boostagrams via [AlbyHub](https://github.com/getAlby/hub). The following workflow has been confirmed on NixOS, but should work about the same on other distros. + +## Prerequisites + +- NixOS with AlbyHub installed and funded (channels open) +- Network access to the AlbyHub machine +- Python 3 and Node.js available on the sending machine + +## Setup + +### 1. Add packages to configuration.nix + +```nix +environment.systemPackages = with pkgs; [ + nodejs + (python3.withPackages (ps: with ps; [ feedparser ])) +]; +``` + +Rebuild: + +```bash +sudo nixos-rebuild switch +``` + +### 2. Connect the Alby CLI to AlbyHub + +You have two options: + +#### Option A: Auth flow (recommended if you have HTTPS) + +```bash +npx @getalby/cli auth https://YOUR_ALBYHUB_ADDRESS --app-name "boostagram" +``` + +Open the URL it prints in your browser, approve the connection in AlbyHub, then: + +```bash +npx @getalby/cli auth --complete +``` + +#### Option B: NWC connection string (works without HTTPS) + +1. Open the AlbyHub web UI in your browser +2. Go to **Apps** or **NWC Connections** +3. Create a new connection named "boostagram" +4. Copy the `nostr+walletconnect://...` string +5. Paste it into the CLI: + +```bash +npx @getalby/cli connect "nostr+walletconnect://YOUR_CONNECTION_STRING" +``` + +### 3. Place the script + +Copy `boostagram` somewhere in your `$PATH` (e.g. `~/bin/` or `/usr/local/bin/`): + +```bash +cp boostagram ~/bin/boostagram +chmod +x ~/bin/boostagram +``` + +## Usage + +```bash +boostagram --feed --amount --message "" --sender "" +``` + +### Required flags + +| Flag | Description | +|------|-------------| +| `--feed` | Podcast RSS feed URL | +| `--amount` | Total sats to boost (split across recipients automatically) | +| `--sender` | Your display name | + +### Optional flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--message` | Boostagram message text | (none) | +| `--episode` | Episode title to boost (partial match, case-insensitive) | Latest episode | +| `--ts` | Playback position in seconds | (none) | +| `--app-name` | App name in the boostagram metadata | `boostagram` | + +### Examples + +Boost the last available episode: + +```bash +python ./boostagram --feed https://linuxunplugged.com/rss --amount 608221 --message "Dis me massage." --sender "poopie mcfartweiner" +``` + +Boost a specific episode: + +```bash +boostagram --feed https://linuxunplugged.com/rss \ + --amount 500 \ + --message "Loved this episode!" \ + --sender "YourName" \ + --episode "Oops! All Shells" +``` + +### How it works + +1. Fetches the RSS feed and finds the `` block +2. If `--episode` is given, matches against episode titles; otherwise uses the latest episode +3. Splits the sats across all recipients defined in the feed's value block +4. Constructs a BLIP-0010 boostagram JSON payload +5. Sends a keysend payment to each recipient via the Alby CLI with the boostagram attached as TLV record 7629169 + +## Troubleshooting + +**"error: no \ block found in feed"** +The podcast doesn't support Podcasting 2.0 value blocks. Not all podcasts have this. + +**"Timed out waiting for wallet approval"** +The Alby CLI isn't authenticated. Run the auth flow from step 2 above. + +**Auth URL won't load in browser** +Use the Tailscale IP instead of the hostname, or use Option B (NWC connection string). + +**Payments fail individually** +Check your AlbyHub has sufficient channel balance. Each recipient split is sent as a separate keysend payment.