Skip to content

Commit ae99fd3

Browse files
committed
feat(socials): post to Bluesky and Nostr from the pit
1 parent 5f6f548 commit ae99fd3

8 files changed

Lines changed: 318 additions & 0 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,24 @@ Alpaca's CLI has no confirmation prompts; `--submit` intentionally removes
211211
MoshCode's preview guard. Live trading additionally requires Alpaca's `--live`
212212
opt-in or corresponding environment setting.
213213

214+
### Social posting from the pit
215+
216+
The pit can hand a prepared post to Bluesky or Nostr without storing either
217+
account's credentials in MoshCode:
218+
219+
```text
220+
/socials
221+
/post bsky "shipped it 🤘"
222+
/post nostr "shipped it 🤘"
223+
```
224+
225+
Bluesky opens its official compose intent. Nostr opens the MoshCode composer,
226+
connects to a NIP-07 browser signer (or a NIP-46 bunker through
227+
[`window.nostr.js`](https://github.com/fiatjaf/window.nostr.js)), signs a kind-1
228+
event, and publishes it to the displayed relays. Both flows leave the final
229+
confirmation in the browser. If the pit is remote or headless, `/post` prints
230+
the composer URL instead.
231+
214232
## Browser terminal (`moshcode console`)
215233

216234
A real terminal in the browser — arrow keys, history, full-screen TUIs — because

apps/pwa/src/routes/socials.mjs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Browser-side social composers. The CLI only hands a draft to these pages;
2+
// account authorization and the final publish stay in the user's browser.
3+
import { Router } from "express";
4+
import { page, footer } from "../lib/html.mjs";
5+
6+
export const socialsRouter = Router();
7+
8+
export const NOSTR_RELAYS = [
9+
"wss://relay.damus.io",
10+
"wss://nos.lol",
11+
"wss://relay.primal.net",
12+
];
13+
14+
export function nostrComposerPage() {
15+
const relays = JSON.stringify(NOSTR_RELAYS);
16+
const body = `
17+
<header class="bar"><div class="wrap bar-inner">
18+
<a class="brand" href="/"><span class="mark">M</span>MOSHCODE<span class="app">socials</span></a>
19+
<a class="btn" href="/">Back to the pit</a>
20+
</div></header>
21+
<main class="wrap" style="max-width:760px;padding:44px 0 64px">
22+
<div class="label acid" style="margin-bottom:10px">NOSTR · KIND 1</div>
23+
<h1 style="font-size:2rem;margin:0 0 10px">Post from the pit.</h1>
24+
<p class="dim mono" style="margin:0 0 24px;line-height:1.65">Your draft stayed in the URL fragment—it was never sent to MoshCode. Connect a browser signer, review the text, then publish it to the relays below.</p>
25+
26+
<div class="card">
27+
<div class="card-head"><span class="h">Draft</span><span class="pill" id="count">0 chars</span></div>
28+
<div class="card-body">
29+
<textarea id="message" rows="8" autofocus placeholder="what's moshing?" style="width:100%;resize:vertical"></textarea>
30+
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px">
31+
<button class="btn acid" id="publish" type="button">Connect signer + publish</button>
32+
<span class="mono dim" id="status" role="status" aria-live="polite">nothing is posted until you click</span>
33+
</div>
34+
</div>
35+
</div>
36+
37+
<div class="card" style="margin-top:18px">
38+
<div class="card-head"><span class="h">Relays</span><span class="pill">publish to any that accept</span></div>
39+
<div class="card-body mono dim" style="font-size:.78rem;line-height:1.8">
40+
${NOSTR_RELAYS.map((relay) => `<div><span class="beat"></span> ${relay}</div>`).join("")}
41+
</div>
42+
</div>
43+
</main>${footer}
44+
45+
<script>
46+
window.wnjParams = { position: "bottom", accent: "green", compactMode: true };
47+
</script>
48+
<script src="https://cdn.jsdelivr.net/npm/window.nostr.js@0.5.0/dist/window.nostr.min.js"></script>
49+
<script>
50+
(function () {
51+
var RELAYS = ${relays};
52+
var message = document.getElementById("message");
53+
var publish = document.getElementById("publish");
54+
var status = document.getElementById("status");
55+
var count = document.getElementById("count");
56+
message.value = new URLSearchParams(location.hash.slice(1)).get("text") || "";
57+
58+
function recount() { count.textContent = Array.from(message.value).length + " chars" }
59+
recount();
60+
message.addEventListener("input", recount);
61+
62+
function sendToRelay(relay, event) {
63+
return new Promise(function (resolve) {
64+
var settled = false;
65+
var ws;
66+
function done(ok, detail) {
67+
if (settled) return;
68+
settled = true;
69+
clearTimeout(timer);
70+
try { ws.close() } catch (_) {}
71+
resolve({ relay: relay, ok: ok, detail: detail || "" });
72+
}
73+
var timer = setTimeout(function () { done(false, "timeout") }, 8000);
74+
try { ws = new WebSocket(relay) } catch (error) { done(false, error.message); return }
75+
ws.addEventListener("open", function () { ws.send(JSON.stringify(["EVENT", event])) });
76+
ws.addEventListener("message", function (incoming) {
77+
try {
78+
var reply = JSON.parse(incoming.data);
79+
if (reply[0] === "OK" && reply[1] === event.id) done(Boolean(reply[2]), String(reply[3] || ""));
80+
} catch (_) {}
81+
});
82+
ws.addEventListener("error", function () { done(false, "connection failed") });
83+
ws.addEventListener("close", function () { done(false, "closed without an acknowledgement") });
84+
});
85+
}
86+
87+
publish.addEventListener("click", async function () {
88+
var content = message.value.trim();
89+
if (!content) { status.textContent = "write something first"; message.focus(); return }
90+
publish.disabled = true;
91+
status.textContent = "waiting for your signer…";
92+
try {
93+
if (!window.nostr) throw new Error("no Nostr signer is available in this browser");
94+
var pubkey = await window.nostr.getPublicKey();
95+
var event = await window.nostr.signEvent({
96+
kind: 1,
97+
created_at: Math.floor(Date.now() / 1000),
98+
tags: [],
99+
content: content,
100+
pubkey: pubkey
101+
});
102+
status.textContent = "signed—publishing to relays…";
103+
var results = await Promise.all(RELAYS.map(function (relay) { return sendToRelay(relay, event) }));
104+
var accepted = results.filter(function (result) { return result.ok });
105+
if (!accepted.length) {
106+
var reasons = results.map(function (result) { return result.relay + ": " + result.detail }).join(" · ");
107+
throw new Error("no relay accepted the event (" + reasons + ")");
108+
}
109+
status.textContent = "published to " + accepted.length + "/" + RELAYS.length + " relays 🤘";
110+
publish.textContent = "Published";
111+
} catch (error) {
112+
status.textContent = error && error.message ? error.message : String(error);
113+
publish.disabled = false;
114+
}
115+
});
116+
})();
117+
</script>`;
118+
119+
return page({ title: "moshcode ▸ post to Nostr", body });
120+
}
121+
122+
socialsRouter.get("/socials/nostr", (_req, res) => {
123+
res.type("html").send(nostrComposerPage());
124+
});

apps/pwa/src/server.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { cliRouter } from "./routes/cli.mjs";
1414
import { sessionsRouter } from "./routes/sessions.mjs";
1515
import { pagesRouter } from "./routes/pages.mjs";
1616
import { moshpitRouter } from "./routes/moshpit.mjs";
17+
import { socialsRouter } from "./routes/socials.mjs";
1718

1819
const app = express();
1920
app.disable("x-powered-by");
@@ -54,6 +55,7 @@ app.use(creditsRouter);
5455
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
5556
app.use(sessionsRouter); // /sessions (live CLI mirror) + /api/sessions
5657
app.use(pagesRouter); // /app, /settings
58+
app.use(socialsRouter); // public browser composers used by /post
5759
app.use(moshpitRouter); // /pit + /api/moshpit/* — the namespace
5860

5961
app.use((req, res) => res.status(404).type("html").send(

apps/pwa/test/socials.test.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { NOSTR_RELAYS, nostrComposerPage } from "../src/routes/socials.mjs";
5+
6+
test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", () => {
7+
const html = nostrComposerPage();
8+
assert.match(html, /window\.nostr\.js@0\.5\.0\/dist\/window\.nostr\.min\.js/);
9+
assert.match(html, /window\.nostr\.getPublicKey\(\)/);
10+
assert.match(html, /window\.nostr\.signEvent\(/);
11+
});
12+
13+
test("Nostr composer creates kind-1 events and publishes to every named relay", () => {
14+
const html = nostrComposerPage();
15+
assert.match(html, /kind: 1/);
16+
assert.match(html, /\["EVENT", event\]/);
17+
for (const relay of NOSTR_RELAYS) assert.ok(html.includes(relay), `${relay} is not rendered`);
18+
});
19+
20+
test("Nostr composer reads the draft from the fragment", () => {
21+
const html = nostrComposerPage();
22+
assert.match(html, /location\.hash\.slice\(1\)/);
23+
assert.doesNotMatch(html, /location\.search/);
24+
});

src/cli-schema.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,10 @@ export const PIT_COMMANDS = [
499499
description: "list workflow tools, or run one" },
500500
{ name: "trade", args: "<verb> [args…]", cli: "trade",
501501
description: "look up markets and preview/place Alpaca orders" },
502+
{ name: "socials", aliases: ["social"], pitOnly: true,
503+
description: "list social networks available for posting" },
504+
{ name: "post", args: '<social> "message"', pitOnly: true,
505+
description: "open a social composer with a prepared post" },
502506
{ name: "install", args: "<engine|tool>", cli: "install",
503507
description: "install an engine or workflow tool" },
504508
{ name: "upgrade", aliases: ["update"], args: "[name…]", cli: "upgrade",

src/socials.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { canOpenBrowser, openBrowser } from "./open-url.mjs";
2+
3+
const DEFAULT_APP = "https://app.moshcode.sh";
4+
5+
export const SOCIALS = [
6+
{
7+
name: "bluesky",
8+
aliases: ["bsky"],
9+
description: "official Bluesky browser composer",
10+
},
11+
{
12+
name: "nostr",
13+
aliases: [],
14+
description: "NIP-07/NIP-46 browser signer + relay publish",
15+
},
16+
];
17+
18+
export function resolveSocial(name) {
19+
const wanted = String(name ?? "").trim().toLowerCase();
20+
return SOCIALS.find((social) =>
21+
social.name === wanted || social.aliases.includes(wanted)) ?? null;
22+
}
23+
24+
function appOrigin(env = process.env) {
25+
return String(env.MOSHCODE_API || DEFAULT_APP).replace(/\/+$/, "");
26+
}
27+
28+
/**
29+
* Build the browser hand-off without opening anything. Nostr keeps the draft
30+
* in the fragment so it never reaches app.moshcode.sh access logs or Referer
31+
* headers; the composer reads it entirely in the browser.
32+
*/
33+
export function socialPostUrl(name, message, { env = process.env } = {}) {
34+
const social = resolveSocial(name);
35+
if (!social) return null;
36+
const text = String(message ?? "");
37+
if (social.name === "bluesky") {
38+
return `https://bsky.app/intent/compose?${new URLSearchParams({ text })}`;
39+
}
40+
return `${appOrigin(env)}/socials/nostr#${new URLSearchParams({ text })}`;
41+
}
42+
43+
export function socialRoster() {
44+
return SOCIALS.map((social) => ({ ...social, aliases: [...social.aliases] }));
45+
}
46+
47+
/**
48+
* Open a provider composer. Posting remains an explicit browser confirmation:
49+
* Bluesky requires it, and Nostr asks the browser signer before relay publish.
50+
*/
51+
export function postSocial(args, {
52+
env = process.env,
53+
canOpen = canOpenBrowser,
54+
open = openBrowser,
55+
} = {}) {
56+
const [requested, ...words] = Array.isArray(args) ? args : [];
57+
const social = resolveSocial(requested);
58+
if (!requested) return { ok: false, error: 'usage: /post <social> "message"' };
59+
if (!social) {
60+
return {
61+
ok: false,
62+
error: `unknown social "${requested}". try: ${SOCIALS.map((entry) => entry.name).join(", ")}`,
63+
};
64+
}
65+
66+
const message = words.join(" ").trim();
67+
if (!message) return { ok: false, error: 'usage: /post <social> "message"' };
68+
69+
const url = socialPostUrl(social.name, message, { env });
70+
const opened = Boolean(canOpen() && open(url));
71+
return { ok: true, social: social.name, message, url, opened };
72+
}

src/tui.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import path from "node:path";
1010
import { ENGINES, agentLaunchArgs, resolveEngine, engineStatus, openSession } from "./engines.mjs";
1111
import { TOOLS, resolveTool, toolStatus, openTool } from "./tools.mjs";
1212
import { tradeArgs, tradeUsage } from "./trade.mjs";
13+
import { postSocial, socialRoster } from "./socials.mjs";
1314
import { runUpgrade } from "./upgrade.mjs";
1415
import { locate, tilde } from "./pwd.mjs";
1516
import { createPrd, listPrds, authoringPrompt } from "./prd.mjs";
@@ -156,6 +157,15 @@ function printTools() {
156157
console.log(ash(" → ") + acid("https://dev.profullstack.com/"));
157158
}
158159

160+
function printSocials() {
161+
console.log(bone(" socials") + ash(" — compose with ") + acid('/post <social> "message"'));
162+
for (const social of socialRoster()) {
163+
const aliases = social.aliases.length ? ` (${social.aliases.join(", ")})` : "";
164+
console.log(` ${acid("●")} ${bone(social.name.padEnd(9))} ${ash(social.description + aliases)}`);
165+
}
166+
console.log(ash(" the browser always asks you to confirm before anything is published"));
167+
}
168+
159169
/**
160170
* The moshscript vocabulary, split the way the CLI's help splits it.
161171
*
@@ -660,6 +670,21 @@ export async function tui() {
660670
rl = mkrl();
661671
continue;
662672
}
673+
if (cmd === "socials" || cmd === "social") {
674+
printSocials();
675+
continue;
676+
}
677+
if (cmd === "post") {
678+
const result = postSocial(rest);
679+
if (!result.ok) { console.log(err(result.error)); continue; }
680+
if (result.opened) {
681+
console.log(ok(`opened the ${result.social} composer — confirm the post in your browser 🤘`));
682+
} else {
683+
console.log(info(`open this ${result.social} composer in a browser:`));
684+
console.log(` ${result.url}`);
685+
}
686+
continue;
687+
}
663688
// Bare engine name → open it.
664689
const resolved = resolveEngine(cmd);
665690
if (resolved) {

test/socials.test.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { postSocial, resolveSocial, socialPostUrl, socialRoster } from "../src/socials.mjs";
5+
6+
test("social roster includes Bluesky and Nostr with aliases", () => {
7+
assert.deepEqual(socialRoster().map((social) => social.name), ["bluesky", "nostr"]);
8+
assert.equal(resolveSocial("bsky")?.name, "bluesky");
9+
assert.equal(resolveSocial("NOSTR")?.name, "nostr");
10+
assert.equal(resolveSocial("twitter"), null);
11+
});
12+
13+
test("Bluesky posts use the official compose intent", () => {
14+
const url = new URL(socialPostUrl("bluesky", "hello & goodbye"));
15+
assert.equal(url.origin + url.pathname, "https://bsky.app/intent/compose");
16+
assert.equal(url.searchParams.get("text"), "hello & goodbye");
17+
});
18+
19+
test("Nostr drafts stay in the URL fragment and honor a self-hosted app", () => {
20+
const url = new URL(socialPostUrl("nostr", "draft #1", {
21+
env: { MOSHCODE_API: "https://mosh.example/" },
22+
}));
23+
assert.equal(url.origin + url.pathname, "https://mosh.example/socials/nostr");
24+
assert.equal(url.search, "");
25+
assert.equal(new URLSearchParams(url.hash.slice(1)).get("text"), "draft #1");
26+
});
27+
28+
test("postSocial opens a prepared composer when a browser is available", () => {
29+
let opened = "";
30+
const result = postSocial(["bsky", "two", "words"], {
31+
canOpen: () => true,
32+
open: (url) => { opened = url; return true; },
33+
});
34+
35+
assert.equal(result.ok, true);
36+
assert.equal(result.social, "bluesky");
37+
assert.equal(result.message, "two words");
38+
assert.equal(result.opened, true);
39+
assert.equal(opened, result.url);
40+
});
41+
42+
test("postSocial reports missing messages and unknown networks without opening", () => {
43+
let opens = 0;
44+
const options = { canOpen: () => true, open: () => { opens++; return true; } };
45+
assert.match(postSocial([], options).error, /usage: \/post/);
46+
assert.match(postSocial(["nostr"], options).error, /usage: \/post/);
47+
assert.match(postSocial(["twitter", "hello"], options).error, /unknown social/);
48+
assert.equal(opens, 0);
49+
});

0 commit comments

Comments
 (0)