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
+16
-47
@@ -1,57 +1,26 @@
|
|||||||
# Deploying RedditBot to Dokploy
|
# Dokploy deployment
|
||||||
|
|
||||||
This guide explains how to deploy the RedditBot to Dokploy without needing to manually transfer your local browser session.
|
RedditBot now uses extension dumps as its only account-authentication path. The container does not expose VNC, start Google Chrome, submit Reddit credentials, or sniff Matrix tokens.
|
||||||
|
|
||||||
## Automated Login Feature
|
## Environment
|
||||||
|
|
||||||
The bot has been updated to support automated login using environment variables. This means when you deploy to a fresh server (like Dokploy), the bot will detect it's not logged in and automatically authenticate using the credentials you provide.
|
Configure only the application secrets required by the Compose file:
|
||||||
|
|
||||||
## Deployment Steps
|
- `OPENROUTER_API_KEY`
|
||||||
|
- `WEB_DASHBOARD_TOKEN`
|
||||||
|
|
||||||
1. **Create a New Application in Dokploy**:
|
Do not configure Reddit usernames, passwords, display servers, CDP endpoints, or remote-browser ports.
|
||||||
* Select "Docker Compose" as the deployment method (recommended) or "Dockerfile".
|
|
||||||
|
|
||||||
2. **Environment Variables**:
|
## Account activation
|
||||||
* Go to the "Environment" tab of your application.
|
|
||||||
* Add the following variables:
|
|
||||||
* `OPENROUTER_API_KEY`: Your OpenRouter API key.
|
|
||||||
* `REDDIT_USERNAME`: Your Reddit username.
|
|
||||||
* `REDDIT_PASSWORD`: Your Reddit password.
|
|
||||||
* `BROWSER_HEADLESS`: `false` (required for interactive noVNC login).
|
|
||||||
* `VNC_PASSWORD`: A strong password for the interactive VNC session.
|
|
||||||
* `NOVNC_PUBLIC_URL`: `http://<dokploy-host>:16080`.
|
|
||||||
|
|
||||||
3. **Volumes (Persistence)**:
|
1. Deploy the service and open the dashboard.
|
||||||
* The `docker-compose.yml` is already configured to mount a `./data` volume.
|
2. Install the extension from `extensions/reddit-session-exporter` in your local Chrome profile.
|
||||||
* Dokploy usually handles volumes automatically for Docker Compose projects, or you may need to define a persistent volume path if you want data to survive container recreations (which you do, to avoid logging in every time).
|
3. Sign in to Reddit locally, open Reddit Chat, and wait for the extension badge to show `OK`.
|
||||||
* Make sure the `/app/data` directory is persisted.
|
4. Export `redditbot-session.json` and upload it under **Settings > Access**.
|
||||||
|
5. Delete the local JSON file after the dashboard verifies it.
|
||||||
|
|
||||||
4. **Deploy**:
|
The dump contains both Reddit cookies and a Matrix bearer token. Until both identities verify, the bot, response checks, chat, media sync, and dashboard start control remain paused. If either credential expires, import a new dump.
|
||||||
* Push your code to your Git repository connected to Dokploy.
|
|
||||||
* Click "Deploy".
|
|
||||||
|
|
||||||
## noVNC networking
|
## Persistence
|
||||||
|
|
||||||
The Compose configuration publishes noVNC directly on host port `16080`.
|
The `reddit_bot_data` volume retains the database, the imported Playwright cookie profile, and the verified Matrix credential across deployments. Back up this volume as sensitive account data.
|
||||||
Do not route it through a Dokploy/Traefik domain: the upstream proxy strips
|
|
||||||
the WebSocket Upgrade request that noVNC requires, producing `rfb.js:688` with
|
|
||||||
WebSocket code `1011` and “Failed to connect to downstream server”.
|
|
||||||
|
|
||||||
Set `NOVNC_PUBLIC_URL` to `http://<dokploy-host>:16080`, then open that URL in
|
|
||||||
a separate tab from the dashboard. If the dashboard itself is HTTPS, browsers
|
|
||||||
will not embed this HTTP endpoint in its iframe; the separate-tab link is the
|
|
||||||
intended route. Restrict inbound TCP `16080` at the Dokploy host firewall to
|
|
||||||
your own IP or VPN subnet.
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
1. On the first run, the bot will see the `data/browser-session` folder is empty/invalid.
|
|
||||||
2. It will read `REDDIT_USERNAME` and `REDDIT_PASSWORD`.
|
|
||||||
3. It will launch a headless browser, navigate to Reddit, and log in.
|
|
||||||
4. The session cookies and storage will be saved to `data/browser-session`.
|
|
||||||
5. On subsequent restarts, it will use the saved session (as long as the `/app/data` volume is persisted).
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
* **Login Failed**: Check the application logs. If Reddit presents a captcha or 2FA, the automated login will fail. You may need to temporarily run locally to solve the captcha, then copy the session (though this feature avoids that for standard accounts).
|
|
||||||
* **Verification**: The logs will show "Credentials found in config/env. Attempting automated login..." followed by "Login successful!".
|
|
||||||
@@ -13,7 +13,7 @@ const sections: ReadonlyArray<{ id: SettingsSection; label: string; description:
|
|||||||
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
|
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
|
||||||
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
|
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
|
||||||
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
|
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
|
||||||
{ id: 'access', label: 'Access', description: 'Reddit browser session' },
|
{ id: 'access', label: 'Access', description: 'Extension session dump' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function errorMessage(error: Error | null): string {
|
function errorMessage(error: Error | null): string {
|
||||||
@@ -26,7 +26,7 @@ export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="page settings-page">
|
<div className="page settings-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit browser access.</p></div>
|
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit account access.</p></div>
|
||||||
</header>
|
</header>
|
||||||
<nav aria-label="Settings sections" className="settings-navigation">
|
<nav aria-label="Settings sections" className="settings-navigation">
|
||||||
{sections.map((item) => (
|
{sections.map((item) => (
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createNativeLoginGate } from '../src/native-login-gate.js';
|
|
||||||
|
|
||||||
const servers: Array<ReturnType<typeof createNativeLoginGate>> = [];
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => {
|
|
||||||
server.close(() => resolve());
|
|
||||||
})));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('native Reddit login gate', () => {
|
|
||||||
it('serves VNC guidance and completes only after the explicit handoff', async () => {
|
|
||||||
const onComplete = vi.fn();
|
|
||||||
const server = createNativeLoginGate({
|
|
||||||
publicVncUrl: 'https://red-vnc.example/vnc.html',
|
|
||||||
onComplete,
|
|
||||||
}).listen(0);
|
|
||||||
servers.push(server);
|
|
||||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
|
||||||
const { port } = server.address() as AddressInfo;
|
|
||||||
|
|
||||||
const page = await fetch(`http://127.0.0.1:${port}/`);
|
|
||||||
const html = await page.text();
|
|
||||||
expect(html).toContain('Chrome is running without automation');
|
|
||||||
expect(html).toContain('https://red-vnc.example/vnc.html');
|
|
||||||
expect(html).toContain("fetch('/complete', { method: 'POST' })");
|
|
||||||
expect(onComplete).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const completed = await fetch(`http://127.0.0.1:${port}/complete`, { method: 'POST' });
|
|
||||||
expect(completed.status).toBe(202);
|
|
||||||
expect(await completed.json()).toEqual({ ok: true, restarting: true });
|
|
||||||
expect(onComplete).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user