Skip to content

Commit 9dd2a3f

Browse files
authored
feat(socials): post to Bluesky and Nostr from the pit (#317)
* feat(socials): post to Bluesky and Nostr from the pit * test(socials): skip the PWA composer test when express is absent apps/pwa is not a pnpm workspace package, so the root `pnpm install` never installs its dependencies and CI's root `node --test` cannot resolve express. The composer test imported the route module statically, so that resolution failure crashed the file instead of skipping it. Probe for express with createRequire and defer the route import, matching the guard the other 38 PWA tests already use.
1 parent 5f6f548 commit 9dd2a3f

8 files changed

Lines changed: 337 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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Unit tests for the browser-side Nostr composer.
2+
//
3+
// Same shape as the other PWA tests: the route module pulls in express at load
4+
// time, so probe for it first and skip cleanly when the PWA dependencies aren't
5+
// installed — that keeps the root `pnpm test` green in a fresh clone.
6+
import assert from "node:assert/strict";
7+
import { createRequire } from "node:module";
8+
import test from "node:test";
9+
10+
const require = createRequire(import.meta.url);
11+
let hasDeps = true;
12+
try {
13+
require("express");
14+
} catch {
15+
hasDeps = false;
16+
}
17+
18+
const skip = hasDeps ? false : "PWA dependencies are not installed";
19+
20+
async function composer() {
21+
const { NOSTR_RELAYS, nostrComposerPage } = await import("../src/routes/socials.mjs");
22+
return { NOSTR_RELAYS, html: nostrComposerPage() };
23+
}
24+
25+
test("Nostr composer loads the pinned NIP-07/NIP-46 bridge", { skip }, async () => {
26+
const { html } = await composer();
27+
assert.match(html, /window\.nostr\.js@0\.5\.0\/dist\/window\.nostr\.min\.js/);
28+
assert.match(html, /window\.nostr\.getPublicKey\(\)/);
29+
assert.match(html, /window\.nostr\.signEvent\(/);
30+
});
31+
32+
test("Nostr composer creates kind-1 events and publishes to every named relay", { skip }, async () => {
33+
const { NOSTR_RELAYS, html } = await composer();
34+
assert.match(html, /kind: 1/);
35+
assert.match(html, /\["EVENT", event\]/);
36+
for (const relay of NOSTR_RELAYS) assert.ok(html.includes(relay), `${relay} is not rendered`);
37+
});
38+
39+
test("Nostr composer reads the draft from the fragment", { skip }, async () => {
40+
const { html } = await composer();
41+
assert.match(html, /location\.hash\.slice\(1\)/);
42+
assert.doesNotMatch(html, /location\.search/);
43+
});

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)