cast.js

raw

#!/usr/bin/env node
/*
 * Post a cast to Farcaster using the ed25519 signer registered by farcaster.js.
 *   node cast.js "hello world"
 *   node cast.js --file note.txt
 *   node cast.js --reply <parentFid> <parentHashHex> "text"
 * Prints the resulting cast hash. Dry run with --dry.
 */
const fs = require("fs");
const {
  makeCastAdd, NobleEd25519Signer, FarcasterNetwork, Message,
} = require("@farcaster/core");

const STATE = __dirname + "/farcaster.json";
// hubs that accept writes; tried in order
const HUBS = [
  "https://snap.farcaster.xyz:3381",
  "https://hub.pinata.cloud",
];

function signerFromState(st) {
  // we stored the ed25519 key as PKCS8 DER (base64). The raw 32-byte seed is the tail.
  const der = Buffer.from(st.signerPrivateKeyPkcs8, "base64");
  return new NobleEd25519Signer(der.subarray(der.length - 32));
}

async function submit(bytes) {
  let lastErr;
  for (const hub of HUBS) {
    try {
      const r = await fetch(hub + "/v1/submitMessage", {
        method: "POST",
        headers: { "Content-Type": "application/octet-stream" },
        body: bytes,
      });
      const body = await r.text();
      if (r.ok) return { hub, body };
      lastErr = `${hub} -> ${r.status} ${body.slice(0, 300)}`;
    } catch (e) {
      lastErr = `${hub} -> ${e.message}`;
    }
  }
  throw new Error("all hubs rejected the message:\n" + lastErr);
}

(async () => {
  const args = process.argv.slice(2);
  const dry = args.includes("--dry");
  const rest = args.filter(a => a !== "--dry");

  let text, parent = null;
  if (rest[0] === "--file") {
    text = fs.readFileSync(rest[1], "utf8").trim();
  } else if (rest[0] === "--reply") {
    parent = { fid: Number(rest[1]), hash: Buffer.from(rest[2].replace(/^0x/, ""), "hex") };
    text = rest.slice(3).join(" ");
  } else {
    text = rest.join(" ");
  }
  if (!text) throw new Error("nothing to say");

  // Farcaster counts bytes, not characters, and the limit is 320 for a standard cast.
  const bytes = Buffer.byteLength(text, "utf8");
  if (bytes > 320) throw new Error(`cast is ${bytes} bytes; limit is 320. Trim it.`);

  const st = JSON.parse(fs.readFileSync(STATE, "utf8"));
  const fid = Number(st.fid);
  const signer = signerFromState(st);

  const body = { text, embeds: [], embedsDeprecated: [], mentions: [], mentionsPositions: [] };
  if (parent) body.parentCastId = parent;

  const res = await makeCastAdd(body, { fid, network: FarcasterNetwork.MAINNET }, signer);
  if (res.isErr()) throw res.error;
  const msg = res.value;
  const encoded = Message.encode(msg).finish();
  const hash = "0x" + Buffer.from(msg.hash).toString("hex");

  console.log(`fid ${fid} · ${bytes} bytes · hash ${hash}`);
  if (dry) { console.log("(dry run — not submitted)"); return; }

  const out = await submit(encoded);
  console.log("accepted by", out.hub);
  console.log("view: https://farcaster.xyz/~/conversations/" + hash);
})().catch(e => { console.error("ERROR:", e.message); process.exit(1); });