168 lines
11 KiB
TypeScript
168 lines
11 KiB
TypeScript
// Store review replies as pure parts: normalising both stores' reviews, the local draft files the owner / agent edit
|
|
// (.storebound/replies/<store>-<id>.md, front matter + the review + "## Reply"), and the checks a reply must pass
|
|
// before `storebound reviews send` sends it (only drafts with `approved: true`, after a confirmation).
|
|
// Sources (checked October 2026):
|
|
// App Store: GET /v1/apps/{id}/customerReviews, POST /v1/customerReviewResponses (one response per review; a new POST
|
|
// replaces it; it shows after up to 24 hours) https://developer.apple.com/documentation/appstoreconnectapi/customer-review-responses
|
|
// Guidance: https://developer.apple.com/app-store/responding-to-reviews/ (no personal info, marketing or spam)
|
|
// Length: Apple documents no maximum; 5970 characters is the limit App Store Connect is commonly reported to
|
|
// enforce (NOT VERIFIED), so storebound warns above it instead of blocking.
|
|
// Google Play: reviews.list returns reviews created or edited in the last week only; reviews.reply {replyText},
|
|
// at most 350 characters (HTML tags stripped, not counted) https://developers.google.com/android-publisher/reply-to-reviews
|
|
import { len } from './copy';
|
|
import { type FmValue, parseFrontMatter, stringifyFrontMatter } from './frontmatter';
|
|
|
|
export type ReviewStore = 'apple' | 'play';
|
|
export const REPLY_LIMITS: Record<ReviewStore, number> = { play: 350, apple: 5970 };
|
|
|
|
export interface Review {
|
|
store: ReviewStore;
|
|
id: string;
|
|
rating: number;
|
|
title?: string;
|
|
body: string;
|
|
author?: string;
|
|
/** Apple: territory (alpha-3); Play: reviewer language. */
|
|
where?: string;
|
|
date: string;
|
|
appVersion?: string;
|
|
reply?: { text: string; date?: string; state?: string };
|
|
}
|
|
|
|
interface AppleReviewsResp {
|
|
data?: { id: string; attributes: { rating?: number; title?: string; body?: string; reviewerNickname?: string; createdDate?: string; territory?: string }; relationships?: { response?: { data?: { id: string } | null } } }[];
|
|
included?: { type: string; id: string; attributes?: { responseBody?: string; lastModifiedDate?: string; state?: string } }[];
|
|
}
|
|
|
|
export function parseAppleReviews(resp: AppleReviewsResp): Review[] {
|
|
const responses = new Map((resp.included ?? []).filter(x => x.type === 'customerReviewResponses').map(x => [x.id, x.attributes ?? {}] as const));
|
|
return (resp.data ?? []).map(r => {
|
|
const resp1 = responses.get(r.relationships?.response?.data?.id ?? '');
|
|
return { store: 'apple' as const, id: r.id, rating: r.attributes.rating ?? 0, title: r.attributes.title || undefined, body: r.attributes.body ?? '',
|
|
author: r.attributes.reviewerNickname, where: r.attributes.territory, date: r.attributes.createdDate ? new Date(r.attributes.createdDate).toISOString() : '',
|
|
...(resp1?.responseBody ? { reply: { text: resp1.responseBody, date: resp1.lastModifiedDate, state: resp1.state } } : {}) };
|
|
});
|
|
}
|
|
|
|
interface PlayReviewsResp {
|
|
reviews?: { reviewId: string; authorName?: string; comments?: { userComment?: { text?: string; lastModified?: { seconds?: string }; starRating?: number; reviewerLanguage?: string; appVersionName?: string };
|
|
developerComment?: { text?: string; lastModified?: { seconds?: string } } }[] }[];
|
|
}
|
|
|
|
const isoFromSeconds = (s?: string) => (s ? new Date(Number(s) * 1000).toISOString() : '');
|
|
|
|
export function parsePlayReviews(resp: PlayReviewsResp): Review[] {
|
|
return (resp.reviews ?? []).map(r => {
|
|
const u = r.comments?.find(c => c.userComment)?.userComment ?? {};
|
|
const d = r.comments?.find(c => c.developerComment)?.developerComment;
|
|
// A review with a separate title comes as "title\tbody".
|
|
const [title, body] = (u.text ?? '').includes('\t') ? (u.text ?? '').split('\t', 2) as [string, string] : [undefined, u.text ?? ''];
|
|
return { store: 'play' as const, id: r.reviewId, rating: u.starRating ?? 0, title: title?.trim() || undefined, body: body.trim(), author: r.authorName,
|
|
where: u.reviewerLanguage, date: isoFromSeconds(u.lastModified?.seconds), appVersion: u.appVersionName,
|
|
...(d?.text ? { reply: { text: d.text, date: isoFromSeconds(d.lastModified?.seconds) } } : {}) };
|
|
});
|
|
}
|
|
|
|
export interface StoreRead { store: 'apple' | 'play'; reviews?: Review[]; error?: string }
|
|
|
|
/**
|
|
* Combines the per-store reads of `reviews list`. A store that failed is reported (warning text per store) but does not
|
|
* hide the other one; when EVERY store that was asked failed it throws, so a broken key never reads as "0 reviews, all
|
|
* fine" (the desktop kept such an empty result as a fresh read and showed no new reviews).
|
|
*/
|
|
export function settleStoreReads(reads: StoreRead[]): { reviews: Review[]; errors: string[] } {
|
|
const errors = reads.filter(r => r.error !== undefined).map(r => `${r.store}: ${r.error}`);
|
|
if (reads.length && errors.length === reads.length) throw new Error(errors.join('; '));
|
|
return { reviews: reads.flatMap(r => r.reviews ?? []), errors };
|
|
}
|
|
|
|
/** Reviews newer than `sinceIso`, optional filters, newest first. */
|
|
export function filterReviews(rs: Review[], o: { since?: string; unanswered?: boolean; ratings?: number[] } = {}): Review[] {
|
|
return rs.filter(r => (!o.since || r.date >= o.since) && (!o.unanswered || !r.reply) && (!o.ratings?.length || o.ratings.includes(r.rating)))
|
|
.sort((a, b) => b.date.localeCompare(a.date));
|
|
}
|
|
|
|
/** File name of a draft: <store>-<review id>.md with unsafe characters replaced. */
|
|
export const draftName = (r: Pick<Review, 'store' | 'id'>): string => `${r.store}-${r.id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)}.md`;
|
|
|
|
export const REPLY_PLACEHOLDER = '<!-- Write the reply below. Plain text, the reviewer\'s language. -->';
|
|
|
|
/** A new draft: front matter (approved: false), the review as a quote, an empty "## Reply" section. */
|
|
export function draftMarkdown(r: Review): string {
|
|
const quote = (t: string) => t.split('\n').map(l => `> ${l}`).join('\n');
|
|
const body = [
|
|
`# ${'★'.repeat(Math.max(0, Math.min(5, r.rating)))}${'☆'.repeat(Math.max(0, 5 - r.rating))} ${r.title ?? ''}`.trimEnd(), '',
|
|
quote(r.body || '(no text)'), '',
|
|
`${r.where ? `${r.where} · ` : ''}${r.date.slice(0, 10)}${r.appVersion ? ` · version ${r.appVersion}` : ''}`, '',
|
|
...(r.reply ? ['## Current reply', '', quote(r.reply.text), ''] : []),
|
|
'## Reply', '', REPLY_PLACEHOLDER, '', '',
|
|
].join('\n');
|
|
const meta: Record<string, FmValue> = { store: r.store, review_id: r.id, rating: r.rating, where: r.where ?? null, date: r.date, limit: REPLY_LIMITS[r.store], approved: false, sent: null };
|
|
return stringifyFrontMatter(meta, body);
|
|
}
|
|
|
|
export interface Draft { file: string; store: ReviewStore; reviewId: string; approved: boolean; sent: string | null; reply: string; meta: Record<string, FmValue> }
|
|
|
|
/** A draft file -> its reply text (after "## Reply", HTML comments removed) and state. */
|
|
export function parseDraft(file: string, text: string): Draft {
|
|
const { data, body } = parseFrontMatter(text);
|
|
const store = data.store;
|
|
if (store !== 'apple' && store !== 'play') throw new Error(`${file}: front matter "store" must be apple or play`);
|
|
if (!data.review_id) throw new Error(`${file}: front matter "review_id" missing`);
|
|
const at = body.search(/^## Reply\s*$/m);
|
|
const reply = at < 0 ? '' : body.slice(at).replace(/^## Reply\s*$/m, '').replace(/<!--[\s\S]*?-->/g, '').trim();
|
|
return { file, store, reviewId: String(data.review_id), approved: data.approved === true, sent: typeof data.sent === 'string' ? data.sent : null, reply, meta: data };
|
|
}
|
|
|
|
/** Front matter `sent: <iso>` (and approved kept) after a successful send. */
|
|
export function markSent(text: string, iso: string): string {
|
|
const { data, body } = parseFrontMatter(text);
|
|
return stringifyFrontMatter({ ...data, sent: iso }, body);
|
|
}
|
|
|
|
const INCENTIVE = [
|
|
/\b(free|bonus|extra|gift|reward|compensat\w*)\b[^.!?\n]{0,40}\b(coins?|gems?|diamonds?|credits?|lives|items?|currency|refund)\b/i,
|
|
/\b(change|update|raise|edit|improve|revise)\s+(your\s+)?(rating|review|stars?|score)\b/i,
|
|
/\b(5|five)[ -]?stars?\b/i,
|
|
/\b(rate|review)\s+us\b/i,
|
|
];
|
|
|
|
export interface ReplyIssue { level: 'error' | 'warn'; msg: string }
|
|
|
|
/** Problems with a reply before sending. Errors block `reviews send`; warnings are shown. */
|
|
export function checkReply(reply: string, store: ReviewStore): ReplyIssue[] {
|
|
const e: ReplyIssue[] = [];
|
|
const text = store === 'play' ? reply.replace(/<[^>]*>/g, '') : reply;
|
|
if (!text.trim()) e.push({ level: 'error', msg: 'empty reply' });
|
|
const n = len(text);
|
|
if (store === 'play' && n > REPLY_LIMITS.play) e.push({ level: 'error', msg: `${n} > ${REPLY_LIMITS.play} characters (Google Play limit)` });
|
|
if (store === 'apple' && n > REPLY_LIMITS.apple) e.push({ level: 'warn', msg: `${n} characters: App Store Connect is reported to stop at ${REPLY_LIMITS.apple}` });
|
|
if (INCENTIVE.some(re => re.test(text))) e.push({ level: 'error', msg: 'offers something for a rating or asks to change / give stars: not allowed (no incentives, no rating manipulation)' });
|
|
if (/[^@\s]+@[^@\s]+\.[a-z]{2,}/i.test(text)) e.push({ level: 'warn', msg: 'contains an email address: only your public support address, never a user\'s' });
|
|
if (/(\+?\d[\d\s().-]{7,}\d)/.test(text)) e.push({ level: 'warn', msg: 'contains a phone-like number: no personal data in public replies' });
|
|
if (/https?:\/\//i.test(text)) e.push({ level: 'warn', msg: 'contains a link: support pages only, no marketing' });
|
|
if (/[A-Z]{6,}/.test(text) || /!{3,}/.test(text)) e.push({ level: 'warn', msg: 'shouting (caps / !!!): keep it calm' });
|
|
return e;
|
|
}
|
|
|
|
/** Which drafts `send` would send, and why the others are skipped. */
|
|
export function sendPlan(drafts: Draft[]): { send: Draft[]; skip: { file: string; why: string }[] } {
|
|
const send: Draft[] = [];
|
|
const skip: { file: string; why: string }[] = [];
|
|
for (const d of drafts) {
|
|
if (d.sent) { skip.push({ file: d.file, why: `already sent ${d.sent.slice(0, 10)}` }); continue; }
|
|
if (!d.approved) { skip.push({ file: d.file, why: 'not approved (set approved: true after reading it)' }); continue; }
|
|
const errors = checkReply(d.reply, d.store).filter(i => i.level === 'error');
|
|
if (errors.length) { skip.push({ file: d.file, why: errors.map(i => i.msg).join('; ') }); continue; }
|
|
send.push(d);
|
|
}
|
|
return { send, skip };
|
|
}
|
|
|
|
/** The request that sends one reply. Apple: a POST replaces an earlier response; Play: :reply creates or updates. */
|
|
export function replyRequest(d: Draft, packageName?: string): { method: 'POST'; url: string; body: unknown } {
|
|
if (d.store === 'apple') return { method: 'POST', url: '/v1/customerReviewResponses', body: { data: { type: 'customerReviewResponses', attributes: { responseBody: d.reply },
|
|
relationships: { review: { data: { type: 'customerReviews', id: d.reviewId } } } } } };
|
|
if (!packageName) throw new Error('play.packageName is not set');
|
|
return { method: 'POST', url: `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}/reviews/${encodeURIComponent(d.reviewId)}:reply`, body: { replyText: d.reply } };
|
|
}
|