Sasquatch can now DRAFT actions from natural language and publish them only after the user reviews & confirms — it never publishes on its own. - useSasquatchActions: publishes confirmed drafts via the app's normal publish path. Supports post_status (NIP-38 kind 30315, auto-expiring) and list_gear (NIP-99 kind 30402, skateboarding-tagged), reusing the existing event schemas. - SasquatchActionCard: inline "review & confirm" card with editable key fields (status text / listing title, price, condition), Publish + Cancel, and done/cancelled states. Nothing is signed until Publish. - SasquatchGuide: new draft_action tool (Pro-gated). The AI emits a draft; a card renders in-chat; confirmDraft signs & publishes. Non-Pro users get an upgrade nudge. Draft lifecycle tracked per message. - Prompt teaches the model to use draft_action for "post/sell/list/set my status" requests and to fill gear fields from the stash first. Examples now working (Pro): "post that I'm skating Southbank for 3 hours" · "list my Spitfire wheels for 8000 sats". Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
/**
|
|
* useSasquatchActions — publish the actions Sasquatch drafts (Pro).
|
|
* ─────────────────────────────────────────────────────────────────────────────
|
|
* Sasquatch never publishes on its own. The AI proposes a structured DRAFT; the
|
|
* user reviews it in a confirmation card; only on confirm do we sign & publish
|
|
* here. All actions reuse the app's existing event schemas.
|
|
*
|
|
* Supported drafts:
|
|
* • post_status — NIP-38 skating status (kind 30315), optional expiry
|
|
* • list_gear — NIP-99 marketplace listing (kind 30402), skateboarding-tagged
|
|
*/
|
|
|
|
import { useCallback } from 'react';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
|
import { USER_STATUS_KIND } from '@/hooks/useUserStatus';
|
|
|
|
// ─── Draft shapes ─────────────────────────────────────────────────────────────
|
|
|
|
export interface PostStatusDraft {
|
|
type: 'post_status';
|
|
/** Freeform status text, e.g. "🛹 Skating Southbank". */
|
|
text: string;
|
|
/** Minutes until it auto-expires (default 120). */
|
|
expiresInMinutes?: number;
|
|
}
|
|
|
|
export interface ListGearDraft {
|
|
type: 'list_gear';
|
|
title: string;
|
|
description?: string;
|
|
/** Price in the given currency (default sats). Omit for "make offer". */
|
|
price?: number;
|
|
currency?: string;
|
|
/** deck | trucks | wheels | bearings | shoes | clothing | other */
|
|
category?: string;
|
|
condition?: string;
|
|
size?: string;
|
|
}
|
|
|
|
export type SasquatchDraft = PostStatusDraft | ListGearDraft;
|
|
|
|
// ─── Human-readable summary for the confirmation card ────────────────────────
|
|
|
|
export function summarizeDraft(d: SasquatchDraft): { title: string; lines: string[]; cta: string } {
|
|
if (d.type === 'post_status') {
|
|
const mins = d.expiresInMinutes ?? 120;
|
|
return {
|
|
title: 'Post a skating status',
|
|
lines: [`"${d.text}"`, `Auto-expires in ${mins} min`],
|
|
cta: 'Post status',
|
|
};
|
|
}
|
|
const lines: string[] = [];
|
|
if (d.category) lines.push(`Category: ${d.category}`);
|
|
if (d.condition) lines.push(`Condition: ${d.condition}`);
|
|
if (d.size) lines.push(`Size: ${d.size}`);
|
|
lines.push(d.price ? `Price: ${d.price.toLocaleString()} ${d.currency ?? 'sats'}` : 'Price: make offer');
|
|
if (d.description) lines.push(d.description);
|
|
return { title: `List "${d.title}" for sale`, lines, cta: 'Publish listing' };
|
|
}
|
|
|
|
// ─── The hook ─────────────────────────────────────────────────────────────────
|
|
|
|
export function useSasquatchActions() {
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
|
|
const publishDraft = useCallback(
|
|
async (draft: SasquatchDraft): Promise<{ ok: true; label: string }> => {
|
|
if (!user) throw new Error('You must be logged in.');
|
|
|
|
if (draft.type === 'post_status') {
|
|
const tags: string[][] = [['d', 'general']];
|
|
const mins = draft.expiresInMinutes ?? 120;
|
|
tags.push(['expiration', String(Math.floor(Date.now() / 1000) + mins * 60)]);
|
|
await publishEvent({ kind: USER_STATUS_KIND, content: draft.text.slice(0, 140), tags });
|
|
return { ok: true, label: 'Status posted 🛹' };
|
|
}
|
|
|
|
// list_gear
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const identifier = `gear-${now}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const currency = draft.currency ?? 'sats';
|
|
const tags: string[][] = [
|
|
['d', identifier],
|
|
['title', draft.title.slice(0, 120)],
|
|
['t', 'skateboarding'],
|
|
['listing_type', 'sell'],
|
|
['status', 'active'],
|
|
['published_at', String(now)],
|
|
['alt', `Gear listing: ${draft.title}`],
|
|
];
|
|
if (draft.category) tags.push(['t', draft.category]);
|
|
if (typeof draft.price === 'number' && draft.price > 0) {
|
|
tags.push(['price_type', 'fixed']);
|
|
tags.push(['price', String(draft.price), currency]);
|
|
} else {
|
|
tags.push(['price_type', 'offer']);
|
|
}
|
|
if (draft.condition) tags.push(['condition', draft.condition]);
|
|
if (draft.size) tags.push(['size', draft.size]);
|
|
|
|
await publishEvent({
|
|
kind: MARKETPLACE_ITEM_KIND,
|
|
content: (draft.description ?? '').slice(0, 2000),
|
|
tags,
|
|
});
|
|
return { ok: true, label: 'Listing published 💸' };
|
|
},
|
|
[user, publishEvent],
|
|
);
|
|
|
|
return { publishDraft };
|
|
}
|