feat: initial implementation of chat in terminal from stream.place

This commit is contained in:
Stevan Freeborn committed 2026-08-26 21:32:59 -05:00
commit 689304656f
39 files changed
+4243

No files matched your search

+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env node
import http from "node:http";
import open from "open";
import os from 'os';
import {
buildAtprotoLoopbackClientMetadata,
NodeOAuthClient,
type NodeSavedSession,
type NodeSavedState,
} from "@atproto/oauth-client-node";
import { Client } from "@atproto/lex";
import * as place from "./lexicons/place.js";
import WebSocket from "ws";
import {
CompositeDidDocumentResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
LocalActorResolver,
WellKnownHandleResolver,
DohJsonHandleResolver,
CompositeHandleResolver,
} from "@atcute/identity-resolver";
import readline from "readline";
import path from "node:path";
import fs from "node:fs/promises";
const c = {
gray: (str: string) => `\x1b[90m${str}\x1b[0m`,
bold: (str: string) => `\x1b[1m${str}\x1b[22m`,
cyan: (str: string) => `\x1b[36m${str}\x1b[39m`,
green: (str: string) => `\x1b[32m${str}\x1b[39m`,
magenta: (str: string) => `\x1b[35m${str}\x1b[39m`,
yellow: (str: string) => `\x1b[33m${str}\x1b[39m`,
red: (str: string) => `\x1b[31m${str}\x1b[39m`,
};
const DEFAULT_JETSTREAM_URL =
"wss://jetstream.us-east.bsky.network/xrpc/network.bsky.jetstream.subscribeEvents?collections=place.stream.chat.message";
const CONFIG_DIR = path.join(os.homedir(), ".streamplace-term-chat");
await fs.mkdir(CONFIG_DIR, { recursive: true });
const SESSION_FILE = path.join(CONFIG_DIR, ".session-store.json");
const STATE_FILE = path.join(CONFIG_DIR, ".state-store.json");
async function loadJsonStore<T>(filePath: string): Promise<Map<string, T>> {
try {
const data = await fs.readFile(filePath, "utf-8");
return new Map(Object.entries(JSON.parse(data)));
} catch {
return new Map();
}
}
async function saveJsonStore<T>(
filePath: string,
store: Map<string, T>,
): Promise<void> {
try {
const obj = Object.fromEntries(store.entries());
await fs.writeFile(filePath, JSON.stringify(obj, null, 2), "utf-8");
} catch (err) {
console.error(`Failed to save store to ${filePath}:`, err);
}
}
const stateStore = await loadJsonStore<NodeSavedState>(STATE_FILE);
const sessionStore = await loadJsonStore<NodeSavedSession>(SESSION_FILE);
const handleResolver = new CompositeHandleResolver({
methods: {
dns: new DohJsonHandleResolver({
dohUrl: "https://mozilla.cloudflare-dns.com/dns-query",
}),
http: new WellKnownHandleResolver(),
},
});
const didResolver = new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
});
const actorResolver = new LocalActorResolver({
handleResolver,
didDocumentResolver: didResolver,
});
const handleCache = new Map<string, string>();
async function resolveHandle(did: string): Promise<string> {
const cached = handleCache.get(did);
if (cached) {
return cached;
}
try {
const actor = await actorResolver.resolve(did as any);
const identifier = actor.handle ?? actor.did;
handleCache.set(did, identifier);
return identifier;
} catch {
return did;
}
}
const oauthClient = new NodeOAuthClient({
clientMetadata: buildAtprotoLoopbackClientMetadata({
scope: "atproto repo:place.stream.chat.message?action=create",
redirect_uris: ["http://127.0.0.1:3000/callback"],
}),
stateStore: {
async get(key: string) {
return stateStore.get(key);
},
async set(key: string, value: NodeSavedState) {
stateStore.set(key, value);
await saveJsonStore(STATE_FILE, stateStore);
},
async del(key: string) {
stateStore.delete(key);
await saveJsonStore(STATE_FILE, stateStore);
},
},
sessionStore: {
async get(key: string) {
return sessionStore.get(key);
},
async set(key: string, value: NodeSavedSession) {
sessionStore.set(key, value);
await saveJsonStore(SESSION_FILE, sessionStore);
},
async del(key: string) {
sessionStore.delete(key);
await saveJsonStore(SESSION_FILE, sessionStore);
},
},
});
async function resolveToDid(input: string): Promise<string> {
if (input.startsWith("did:")) {
return input;
}
const resolved = await actorResolver.resolve(input as any);
return resolved.did;
}
async function login(handleOrDid: string) {
let userDid: string;
try {
userDid = await resolveToDid(handleOrDid);
} catch (err) {
console.error(c.red(`Failed to resolve handle "${handleOrDid}":`), err);
throw err;
}
try {
const session = await oauthClient.restore(userDid);
if (session) {
console.log(
c.green(`\nRestored existing session for @${handleOrDid} (${userDid})`),
);
return session;
}
} catch { }
console.log(c.gray(`\nInitiating browser login for @${handleOrDid}...`));
const authUrl = await oauthClient.authorize(handleOrDid, {
scope: "atproto repo:place.stream.chat.message?action=create",
});
const params = await new Promise<URLSearchParams>((resolve, reject) => {
const server = http.createServer((req, res) => {
if (!req.url) {
return;
}
const url = new URL(req.url, "http://127.0.0.1:3000");
if (url.pathname === "/callback") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(
"<h1>Authorized! You can close this tab and return to your terminal.</h1>",
);
resolve(url.searchParams);
server.close();
}
});
server.listen(3000, "127.0.0.1", () => {
console.log(
c.gray(
"Listening on http://127.0.0.1:3000/callback for OAuth redirect...",
),
);
open(authUrl.toString());
});
server.on("error", reject);
});
const { session } = await oauthClient.callback(params);
return session;
}
async function main() {
const args = process.argv.slice(2);
const handleArg = args.find((arg) => !arg.startsWith("--"));
const streamerFlagIndex = args.indexOf("--streamer");
const streamerInput =
streamerFlagIndex !== -1
? args[streamerFlagIndex + 1]
: process.env.STREAMER_DID;
if (!handleArg || args.includes("--help") || args.includes("-h")) {
console.log(
c.gray(`
Streamplace Chat Client
Usage:
npm start -- <your-handle> [--streamer <streamer-handle-or-did>]
Options:
--streamer Specify target streamer handle/DID to view and post chat messages.
Defaults to your own handle if omitted.
Examples:
npm start -- alice.bsky.social
npm start -- alice.bsky.social --streamer streamer.bsky.social
`),
);
process.exit(0);
}
const session = await login(handleArg);
const client = new Client(session);
let targetStreamerDid = session.did as string;
if (streamerInput) {
if (streamerInput.startsWith("did:")) {
targetStreamerDid = streamerInput;
} else {
try {
const resolved = await actorResolver.resolve(streamerInput as any);
targetStreamerDid = resolved.did;
} catch (err) {
console.error(
c.red(
`Could not resolve streamer handle "${streamerInput}". Using user's DID.`,
),
);
}
}
}
const connectedAsHandle = await resolveHandle(session.did);
const targetStreamerHandle = await resolveHandle(targetStreamerDid);
console.clear();
console.log(
c.green(c.bold("Connected as:")) +
` @${connectedAsHandle} (${session.did})`,
);
console.log(
c.yellow(c.bold("Chat room streamer:")) +
` @${targetStreamerHandle} (${targetStreamerDid})`,
);
console.log(
c.gray("Type your message and hit Enter. Commands: /quit, /help\n"),
);
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: c.cyan(c.bold("> ")),
});
let ws: WebSocket | null = null;
let isConnecting = false;
function connectWs() {
if (isConnecting) return;
isConnecting = true;
ws = new WebSocket(process.env.JETSTREAM_URL || DEFAULT_JETSTREAM_URL);
ws.on("open", () => {
isConnecting = false;
rl.prompt();
});
ws.on("message", async (msg) => {
try {
const jsonMsg = JSON.parse(msg.toString());
if (jsonMsg.payload?.record?.streamer !== targetStreamerDid) {
return;
}
const createdAt = new Date(jsonMsg.payload.record.createdAt);
const timestamp = createdAt.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
const senderDid = jsonMsg.payload.did;
const senderIdentifier = await resolveHandle(senderDid);
const messageText = jsonMsg.payload.record.text;
const formattedLine = `${c.gray(`[${timestamp}]`)} ${c.magenta("[SP]")} ${c.cyan(
c.bold(senderIdentifier),
)}: ${messageText}`;
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
console.log(formattedLine);
rl.prompt(true);
} catch { }
});
ws.on("error", (err) => {
readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0);
console.error(`[WebSocket Error] ${err.message}`);
rl.prompt(true);
});
ws.on("close", () => {
isConnecting = false;
setTimeout(connectWs, 3000);
});
}
connectWs();
rl.on("line", async (line) => {
process.stdout.moveCursor(0, -1);
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
const text = line.trim();
if (!text) {
rl.prompt();
return;
}
if (text === "/quit" || text === "/exit") {
rl.close();
return;
}
if (text === "/help") {
console.log("Commands: /quit, /exit, /help");
rl.prompt();
return;
}
try {
await client.create(place.stream.chat.message as any, {
streamer: targetStreamerDid,
text: text,
createdAt: new Date().toISOString(),
});
} catch (err) {
if (err instanceof Error) {
console.error(c.red(`Failed to send message: ${err.message}`));
}
}
rl.prompt();
});
rl.on("close", () => {
if (ws) {
ws.removeAllListeners();
ws.close();
}
console.log(c.gray("\nExiting chat. Goodbye!"));
process.exit(0);
});
}
main().catch((err) => {
if (err instanceof Error) {
console.error(c.red(`Fatal error: ${err.message}`));
}
process.exit(1);
});