diff --git a/scripts/test-unit.ts b/scripts/test-unit.ts index 09f360b..105bdf5 100644 --- a/scripts/test-unit.ts +++ b/scripts/test-unit.ts @@ -12,7 +12,11 @@ import { FREE_ATTEMPTS, lockRemaining, lockSecondsFor } from '../src/throttle.ts import type { Env } from '../src/types.ts'; import { authorizeUrl, oauthRedirectUri } from '../src/meta.ts'; import { isSelfComment } from '../src/guard.ts'; -import { parseWebhookPayload } from '../src/process.ts'; +import { + parsePublicReplyVariations, + parseWebhookPayload, + selectPublicReply, +} from '../src/process.ts'; import { escapeRegex, findMatchingRule, @@ -363,3 +367,54 @@ describe('login throttle', () => { assert.equal(lockRemaining({ fails: 0, lockedUntil: 0 }, 1_000), 0); }); }); + +describe('public reply variations', () => { + it('handles null, undefined, or empty string gracefully', () => { + assert.deepEqual(parsePublicReplyVariations(null), []); + assert.deepEqual(parsePublicReplyVariations(undefined), []); + assert.deepEqual(parsePublicReplyVariations(''), []); + assert.deepEqual(parsePublicReplyVariations(' \n \n '), []); + assert.equal(selectPublicReply(null), null); + assert.equal(selectPublicReply(''), null); + }); + + it('keeps backward compatibility with existing single-value reply text', () => { + const single = 'Sent you a DM with the details! Check your inbox.'; + assert.deepEqual(parsePublicReplyVariations(single), [single]); + assert.equal(selectPublicReply(single), single); + }); + + it('parses multiple newline-delimited variations ignoring blank lines', () => { + const multiline = ` + Check your DM! + + Sent to your inbox! + + Check your message request folder! + `; + const variations = parsePublicReplyVariations(multiline); + assert.deepEqual(variations, [ + 'Check your DM!', + 'Sent to your inbox!', + 'Check your message request folder!', + ]); + }); + + it('supports JSON array format if stored as json', () => { + const jsonStr = JSON.stringify(['Variation 1', 'Variation 2', 'Variation 3']); + assert.deepEqual(parsePublicReplyVariations(jsonStr), [ + 'Variation 1', + 'Variation 2', + 'Variation 3', + ]); + }); + + it('picks variations at random using random generator function', () => { + const text = 'Option A\nOption B\nOption C'; + assert.equal(selectPublicReply(text, () => 0), 'Option A'); + assert.equal(selectPublicReply(text, () => 1), 'Option B'); + assert.equal(selectPublicReply(text, () => 2), 'Option C'); + assert.equal(selectPublicReply(text, () => 3), 'Option A'); // modulo wrapping + }); +}); + diff --git a/src/process.ts b/src/process.ts index 1014408..40fa83e 100644 --- a/src/process.ts +++ b/src/process.ts @@ -57,6 +57,43 @@ export function parseWebhookPayload(raw: string): unknown { } } +export function parsePublicReplyVariations(raw: string | null | undefined): string[] { + if (!raw) return []; + const trimmed = raw.trim(); + if (!trimmed) return []; + + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((s) => String(s).trim()).filter((s) => s.length > 0); + } + } catch { + // Fall through if not valid JSON + } + } + + return trimmed + .split(/\r?\n/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export function selectPublicReply( + raw: string | null | undefined, + randomFn: (max: number) => number = (max) => { + const arr = new Uint32Array(1); + crypto.getRandomValues(arr); + return arr[0] % max; + } +): string | null { + const variations = parsePublicReplyVariations(raw); + if (variations.length === 0) return null; + if (variations.length === 1) return variations[0]; + const idx = Math.abs(randomFn(variations.length)) % variations.length; + return variations[idx]; +} + function commentIdOf(value: CommentValue): string | undefined { return asId(value.id) || asId(value.comment_id); } @@ -170,7 +207,7 @@ export async function processComment( if (!dm.ok) errors.push(`DM: ${dm.body}`); - const publicText = rule.public_reply_text?.trim(); + const publicText = selectPublicReply(rule.public_reply_text); if (publicText) { const reply = await sendPublicReply(commentId, token, publicText); replyStatus = reply.ok ? 'ok' : 'failed'; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 7e5c94d..2cd63ea 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -588,7 +588,7 @@ function ruleForm(opts: {
${String(dmLen)}/1,000 characters
-Leave blank to only send the private message, with no public reply.
+One reply per line (one chosen at random per send). Leave blank to only send the private message, with no public reply.
`;