chore: remove retired native login paths

This commit is contained in:
jjbrunton committed 2026-08-08 16:55:04 +01:00
1 parent ea92a1a92b
commit a36723a325
6 files changed
+18 -775

No files matched your search

-560
View File
@@ -1,560 +0,0 @@
import type { Page } from 'playwright';
import type { Logger } from '../utils/logger.js';
export class RedditMessagePage {
private dryRun: boolean = false;
constructor(
private page: Page,
private logger: Logger
) { }
setDryRun(enabled: boolean): void {
this.dryRun = enabled;
if (enabled) {
this.logger.info('Dry run mode enabled - messages will be filled but not sent');
}
}
async sendMessage(recipient: string, subject: string, body: string): Promise<boolean> {
try {
this.logger.info(`Sending message to u/${recipient}`);
// Navigate to compose page
await this.page.goto('https://www.reddit.com/message/compose/', {
waitUntil: 'domcontentloaded',
timeout: 60000,
});
// Wait for the form to load - try multiple possible selectors
await this.page.waitForSelector('faceplate-text-input[name="message-recipient-input"], input[name="to"], #to, [data-testid="message-to"], input[placeholder*="Username"]', {
timeout: 30000,
});
// Wait for React/Hydration
await this.page.waitForTimeout(2000);
// --- Recipient ---
const recipientFilled = await this.fillField(
['faceplate-text-input[name="message-recipient-input"]', 'input[name="to"]', '#to', '[data-testid="message-to"]'],
recipient
);
if (!recipientFilled) {
// Fallback: try to find by label
const labels = await this.page.$$('label');
let found = false;
for (const label of labels) {
const text = await label.textContent();
if (text && (text.includes('to') || text.includes('To'))) {
const id = await label.getAttribute('for');
if (id) {
const input = await this.page.$(`#${id}`);
if (input) {
await input.click();
await input.fill(recipient);
found = true;
break;
}
}
}
}
if (!found) {
throw new Error('Could not find recipient input field');
}
}
// Wait a moment for Reddit's validation to kick in
await this.page.waitForTimeout(1500);
// Check for "unable to send message" error immediately after recipient is filled
// This happens when the user has blocked messages, doesn't exist, or doesn't accept message requests
const recipientError = await this.checkRecipientError();
if (recipientError) {
this.logger.warn(`Cannot message u/${recipient}: ${recipientError}`);
return false;
}
// --- Subject ---
const subjectFilled = await this.fillField(
['faceplate-text-input[name="message-title"]', 'input[name="subject"]', '#subject'],
subject
);
if (!subjectFilled) {
throw new Error('Could not find subject input field');
}
// --- Body ---
let bodyFilled = await this.fillField(
['faceplate-textarea-input[name="message-content"]', 'textarea[name="text"]', 'textarea#text', 'textarea.md', '[contenteditable="true"]', '[data-testid="markdown-input"]'],
body,
true // use typeWithDelay for body
);
if (!bodyFilled) {
throw new Error('Could not find message body input');
}
// Wait a bit before submitting
await this.page.waitForTimeout(1000);
// Dry run mode - don't actually send
if (this.dryRun) {
this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`);
this.logger.info(`[DRY RUN] Subject: ${subject}`);
this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`);
this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`);
await this.page.waitForTimeout(10000);
return true;
}
// Find and click submit button - prioritized by specificity to user provided snippet
const submitButton =
(await this.page.$('button[type="submit"]:has-text("Send")')) ||
(await this.page.$('button.button-primary:has-text("Send")')) ||
(await this.page.$('button:has(span:has-text("Send"))')) ||
(await this.page.$('button[type="submit"]')) ||
(await this.page.$('button:has-text("Send")')) ||
(await this.page.$('input[type="submit"]'));
let clicked = false;
if (!submitButton) {
clicked = await this.clickSendButtonViaShadowDom();
if (!clicked) {
throw new Error('Could not find submit button');
}
}
if (submitButton) {
// Wait for button to be enabled (up to 5 seconds)
// Sometimes validation takes a moment
try {
await submitButton.waitForElementState('enabled', { timeout: 5000 });
} catch (e) {
this.logger.warn('Submit button still disabled after filling fields. Attempting to trigger validation...');
// Try to trigger validation by blurring/focusing the last field
await this.page.keyboard.press('Tab');
await this.page.waitForTimeout(500);
await this.page.keyboard.press('Shift+Tab');
await this.page.waitForTimeout(500);
// One last check
const isEnabled = await submitButton.isEnabled();
if (!isEnabled) {
this.logger.warn('Button still disabled. Attempting force click...');
await submitButton.click({ force: true });
clicked = true;
// If force click fails/doesn't work, we proceed to success check anyway
// (sometimes it works even if reported disabled in shadow dom scenarios)
} else {
await submitButton.click();
clicked = true;
}
}
}
if (submitButton && !clicked) {
await submitButton.click();
clicked = true;
}
if (!clicked) {
clicked = await this.clickSendButtonViaShadowDom();
}
// Wait for success indication
try {
await Promise.race([
this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }),
this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }),
// Check for the specific success message in the new UI
this.page.waitForFunction(() => {
const confirmation = document.querySelector('.message-sent-confirmation-text');
return confirmation && !confirmation.hasAttribute('hidden') && confirmation.textContent?.trim().length > 0;
}, null, { timeout: 15000 })
]);
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
} catch {
// Check if we're on a success page or got any error
const currentUrl = this.page.url();
if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Also check if the success message is visible now (in case race failed slightly early)
const successMsg = await this.page.$('.message-sent-confirmation-text:not([hidden])');
if (successMsg && (await successMsg.isVisible())) {
this.logger.info(`Message sent successfully to u/${recipient}`);
return true;
}
// Check for error messages
// Be more specific to avoid false positives (require visibility and content)
const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"], [role="alert"]');
if (errorElement && await errorElement.isVisible()) {
const errorText = await errorElement.textContent();
if (errorText && errorText.trim().length > 0) {
throw new Error(`Reddit error: ${errorText}`);
}
}
// If no explicit error found but also no success found:
// Assume success if we are still on the same page but the input is cleared?
// Or just log warning.
// For now, let's trust the user report that it works and be less aggressive with throwing.
this.logger.warn('Message send status unclear (no success indicator found, but no error either). Assuming success if no error alert appeared.');
return true;
}
} catch (error) {
this.logger.error(`Failed to send message to u/${recipient}:`, error);
return false;
}
}
private async clickSendButtonViaShadowDom(): Promise<boolean> {
try {
const clicked = await this.page.evaluate(() => {
function collectAllElements(root: ParentNode): Element[] {
const elements: Element[] = [];
if (!root) return elements;
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = treeWalker.currentNode as Element | null;
while (current) {
elements.push(current);
const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot;
if (maybeShadow) {
elements.push(...collectAllElements(maybeShadow));
}
current = treeWalker.nextNode() as Element | null;
}
return elements;
}
function isSendButton(el: Element): boolean {
const text = (el.textContent || '').trim().toLowerCase();
const aria = (el.getAttribute('aria-label') || '').trim().toLowerCase();
const value = (el instanceof HTMLInputElement ? el.value : '').trim().toLowerCase();
const label = [text, aria, value].join(' ');
return /\bsend\b/.test(label);
}
const elements = collectAllElements(document);
for (const el of elements) {
if (el.tagName === 'BUTTON' && isSendButton(el)) {
(el as HTMLElement).click();
return true;
}
if (el.tagName === 'INPUT') {
const input = el as HTMLInputElement;
if (input.type === 'submit' && isSendButton(el)) {
input.click();
return true;
}
}
}
return false;
});
if (clicked) {
this.logger.info('Clicked send button via shadow DOM fallback');
}
return clicked;
} catch (error) {
this.logger.warn('Failed to click send button via shadow DOM fallback:', error);
return false;
}
}
private async fillField(selectors: string[], value: string, useDelay = false): Promise<boolean> {
for (const selector of selectors) {
try {
const element = await this.page.$(selector);
if (element) {
// First, check if this is a faceplate-* Shadow DOM component
const isShadowComponent = await element.evaluate((el) => {
return el.tagName.toLowerCase().startsWith('faceplate-') && !!el.shadowRoot;
});
if (isShadowComponent) {
// Handle Shadow DOM components specially
this.logger.debug?.(`Found shadow DOM component: ${selector}`);
// Click the outer element to focus it
await element.click();
await this.page.waitForTimeout(100);
if (useDelay) {
// For typing with delay, we need to focus the inner input first
await element.evaluate((el) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLElement;
inner?.focus();
});
await this.page.waitForTimeout(100);
await this.typeWithDelay(value);
} else {
// Directly set value via evaluate and dispatch events
const success = await element.evaluate((el, val: string) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null;
if (!inner) return false;
// Focus the element
inner.focus();
// Clear existing value
inner.value = '';
// Set new value
inner.value = val;
// Dispatch events that Reddit's validation listens for
inner.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
inner.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
inner.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, composed: true }));
// Blur to trigger any onBlur validation
inner.blur();
return true;
}, value);
if (!success) {
this.logger.warn?.(`Failed to set value via shadow DOM for ${selector}`);
continue;
}
}
// Verify the value was set
const verifiedValue = await element.evaluate((el) => {
const inner = el.shadowRoot?.querySelector('input, textarea') as HTMLInputElement | HTMLTextAreaElement | null;
return inner?.value || '';
});
if (verifiedValue === value) {
this.logger.debug?.(`Successfully filled ${selector} with value`);
return true;
} else {
this.logger.warn?.(`Value mismatch after setting ${selector}: expected "${value.substring(0, 20)}...", got "${verifiedValue.substring(0, 20)}..."`);
// Still return true if we at least set something
if (verifiedValue.length > 0) return true;
}
} else {
// Standard element handling (non-shadow DOM)
await element.click();
const targetHandle = await element.evaluateHandle((el) => {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement).isContentEditable) {
return el;
}
return el.querySelector('input, textarea, [contenteditable="true"]');
});
const target = targetHandle.asElement() || element;
if (useDelay) {
await target.click();
await this.typeWithDelay(value);
} else {
try {
await target.fill(value);
} catch {
// Last resort fallback - use element directly since target may not be evaluatable
await element.evaluate((el, val: string) => {
const targetEl = (el.querySelector('input, textarea, [contenteditable="true"]') || el) as HTMLElement;
if (targetEl instanceof HTMLInputElement || targetEl instanceof HTMLTextAreaElement) {
targetEl.value = val;
} else if (targetEl.isContentEditable) {
targetEl.textContent = val;
}
targetEl.dispatchEvent(new Event('input', { bubbles: true }));
targetEl.dispatchEvent(new Event('change', { bubbles: true }));
}, value);
}
}
return true;
}
}
} catch (e) {
this.logger.warn?.(`Error filling field with selector ${selector}:`, e);
// Continue to next selector
}
}
return false;
}
private async typeWithDelay(text: string): Promise<void> {
// Type with human-like delays
for (const char of text) {
await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 });
}
}
async isLoggedIn(): Promise<boolean> {
try {
await this.page.goto('https://www.reddit.com', {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
// Wait for page to stabilize
await this.page.waitForTimeout(2000);
const visibleIndicators = [
'[data-testid="user-drawer-button"]',
'button[aria-label*="User menu"]',
];
for (const selector of visibleIndicators) {
const el = this.page.locator(selector).first();
if (await el.isVisible().catch(() => false)) {
return true;
}
}
const attributeIndicators = [
'reddit-header-large[is-logged-in="true"]',
'shreddit-post-overflow-menu[current-user-id]',
];
for (const selector of attributeIndicators) {
const el = await this.page.$(selector);
if (el) {
return true;
}
}
return await this.page.evaluate(async () => {
try {
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
if (!res.ok) return false;
const data = await res.json();
return Boolean(data && data.name);
} catch {
return false;
}
});
} catch (error) {
this.logger.error('Failed to check login status:', error);
return false;
}
}
/**
* Check if Reddit is showing an error after entering the recipient
* (e.g., "You are unable to send a message request to this account")
*/
private async checkRecipientError(): Promise<string | null> {
const errorPatterns = [
'unable to send a message',
'unable to send message',
'cannot send message',
'can\'t send message',
'user not found',
'user does not exist',
'doesn\'t accept direct messages',
'blocked',
'something went wrong',
];
// Retry for up to 4 seconds to allow async validation to complete
const maxRetries = 8;
for (let i = 0; i < maxRetries; i++) {
try {
// 1. Check specific Shadow DOM path (most reliable for new UI)
const specificError = await this.page.evaluate(() => {
const input = document.querySelector('faceplate-text-input[name="message-recipient-input"]');
if (!input || !input.shadowRoot) return null;
const helper = input.shadowRoot.querySelector('faceplate-form-helper-text');
if (!helper) return null;
// Check deep shadow text
if (helper.shadowRoot) {
const inner = helper.shadowRoot.querySelector('#helper-text');
if (inner && inner.textContent) return inner.textContent;
}
// Check light text
return helper.textContent;
});
if (specificError) {
const lowerText = specificError.toLowerCase();
if (errorPatterns.some(p => lowerText.includes(p))) {
return specificError.trim();
}
}
// 2. Check standard DOM selectors
const errorSelectors = [
'.error',
'[class*="error"]',
'[class*="Error"]',
'[role="alert"]',
'span[style*="color: red"]',
'faceplate-text-input[name="message-recipient-input"] ~ *',
];
for (const selector of errorSelectors) {
const elements = await this.page.$$(selector);
for (const el of elements) {
const text = await el.textContent();
if (text) {
const lowerText = text.toLowerCase();
if (errorPatterns.some(p => lowerText.includes(p))) {
return text.trim();
}
}
}
}
// 3. Fallback: Full Shadow DOM Crawl (expensive but thorough)
// Only do this on later retries to save time initially? Or just do it.
const errorFromShadow = await this.page.evaluate((patterns: string[]) => {
function collectAllElements(root: ParentNode): Element[] {
const elements: Element[] = [];
if (!root) return elements;
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let current = treeWalker.currentNode as Element | null;
while (current) {
elements.push(current);
const maybeShadow = (current as Element & { shadowRoot?: ShadowRoot }).shadowRoot;
if (maybeShadow) {
elements.push(...collectAllElements(maybeShadow));
}
current = treeWalker.nextNode() as Element | null;
}
return elements;
}
const allElements = collectAllElements(document);
for (const el of allElements) {
const text = (el.textContent || '').toLowerCase();
if (text.length > 200 || text.length < 5) continue; // Filter reasonable length
for (const pattern of patterns) {
if (text.includes(pattern)) {
return (el.textContent || '').trim();
}
}
}
return null;
}, errorPatterns);
if (errorFromShadow) return errorFromShadow;
} catch (error) {
this.logger.warn('Error checking for recipient error:', error);
}
// Wait before next retry
await this.page.waitForTimeout(500);
}
return null;
}
}