// Store review replies as pure parts: normalising both stores' reviews, the local draft files the owner / agent edit // (.storebound/replies/-.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 = { 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) } } : {}) }; }); } /** 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: -.md with unsafe characters replaced. */ export const draftName = (r: Pick): string => `${r.store}-${r.id.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)}.md`; export const REPLY_PLACEHOLDER = ''; /** 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 = { 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 } /** 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(//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: ` (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 } }; }