chore: remove retired native login paths
This commit is contained in:
1 parent
ea92a1a92b
commit
a36723a325
6 files changed
+18
-775
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import http, { type Server } from 'node:http';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export interface NativeLoginGateOptions {
|
||||
publicVncUrl: string;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character] ?? character);
|
||||
}
|
||||
|
||||
function loginPage(publicVncUrl: string): string {
|
||||
const safeVncUrl = escapeHtml(publicVncUrl);
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Reddit login required</title>
|
||||
<style>
|
||||
body { margin: 0; font: 16px system-ui, sans-serif; color: #f5f5f5; background: #111; }
|
||||
main { max-width: 1100px; margin: 24px auto; padding: 0 20px; }
|
||||
.notice { padding: 18px; border: 1px solid #3c765f; border-radius: 12px; background: #17251f; }
|
||||
iframe { width: 100%; height: 70vh; margin-top: 18px; border: 1px solid #444; border-radius: 10px; background: #000; }
|
||||
button, a { display: inline-block; margin: 14px 8px 0 0; padding: 11px 16px; border: 0; border-radius: 8px; font-weight: 700; }
|
||||
button { color: white; background: #ff4500; cursor: pointer; }
|
||||
a { color: white; background: #444; text-decoration: none; }
|
||||
#status { margin-left: 8px; color: #b9dfcf; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<section class="notice">
|
||||
<strong>Chrome is running without automation.</strong>
|
||||
<p>Log in to Reddit in the browser below. Only after Reddit shows you as signed in, click <em>I'm logged in - connect the bot</em>. Chrome will restart with the same profile and the bot will verify both Reddit and Matrix before any bot logic can run.</p>
|
||||
<button id="complete" type="button">I'm logged in - connect the bot</button>
|
||||
<a href="${safeVncUrl}" target="_blank" rel="noreferrer">Open VNC in a new tab</a>
|
||||
<span id="status"></span>
|
||||
</section>
|
||||
<iframe src="${safeVncUrl}" title="Reddit login browser"></iframe>
|
||||
<script>
|
||||
document.getElementById('complete').addEventListener('click', async () => {
|
||||
const button = document.getElementById('complete');
|
||||
const status = document.getElementById('status');
|
||||
button.disabled = true;
|
||||
status.textContent = 'Restarting Chrome and verifying your account...';
|
||||
const response = await fetch('/complete', { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
button.disabled = false;
|
||||
status.textContent = 'Could not start verification. Please try again.';
|
||||
return;
|
||||
}
|
||||
setTimeout(() => window.location.reload(), 3000);
|
||||
});
|
||||
</script>
|
||||
</main></body></html>`;
|
||||
}
|
||||
|
||||
export function createNativeLoginGate(options: NativeLoginGateOptions): Server {
|
||||
return http.createServer((request, response) => {
|
||||
if (request.method === 'POST' && request.url === '/complete') {
|
||||
response.writeHead(202, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ ok: true, restarting: true }));
|
||||
options.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
response.writeHead(405).end();
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
response.end(loginPage(options.publicVncUrl));
|
||||
});
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
const port = Number.parseInt(process.env.WEB_PORT ?? '3000', 10);
|
||||
const publicBase = (process.env.NOVNC_PUBLIC_URL ?? `http://localhost:${process.env.NOVNC_PORT ?? '6080'}`).replace(/\/$/, '');
|
||||
const publicVncUrl = `${publicBase}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
|
||||
let server: Server;
|
||||
server = createNativeLoginGate({
|
||||
publicVncUrl,
|
||||
onComplete: () => setTimeout(() => server.close(() => process.exit(0)), 250).unref(),
|
||||
});
|
||||
server.listen(port, () => {
|
||||
process.stdout.write(`[native-login] waiting for human login on port ${port}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
const entryUrl = process.argv[1] ? pathToFileURL(process.argv[1]).href : '';
|
||||
if (import.meta.url === entryUrl) run();
|
||||
@@ -1,28 +0,0 @@
|
||||
export interface NativeLoginHandoff {
|
||||
request(): void;
|
||||
}
|
||||
|
||||
export interface ProcessNativeLoginHandoffOptions {
|
||||
exitCode?: number;
|
||||
delayMs?: number;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
|
||||
export class ProcessNativeLoginHandoff implements NativeLoginHandoff {
|
||||
private requested = false;
|
||||
private readonly exitCode: number;
|
||||
private readonly delayMs: number;
|
||||
private readonly exit: (code: number) => void;
|
||||
|
||||
constructor(options: ProcessNativeLoginHandoffOptions = {}) {
|
||||
this.exitCode = options.exitCode ?? 75;
|
||||
this.delayMs = options.delayMs ?? 250;
|
||||
this.exit = options.exit ?? ((code) => process.exit(code));
|
||||
}
|
||||
|
||||
request(): void {
|
||||
if (this.requested) return;
|
||||
this.requested = true;
|
||||
setTimeout(() => this.exit(this.exitCode), this.delayMs).unref();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user