203 lines
11 KiB
TypeScript
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');
|
|
}
|