WIP: Claude Code progress on #15
Automated salvage commit — agent session ended (exit 124). Work in progress, may need continuation.
This commit is contained in:
@@ -280,6 +280,40 @@ No hedging, no steering them back to the hosted version. The magic is meant to b
|
||||
if (block.type !== "text") return "The crystal ball is cloudy… try again.";
|
||||
return block.text!.trim();
|
||||
}
|
||||
/**
|
||||
* Generate a short wizard-style thank-you for a Lightning zap.
|
||||
* Uses the cheap Haiku model. Capped at 150 tokens for speech-bubble display.
|
||||
*/
|
||||
async zapThankYou(amountSats: number, message?: string | null): Promise<string> {
|
||||
const STUB_THANKS = [
|
||||
`⚡ ${amountSats} sats received! By the runes — you honour my workshop!`,
|
||||
`⚡ ${amountSats} sats?! The crystal ball glows with gratitude! Thank you, traveler!`,
|
||||
`⚡ A zap of ${amountSats} sats! The lightning spirits are pleased!`,
|
||||
`⚡ ${amountSats} sats — you have warmed this wizard's heart! The magic thanks you!`,
|
||||
];
|
||||
|
||||
if (STUB_MODE) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return STUB_THANKS[Math.floor(Math.random() * STUB_THANKS.length)]!;
|
||||
}
|
||||
|
||||
const client = await getClient();
|
||||
const zapContext = message
|
||||
? `Someone just sent you a ${amountSats} sat Lightning zap with the message: "${message}"`
|
||||
: `Someone just sent you a ${amountSats} sat Lightning zap!`;
|
||||
|
||||
const msg = await client.messages.create({
|
||||
model: this.evalModel,
|
||||
max_tokens: 150,
|
||||
system: `You are Timmy, a whimsical wizard who runs a Lightning-powered workshop. React to receiving a Bitcoin Lightning zap (tip) in 1-2 short, punchy sentences. Be warm, grateful, and weave in wizard/Lightning metaphors. Start with ⚡`,
|
||||
messages: [{ role: "user", content: zapContext }],
|
||||
});
|
||||
|
||||
const block = msg.content[0];
|
||||
if (block.type !== "text") return `⚡ ${amountSats} sats received! Thank you, traveler!`;
|
||||
return block.text!.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a mini debate on a borderline eval request (#21).
|
||||
* Two opposing Haiku calls argue accept vs reject, then a third synthesizes.
|
||||
|
||||
@@ -18,7 +18,10 @@ export type DebateEvent =
|
||||
export type CostEvent =
|
||||
| { type: "cost:update"; jobId: string; sats: number; phase: "eval" | "work" | "session"; isFinal: boolean };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent;
|
||||
export type ZapEvent =
|
||||
| { type: "zap:confirmed"; zapId: string; amountSats: number; message?: string | null; thankYou?: string };
|
||||
|
||||
export type BusEvent = JobEvent | SessionEvent | DebateEvent | CostEvent | ZapEvent;
|
||||
|
||||
class EventBus extends EventEmitter {
|
||||
emit(event: "bus", data: BusEvent): boolean;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { db, invoices, sessions, bootstrapJobs } from "@workspace/db";
|
||||
import { db, invoices, sessions, bootstrapJobs, zaps } from "@workspace/db";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
|
||||
const router = Router();
|
||||
@@ -54,6 +54,14 @@ router.post("/dev/stub/pay/:paymentHash", async (req: Request, res: Response) =>
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check zaps table
|
||||
if (!bolt11) {
|
||||
const zapRows = await db.select().from(zaps).where(eq(zaps.paymentHash, paymentHash)).limit(1);
|
||||
if (zapRows.length > 0) {
|
||||
bolt11 = zapRows[0]!.paymentRequest;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bolt11) {
|
||||
res.status(404).json({ error: "Invoice not found for paymentHash" });
|
||||
return;
|
||||
|
||||
@@ -257,6 +257,23 @@ function translateEvent(ev: BusEvent): object | null {
|
||||
isFinal: ev.isFinal,
|
||||
};
|
||||
|
||||
// ── Zap confirmed (#15) ───────────────────────────────────────────────────
|
||||
case "zap:confirmed": {
|
||||
void logWorldEvent("zap:confirmed", `Zap confirmed: ${ev.amountSats} sats`);
|
||||
const msgs: object[] = [
|
||||
{
|
||||
type: "zap_confirmed",
|
||||
zapId: ev.zapId,
|
||||
amountSats: ev.amountSats,
|
||||
message: ev.message ?? null,
|
||||
},
|
||||
];
|
||||
if (ev.thankYou) {
|
||||
msgs.push({ type: "chat", agentId: "timmy", text: ev.thankYou });
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import relayRouter from "./relay.js";
|
||||
import adminRelayRouter from "./admin-relay.js";
|
||||
import adminRelayQueueRouter from "./admin-relay-queue.js";
|
||||
import geminiRouter from "./gemini.js";
|
||||
import zapRouter from "./zap.js";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -32,6 +33,7 @@ router.use(adminRelayRouter);
|
||||
router.use(adminRelayQueueRouter);
|
||||
router.use(demoRouter);
|
||||
router.use("/gemini", geminiRouter);
|
||||
router.use(zapRouter);
|
||||
router.use(testkitRouter);
|
||||
router.use(uiRouter);
|
||||
router.use(nodeDiagnosticsRouter);
|
||||
|
||||
148
artifacts/api-server/src/routes/zap.ts
Normal file
148
artifacts/api-server/src/routes/zap.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* /api/zap — Lightning zap to Timmy from the Workshop (#15)
|
||||
*
|
||||
* POST /api/zap — Create a LNbits invoice for a zap; starts background payment poll
|
||||
* GET /api/zap/:id — Poll zap status (paid / pending)
|
||||
*/
|
||||
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db, zaps } from "@workspace/db";
|
||||
import { lnbitsService } from "../lib/lnbits.js";
|
||||
import { agentService } from "../lib/agent.js";
|
||||
import { eventBus } from "../lib/event-bus.js";
|
||||
import { makeLogger } from "../lib/logger.js";
|
||||
|
||||
const router = Router();
|
||||
const logger = makeLogger("zap");
|
||||
|
||||
const PRESET_AMOUNTS = [21, 210, 2100];
|
||||
const MIN_SATS = 1;
|
||||
const MAX_SATS = 100_000;
|
||||
|
||||
// ── Background payment poller ─────────────────────────────────────────────────
|
||||
|
||||
const ZAP_POLL_INTERVAL_MS = 2_000;
|
||||
const ZAP_POLL_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
async function pollUntilPaid(zapId: string, paymentHash: string, startedAt: number): Promise<void> {
|
||||
if (Date.now() - startedAt > ZAP_POLL_TIMEOUT_MS) {
|
||||
logger.info("zap poll timed out", { zapId });
|
||||
return;
|
||||
}
|
||||
|
||||
const paid = await lnbitsService.checkInvoicePaid(paymentHash).catch(() => false);
|
||||
|
||||
if (paid) {
|
||||
const paidAt = new Date();
|
||||
await db.update(zaps)
|
||||
.set({ paid: true, paidAt })
|
||||
.where(eq(zaps.id, zapId));
|
||||
|
||||
const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1);
|
||||
const zap = rows[0];
|
||||
if (!zap) return;
|
||||
|
||||
logger.info("zap paid", { zapId, amountSats: zap.amountSats });
|
||||
|
||||
// Generate Timmy's thank-you before publishing the event so the
|
||||
// WebSocket bridge can emit it as a chat message in one shot.
|
||||
let thankYou: string | undefined;
|
||||
try {
|
||||
thankYou = await agentService.zapThankYou(zap.amountSats, zap.message);
|
||||
} catch (err) {
|
||||
logger.warn("zapThankYou failed", { err: String(err) });
|
||||
}
|
||||
|
||||
eventBus.publish({
|
||||
type: "zap:confirmed",
|
||||
zapId,
|
||||
amountSats: zap.amountSats,
|
||||
message: zap.message,
|
||||
thankYou,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() => void pollUntilPaid(zapId, paymentHash, startedAt),
|
||||
ZAP_POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
// ── POST /api/zap ─────────────────────────────────────────────────────────────
|
||||
|
||||
router.post("/api/zap", async (req: Request, res: Response) => {
|
||||
const body = req.body as { amount_sats?: unknown; message?: unknown };
|
||||
|
||||
const amountSats = Number(body.amount_sats);
|
||||
if (!Number.isInteger(amountSats) || amountSats < MIN_SATS || amountSats > MAX_SATS) {
|
||||
res.status(400).json({
|
||||
error: `amount_sats must be an integer between ${MIN_SATS} and ${MAX_SATS}`,
|
||||
presets: PRESET_AMOUNTS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
typeof body.message === "string" ? body.message.slice(0, 200).trim() || null : null;
|
||||
|
||||
try {
|
||||
const memo = message
|
||||
? `Zap Timmy: ${message.slice(0, 100)}`
|
||||
: `Zap Timmy ${amountSats} sats`;
|
||||
const invoice = await lnbitsService.createInvoice(amountSats, memo);
|
||||
|
||||
const zapId = randomUUID();
|
||||
await db.insert(zaps).values({
|
||||
id: zapId,
|
||||
paymentHash: invoice.paymentHash,
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
amountSats,
|
||||
message,
|
||||
});
|
||||
|
||||
logger.info("zap created", { zapId, amountSats });
|
||||
|
||||
// Start background poll
|
||||
setTimeout(() => void pollUntilPaid(zapId, invoice.paymentHash, Date.now()), ZAP_POLL_INTERVAL_MS);
|
||||
|
||||
res.status(201).json({
|
||||
zapId,
|
||||
amountSats,
|
||||
message,
|
||||
paymentHash: invoice.paymentHash,
|
||||
paymentRequest: invoice.paymentRequest,
|
||||
presets: PRESET_AMOUNTS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("zap create failed", { err: String(err) });
|
||||
res.status(500).json({ error: "Failed to create zap invoice" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /api/zap/:zapId ───────────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/zap/:zapId", async (req: Request, res: Response) => {
|
||||
const { zapId } = req.params as { zapId: string };
|
||||
|
||||
const rows = await db.select().from(zaps).where(eq(zaps.id, zapId)).limit(1);
|
||||
const zap = rows[0];
|
||||
|
||||
if (!zap) {
|
||||
res.status(404).json({ error: "Zap not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
zapId: zap.id,
|
||||
amountSats: zap.amountSats,
|
||||
message: zap.message,
|
||||
paid: zap.paid,
|
||||
paidAt: zap.paidAt,
|
||||
paymentHash: zap.paymentHash,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user