Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion scripts/test-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
});
});

39 changes: 38 additions & 1 deletion src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,7 @@ function ruleForm(opts: {
<p class="muted">${String(dmLen)}/1,000 characters</p>
<label for="public_reply_text">Public reply under the comment (optional)</label>
<textarea id="public_reply_text" name="public_reply_text">${v.public_reply_text}</textarea>
<p class="muted">Leave blank to only send the private message, with no public reply.</p>
<p class="muted">One reply per line (one chosen at random per send). Leave blank to only send the private message, with no public reply.</p>
<div class="row"><button type="submit">Save rule</button></div>
</form>
`;
Expand Down