storebound/cli/reviews.ts

203 lines
11 KiB
TypeScript

// Store reviews and replies.
// storebound reviews list [--store apple|play] [--days 14] [--unanswered] [--rating 1,2] [--json]
// storebound reviews draft [--store apple|play] [--days 14] [--rating 1,2,3] [--all] [--dir .storebound/replies] [--dry]
// storebound reviews send [--dir .storebound/replies] [--dry] [--yes]
// storebound reviews drafts [--json] the local drafts with the checks `send` applies (offline)
// storebound reviews approve <draft file> [--undo] [--dry] sets approved: true (false) in one draft (local file)
// storebound reviews reply <draft file> --text "..." [--dry] writes the reply of one draft (local file; approval reset)
// storebound reviews send --file <draft file> ... sends that one approved draft only
// `draft` writes one Markdown file per unanswered review into .storebound/replies/ (gitignored): front matter
// (store, review_id, rating, approved: false), the review, and an empty "## Reply" section. Existing files are never
// overwritten (your edits stay). The owner or an agent with the review-replies skill writes the reply; the owner sets
// `approved: true`. `send` sends only approved, unsent drafts that pass the checks (length, no incentives), after a
// confirmation, then writes `sent: <time>` into the file. Replies are public: `send` is not an MCP tool.
// Google Play's API only returns reviews created or edited in the last 7 days.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import { isConflict } from './lib/upsert';
import { ascClient, type J } from './apple/asc';
import type { Ctx } from './lib/ctx';
import { confirm } from './lib/ctx';
import { done, log, printJson, warn } from './lib/log';
import {
checkReply, type Draft, draftMarkdown, draftName, filterReviews, markSent, parseAppleReviews, parseDraft, parsePlayReviews, replyRequest,
type Review, sendPlan,
} from './lib/reviews';
import { textTable } from './lib/table';
import { readReplyDrafts, setApproved, setReplyText } from './lib/project-files';
import { playClient } from './play/client';
const TAG = 'reviews';
async function appleReviews(ctx: Ctx, since: string): Promise<Review[]> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const out: Review[] = [];
let url: string | null = `/v1/apps/${await c.app()}/customerReviews?sort=-createdDate&include=response&limit=200`;
while (url) {
const page: J = await c.asc('GET', url);
const rs = parseAppleReviews(page);
out.push(...rs);
if (!rs.length || rs.at(-1)!.date < since) break;
url = page.links?.next ?? null;
}
log(TAG, `apple: ${out.length} reviews read`);
return out;
}
async function playReviews(ctx: Ctx): Promise<Review[]> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const out: Review[] = [];
let token = '';
do {
const r = await play.call<{ reviews?: unknown[]; tokenPagination?: { nextPageToken?: string } }>('GET', `${play.API}/reviews?maxResults=100${token ? `&token=${encodeURIComponent(token)}` : ''}`);
out.push(...parsePlayReviews(r as Parameters<typeof parsePlayReviews>[0]));
token = r.tokenPagination?.nextPageToken ?? '';
} while (token);
log(TAG, `play: ${out.length} reviews read (the API covers the last 7 days)`);
return out;
}
async function collect(ctx: Ctx, o: { since: string; unanswered: boolean; ratings: number[] }): Promise<Review[]> {
const { config } = ctx.loaded;
const store = ctx.args.get('store');
if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play');
const all: Review[] = [];
if ((!store || store === 'apple') && config.apple) {
try { all.push(...await appleReviews(ctx, o.since)); } catch (e) { if (store) throw e; warn(TAG, `apple: ${(e as Error).message.slice(0, 200)}`); }
}
if ((!store || store === 'play') && config.play) {
try { all.push(...await playReviews(ctx)); } catch (e) { if (store) throw e; warn(TAG, `play: ${(e as Error).message.slice(0, 200)}`); }
}
return filterReviews(all, o);
}
const snippet = (r: Review) => `${r.title ? r.title + ': ' : ''}${r.body}`.replace(/\s+/g, ' ').slice(0, 70);
function readDrafts(dir: string): { drafts: Draft[]; texts: Map<string, string> } {
const texts = new Map<string, string>();
const drafts: Draft[] = [];
if (!existsSync(dir)) return { drafts, texts };
for (const f of readdirSync(dir).filter(n => /^(apple|play)-.+\.md$/.test(n)).sort()) {
const p = join(dir, f);
const t = readFileSync(p, 'utf8');
texts.set(p, t);
try { drafts.push(parseDraft(p, t)); } catch (e) { warn(TAG, (e as Error).message); }
}
return { drafts, texts };
}
export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const sub = a._[0];
const dir = ctx.loaded.resolve(a.get('dir') ?? '.storebound/replies');
const days = a.num('days', 14);
const since = new Date(Date.now() - days * 86_400_000).toISOString();
const ratings = a.list('rating').map(Number).filter(n => n >= 1 && n <= 5);
if (sub === 'list') {
const rs = await collect(ctx, { since, unanswered: a.has('unanswered'), ratings });
if (a.has('json')) { printJson(rs.map(({ author: _a, ...r }) => r)); return; }
console.log(textTable(['store', 'date', 'stars', 'where', 'replied', 'review'], rs.map(r => [r.store, r.date.slice(0, 10), r.rating, r.where ?? '', r.reply ? 'yes' : '', snippet(r)])));
done(TAG, `${rs.length} review(s) in the last ${days} days, ${rs.filter(r => !r.reply).length} without a reply`);
return;
}
if (sub === 'draft') {
const rs = await collect(ctx, { since, unanswered: !a.has('all'), ratings });
let made = 0, kept = 0;
if (!a.has('dry')) mkdirSync(dir, { recursive: true });
for (const r of rs) {
const p = join(dir, draftName(r));
if (existsSync(p)) { kept++; continue; }
if (a.has('dry')) log(TAG, `would write ${p}`);
else writeFileSync(p, draftMarkdown(r));
made++;
}
done(TAG, `${made} new draft(s)${a.has('dry') ? ' (dry run)' : ''}, ${kept} kept as they are, in ${dir}. Next: write each "## Reply", set approved: true, then storebound reviews send`);
return;
}
if (sub === 'send') {
const { drafts: all, texts } = readDrafts(dir);
const only = a.get('file');
if (only !== undefined && !/^(apple|play)-[A-Za-z0-9._-]{1,120}\.md$/.test(only)) throw new Error('--file: a draft file name in .storebound/replies');
const drafts = only === undefined ? all : all.filter(d => d.file.split(/[\\/]/).pop() === only);
if (only !== undefined && !drafts.length) throw new Error(`no draft ${only} in ${dir}`);
const plan = sendPlan(drafts);
for (const s of plan.skip) log(TAG, `skip ${s.file.split(/[\\/]/).pop()}: ${s.why}`);
for (const d of plan.send) for (const i of checkReply(d.reply, d.store)) warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${i.msg}`);
if (!plan.send.length) { done(TAG, 'nothing to send'); return; }
console.log(textTable(['store', 'review', 'chars', 'reply'], plan.send.map(d => [d.store, d.reviewId, [...d.reply].length, d.reply.replace(/\s+/g, ' ').slice(0, 70)])));
if (a.has('dry')) { done(TAG, `${plan.send.length} reply(ies) would be sent (dry run)`); return; }
if (!(await confirm(ctx, `Send ${plan.send.length} public reply(ies)? Users are notified and the replies show on the store.`))) return;
const { config, root } = ctx.loaded;
const c = plan.send.some(d => d.store === 'apple') ? await ascClient(root, config.apple!.bundleId, config.apple?.appId) : null;
const play = plan.send.some(d => d.store === 'play') ? await playClient(root, config.play!.packageName) : null;
let sent = 0;
for (const d of plan.send) {
const req = replyRequest(d, config.play?.packageName);
try {
// Play's :reply replaces the reply (safe to send again); Apple keeps one response per review and answers a
// second one with 409: then an earlier run sent it (and stopped before noting it), so it is noted now
if (d.store === 'apple') {
try { await c!.asc(req.method, req.url, req.body); } catch (e) {
if (!isConflict(e)) throw e;
warn(TAG, `${d.file.split(/[\\/]/).pop()}: the review already has a response on the App Store (an earlier run sent it); noted as sent, not sent again`);
}
} else await play!.call(req.method, req.url, req.body);
writeFileSync(d.file, markSent(texts.get(d.file)!, new Date().toISOString()));
sent++;
log(TAG, `sent ${d.file.split(/[\\/]/).pop()}`);
} catch (e) { warn(TAG, `${d.file.split(/[\\/]/).pop()}: ${(e as Error).message.slice(0, 300)}`); }
}
done(TAG, `${sent}/${plan.send.length} sent (Apple shows replies within about 24 hours)`);
return;
}
if (sub === 'drafts') {
// read-only: the local drafts with the checks `send` applies (no network)
const r = readReplyDrafts(dir);
if (a.has('json')) { printJson(r); return; }
console.log(textTable(['store', 'review', 'state', 'chars', 'checks'], r.drafts.map(d => [d.store, d.reviewId, d.sent ? `sent ${d.sent.slice(0, 10)}` : d.approved ? 'approved' : 'draft',
[...d.reply].length, d.issues.map(i => `${i.level}: ${i.msg}`).join('; ')])));
for (const p of r.problems) warn(TAG, p);
done(TAG, `${r.drafts.length} draft(s) in ${dir}`);
return;
}
if (sub === 'approve') {
// marks one draft approved (or not, with --undo); local file only, nothing is sent
const file = a.get('file') ?? a._[1];
if (!file) throw new Error('usage: storebound reviews approve <draft file> [--undo] [--dry]');
const p = resolve(dir, file);
if (relative(dir, p).startsWith('..') || !p.endsWith('.md') || !existsSync(p)) throw new Error(`no draft ${file} in ${dir}`);
const text = readFileSync(p, 'utf8');
const d = parseDraft(p, text);
const approved = !a.has('undo');
if (approved) {
const errors = checkReply(d.reply, d.store).filter(i => i.level === 'error');
if (errors.length) throw new Error(`${file}: ${errors.map(i => i.msg).join('; ')}`);
}
if (a.has('dry')) { done(TAG, `would set approved: ${approved} in ${p} (dry run)`); return; }
writeFileSync(p, setApproved(text, approved));
done(TAG, `${approved ? 'approved' : 'approval removed'}: ${p} (send with storebound reviews send)`);
return;
}
if (sub === 'reply') {
// writes the reply text of one draft (local file only); an edited reply needs a fresh approval
const file = a.get('file') ?? a._[1];
const text = a.get('text');
if (!file || text === undefined) throw new Error('usage: storebound reviews reply <draft file> --text "..." [--dry]');
const p = resolve(dir, file);
if (relative(dir, p).startsWith('..') || !p.endsWith('.md') || !existsSync(p)) throw new Error(`no draft ${file} in ${dir}`);
const d = parseDraft(p, readFileSync(p, 'utf8'));
const next = setReplyText(readFileSync(p, 'utf8'), text);
for (const i of checkReply(parseDraft(p, next).reply, d.store)) (i.level === 'error' ? warn : log)(TAG, `${file}: ${i.msg}`);
if (a.has('dry')) { done(TAG, `would write the reply (${[...text.trim()].length} characters) into ${p}${d.approved ? ' and reset its approval' : ''} (dry run)`); return; }
writeFileSync(p, next);
done(TAG, `reply saved in ${p}${d.approved ? ' (approval reset: approve it again)' : ''}`);
return;
}
throw new Error('usage: storebound reviews list | draft | drafts | approve | reply | send');
}