mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 02:46:17 +00:00
feat: Add conclusion webhook support for final summary notifications (#355)
- Updated README.md to include new configuration options for conclusion webhook. - Enhanced BrowserFunc.ts with improved error handling during page reloads. - Implemented conclusionWebhook configuration in config.json. - Refactored Login.ts to use Playwright types and improved passkey handling. - Added safeClick method in SearchOnBing.ts to handle click timeouts and overlays. - Introduced account summary collection in index.ts for reporting. - Created ConclusionWebhook.ts to send structured summaries to a dedicated webhook. - Updated TypeScript definitions for better type safety across the project.
This commit is contained in:
@@ -82,6 +82,8 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r
|
||||
| proxy.proxyBingTerms | Enable or disable proxying the request via set proxy | `true` (will be proxied) |
|
||||
| webhook.enabled | Enable or disable your set webhook | `false` |
|
||||
| webhook.url | Your Discord webhook URL | `null` |
|
||||
| conclusionWebhook.enabled | Enable or disable the final summary dedicated webhook | `false` |
|
||||
| conclusionWebhook.url | Discord webhook URL used ONLY for the end summary | `null` |
|
||||
|
||||
## Features ##
|
||||
- [x] Multi-Account Support
|
||||
@@ -90,6 +92,7 @@ A basic docker `compose.yaml` is provided. Follow these steps to configure and r
|
||||
- [x] Passwordless Support
|
||||
- [x] Headless Support
|
||||
- [x] Discord Webhook Support
|
||||
- [x] Final Summary Webhook (dedicated optional)
|
||||
- [x] Desktop Searches
|
||||
- [x] Configurable Tasks
|
||||
- [x] Microsoft Edge Searches
|
||||
|
||||
@@ -92,9 +92,32 @@ export default class BrowserFunc {
|
||||
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||
await this.goHome(this.bot.homePage)
|
||||
}
|
||||
|
||||
// Reload the page to get new data
|
||||
await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' })
|
||||
let lastError: any = null
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
// Reload the page to get new data
|
||||
await this.bot.homePage.reload({ waitUntil: 'domcontentloaded' })
|
||||
lastError = null
|
||||
break
|
||||
} catch (re) {
|
||||
lastError = re
|
||||
const msg = (re instanceof Error ? re.message : String(re))
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
|
||||
// If page/context closed => bail early after first retry
|
||||
if (msg.includes('has been closed')) {
|
||||
if (attempt === 1) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
|
||||
try {
|
||||
await this.goHome(this.bot.homePage)
|
||||
} catch {/* ignore */}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (attempt === 2 && lastError) throw lastError
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
}
|
||||
|
||||
const scriptContent = await this.bot.homePage.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
@@ -108,7 +131,7 @@ export default class BrowserFunc {
|
||||
}
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
const dashboardData = await this.bot.homePage.evaluate(scriptContent => {
|
||||
const dashboardData = await this.bot.homePage.evaluate((scriptContent: string) => {
|
||||
// Extract the dashboard object using regex
|
||||
const regex = /var dashboard = (\{.*?\});/s
|
||||
const match = regex.exec(scriptContent)
|
||||
@@ -272,7 +295,7 @@ export default class BrowserFunc {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const scriptContent = $('script').filter((index, element) => {
|
||||
const scriptContent = $('script').filter((index: number, element: any) => {
|
||||
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
||||
}).text()
|
||||
|
||||
@@ -332,7 +355,7 @@ export default class BrowserFunc {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId))
|
||||
const element = $('.offer-cta').toArray().find((x: any) => x.attribs.href?.includes(activity.offerId))
|
||||
if (element) {
|
||||
selector = `a[href*="${element.attribs.href}"]`
|
||||
}
|
||||
|
||||
@@ -40,6 +40,24 @@ export default class BrowserUtil {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Handle blocking Bing privacy overlay intercepting clicks (#bnp_overlay_wrapper)
|
||||
try {
|
||||
const overlay = await page.locator('#bnp_overlay_wrapper').first()
|
||||
if (await overlay.isVisible({ timeout: 500 }).catch(()=>false)) {
|
||||
// Try common dismiss buttons inside overlay
|
||||
const rejectBtn = await page.locator('#bnp_btn_reject, button[aria-label*="Reject" i]').first()
|
||||
const acceptBtn = await page.locator('#bnp_btn_accept').first()
|
||||
if (await rejectBtn.isVisible().catch(()=>false)) {
|
||||
await rejectBtn.click({ timeout: 500 }).catch(()=>{})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject')
|
||||
} else if (await acceptBtn.isVisible().catch(()=>false)) {
|
||||
await acceptBtn.click({ timeout: 500 }).catch(()=>{})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Accept (fallback)')
|
||||
}
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async getLatestTab(page: Page): Promise<Page> {
|
||||
|
||||
@@ -43,5 +43,9 @@
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
},
|
||||
"conclusionWebhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import type { Page } from 'playwright'
|
||||
import readline from 'readline'
|
||||
import * as crypto from 'crypto'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
@@ -10,8 +10,9 @@ import { OAuth } from '../interface/OAuth'
|
||||
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
// Use as any to avoid strict typing issues with our minimal process shim
|
||||
input: (process as any).stdin,
|
||||
output: (process as any).stdout
|
||||
})
|
||||
|
||||
export class Login {
|
||||
@@ -21,6 +22,8 @@ export class Login {
|
||||
private redirectUrl: string = 'https://login.live.com/oauth20_desktop.srf'
|
||||
private tokenUrl: string = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
|
||||
private scope: string = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
|
||||
// Flag to prevent spamming passkey logs after first handling
|
||||
private passkeyHandled: boolean = false
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
@@ -35,7 +38,7 @@ export class Login {
|
||||
await page.goto('https://rewards.bing.com/signin')
|
||||
|
||||
// Disable FIDO support in login request
|
||||
await page.route('**/GetCredentialType.srf*', (route) => {
|
||||
await page.route('**/GetCredentialType.srf*', (route: any) => {
|
||||
const body = JSON.parse(route.request().postData() || '{}')
|
||||
body.isFidoSupported = false
|
||||
route.continue({ postData: JSON.stringify(body) })
|
||||
@@ -265,7 +268,7 @@ export class Login {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'SMS 2FA code required. Waiting for user input...')
|
||||
|
||||
const code = await new Promise<string>((resolve) => {
|
||||
rl.question('Enter 2FA code:\n', (input) => {
|
||||
rl.question('Enter 2FA code:\n', (input: string) => {
|
||||
rl.close()
|
||||
resolve(input)
|
||||
})
|
||||
@@ -287,21 +290,32 @@ export class Login {
|
||||
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
||||
authorizeUrl.searchParams.append('login_hint', email)
|
||||
|
||||
// Disable FIDO for OAuth flow as well (reduces passkey prompts resurfacing)
|
||||
await page.route('**/GetCredentialType.srf*', (route: any) => {
|
||||
const body = JSON.parse(route.request().postData() || '{}')
|
||||
body.isFidoSupported = false
|
||||
route.continue({ postData: JSON.stringify(body) })
|
||||
}).catch(()=>{})
|
||||
|
||||
await page.goto(authorizeUrl.href)
|
||||
|
||||
let currentUrl = new URL(page.url())
|
||||
let code: string
|
||||
|
||||
const authStart = Date.now()
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Waiting for authorization...')
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
// Attempt to dismiss passkey/passkey-like screens quickly (non-blocking)
|
||||
await this.tryDismissPasskeyPrompt(page)
|
||||
if (currentUrl.hostname === 'login.live.com' && currentUrl.pathname === '/oauth20_desktop.srf') {
|
||||
code = currentUrl.searchParams.get('code')!
|
||||
break
|
||||
}
|
||||
|
||||
currentUrl = new URL(page.url())
|
||||
await this.bot.utils.wait(5000)
|
||||
// Shorter wait to react faster to passkey prompt
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
const body = new URLSearchParams()
|
||||
@@ -322,7 +336,8 @@ export class Login {
|
||||
const tokenResponse = await this.bot.axios.request(tokenRequest)
|
||||
const tokenData: OAuth = await tokenResponse.data
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Successfully authorized')
|
||||
const authDuration = Date.now() - authStart
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Successfully authorized in ${Math.round(authDuration/1000)}s`)
|
||||
return tokenData.access_token
|
||||
}
|
||||
|
||||
@@ -346,70 +361,141 @@ export class Login {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Successfully logged into the rewards portal')
|
||||
}
|
||||
|
||||
private lastNoPromptLog: number = 0
|
||||
private noPromptIterations: number = 0
|
||||
private async dismissLoginMessages(page: Page) {
|
||||
// Passkey / Windows Hello prompt ("Sign in faster"), click "Skip for now"
|
||||
// Primary heuristics: presence of biometric video OR title mentions passkey/sign in faster
|
||||
const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 2000 }).catch(() => null)
|
||||
let handledPasskey = false
|
||||
let didSomething = false
|
||||
|
||||
// PASSKEY / Windows Hello / Sign in faster
|
||||
const passkeyVideo = await page.waitForSelector('[data-testid="biometricVideo"]', { timeout: 1000 }).catch(() => null)
|
||||
if (passkeyVideo) {
|
||||
const skipButton = await page.$('[data-testid="secondaryButton"]')
|
||||
const skipButton = await page.$('button[data-testid="secondaryButton"]')
|
||||
if (skipButton) {
|
||||
await skipButton.click()
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Use Passkey" modal via data-testid=secondaryButton')
|
||||
await page.waitForTimeout(500)
|
||||
handledPasskey = true
|
||||
await skipButton.click().catch(()=>{})
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog detected (video heuristic) -> clicked "Skip for now"')
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await page.waitForTimeout(300)
|
||||
didSomething = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!handledPasskey) {
|
||||
// Fallback heuristics: title text or presence of primary+secondary buttons typical of the passkey screen
|
||||
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 1000 }).catch(() => null)
|
||||
if (!didSomething) {
|
||||
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 800 }).catch(() => null)
|
||||
const titleText = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
|
||||
const looksLikePasskeyTitle = /sign in faster|passkey/i.test(titleText)
|
||||
|
||||
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 1000 }).catch(() => null)
|
||||
const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 1000 }).catch(() => null)
|
||||
|
||||
if (looksLikePasskeyTitle && secondaryBtn) {
|
||||
await secondaryBtn.click()
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by title + secondaryButton')
|
||||
await page.waitForTimeout(500)
|
||||
handledPasskey = true
|
||||
} else if (secondaryBtn && primaryBtn) {
|
||||
// If both buttons are visible (Next + Skip for now), prefer the secondary (Skip for now)
|
||||
await secondaryBtn.click()
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen by button pair heuristic')
|
||||
await page.waitForTimeout(500)
|
||||
handledPasskey = true
|
||||
} else if (!handledPasskey) {
|
||||
// Last-resort fallbacks by text and close icon
|
||||
const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first()
|
||||
if (await skipByText.isVisible().catch(() => false)) {
|
||||
await skipByText.click()
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed Passkey screen via text fallback')
|
||||
await page.waitForTimeout(500)
|
||||
handledPasskey = true
|
||||
} else {
|
||||
const closeBtn = await page.$('#close-button')
|
||||
if (closeBtn) {
|
||||
await closeBtn.click().catch(() => { })
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Attempted to close Passkey screen via close button')
|
||||
await page.waitForTimeout(500)
|
||||
const looksLikePasskey = /sign in faster|passkey|fingerprint|face|pin/i.test(titleText)
|
||||
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
|
||||
const primaryBtn = await page.waitForSelector('button[data-testid="primaryButton"]', { timeout: 500 }).catch(() => null)
|
||||
if (looksLikePasskey && secondaryBtn) {
|
||||
await secondaryBtn.click().catch(()=>{})
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey dialog detected (title: "${titleText}") -> clicked secondary`)
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await page.waitForTimeout(300)
|
||||
didSomething = true
|
||||
} else if (!didSomething && secondaryBtn && primaryBtn) {
|
||||
const secText = (await secondaryBtn.textContent() || '').trim()
|
||||
if (/skip for now/i.test(secText)) {
|
||||
await secondaryBtn.click().catch(()=>{})
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (pair heuristic) -> clicked secondary (Skip for now)')
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await page.waitForTimeout(300)
|
||||
didSomething = true
|
||||
}
|
||||
}
|
||||
if (!didSomething) {
|
||||
const skipByText = await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first()
|
||||
if (await skipByText.isVisible().catch(()=>false)) {
|
||||
await skipByText.click().catch(()=>{})
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Passkey dialog (text fallback) -> clicked "Skip for now"')
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await page.waitForTimeout(300)
|
||||
didSomething = true
|
||||
}
|
||||
}
|
||||
if (!didSomething) {
|
||||
const closeBtn = await page.$('#close-button')
|
||||
if (closeBtn) {
|
||||
await closeBtn.click().catch(()=>{})
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', 'Attempted close button on potential passkey modal')
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use Keep me signed in
|
||||
if (await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 2000 }).catch(() => null)) {
|
||||
const yesButton = await page.$('[data-testid="primaryButton"]')
|
||||
// KMSI (Keep me signed in) prompt
|
||||
const kmsi = await page.waitForSelector('[data-testid="kmsiVideo"]', { timeout: 800 }).catch(()=>null)
|
||||
if (kmsi) {
|
||||
const yesButton = await page.$('button[data-testid="primaryButton"]')
|
||||
if (yesButton) {
|
||||
await yesButton.click()
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-LOGIN-MESSAGES', 'Dismissed "Keep me signed in" modal')
|
||||
await page.waitForTimeout(500)
|
||||
await yesButton.click().catch(()=>{})
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'KMSI dialog detected -> accepted (Yes)')
|
||||
await page.waitForTimeout(300)
|
||||
didSomething = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!didSomething) {
|
||||
this.noPromptIterations++
|
||||
const now = Date.now()
|
||||
if (this.noPromptIterations === 1 || (now - this.lastNoPromptLog) > 10000) {
|
||||
this.lastNoPromptLog = now
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
|
||||
// Reset counter if it grows large to keep number meaningful
|
||||
if (this.noPromptIterations > 50) this.noPromptIterations = 0
|
||||
}
|
||||
} else {
|
||||
// Reset counters after an interaction
|
||||
this.noPromptIterations = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Lightweight passkey prompt dismissal used in mobile OAuth loop */
|
||||
private async tryDismissPasskeyPrompt(page: Page) {
|
||||
try {
|
||||
// Fast existence checks with very small timeouts to avoid slowing the loop
|
||||
const titleEl = await page.waitForSelector('[data-testid="title"]', { timeout: 500 }).catch(() => null)
|
||||
const secondaryBtn = await page.waitForSelector('button[data-testid="secondaryButton"]', { timeout: 500 }).catch(() => null)
|
||||
// Direct text locator fallback (sometimes data-testid changes)
|
||||
const textSkip = secondaryBtn ? null : await page.locator('xpath=//button[contains(normalize-space(.), "Skip for now")]').first().isVisible().catch(()=>false)
|
||||
if (secondaryBtn) {
|
||||
// Heuristic: if title indicates passkey or both primary/secondary exist with typical text
|
||||
let shouldClick = false
|
||||
let titleText = ''
|
||||
if (titleEl) {
|
||||
titleText = (await titleEl.textContent() || '').trim()
|
||||
if (/sign in faster|passkey|fingerprint|face|pin/i.test(titleText)) {
|
||||
shouldClick = true
|
||||
}
|
||||
}
|
||||
if (!shouldClick && textSkip) {
|
||||
shouldClick = true
|
||||
}
|
||||
if (!shouldClick) {
|
||||
// Fallback text probe on the secondary button itself
|
||||
const btnText = (await secondaryBtn.textContent() || '').trim()
|
||||
if (/skip for now/i.test(btnText)) {
|
||||
shouldClick = true
|
||||
}
|
||||
}
|
||||
if (shouldClick) {
|
||||
await secondaryBtn.click().catch(() => { })
|
||||
if (!this.passkeyHandled) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Passkey prompt (loop) -> clicked skip${titleText ? ` (title: ${titleText})` : ''}`)
|
||||
}
|
||||
this.passkeyHandled = true
|
||||
await this.bot.utils.wait(500)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore minor errors */ }
|
||||
}
|
||||
|
||||
private async checkBingLogin(page: Page): Promise<void> {
|
||||
|
||||
@@ -30,7 +30,7 @@ export class Quiz extends Workers {
|
||||
|
||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
||||
const answerAttribute = await answerSelector?.evaluate(el => el.getAttribute('iscorrectoption'))
|
||||
const answerAttribute = await answerSelector?.evaluate((el: any) => el.getAttribute('iscorrectoption'))
|
||||
|
||||
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
||||
answers.push(`#rqAnswerOption${i}`)
|
||||
@@ -60,7 +60,7 @@ export class Quiz extends Workers {
|
||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: 10000 })
|
||||
const dataOption = await answerSelector?.evaluate(el => el.getAttribute('data-option'))
|
||||
const dataOption = await answerSelector?.evaluate((el: any) => el.getAttribute('data-option'))
|
||||
|
||||
if (dataOption === correctOption) {
|
||||
// Click the answer on page
|
||||
|
||||
@@ -295,7 +295,7 @@ export class Search extends Workers {
|
||||
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
||||
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
||||
|
||||
await page.evaluate((scrollPos) => {
|
||||
await page.evaluate((scrollPos: number) => {
|
||||
window.scrollTo(0, scrollPos)
|
||||
}, randomScrollPosition)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import type { Page } from 'playwright'
|
||||
import * as fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
@@ -21,7 +21,7 @@ export class SearchOnBing extends Workers {
|
||||
|
||||
const searchBar = '#sb_form_q'
|
||||
await page.waitForSelector(searchBar, { state: 'visible', timeout: 10000 })
|
||||
await page.click(searchBar)
|
||||
await this.safeClick(page, searchBar)
|
||||
await this.bot.utils.wait(500)
|
||||
await page.keyboard.type(query)
|
||||
await page.keyboard.press('Enter')
|
||||
@@ -36,6 +36,22 @@ export class SearchOnBing extends Workers {
|
||||
}
|
||||
}
|
||||
|
||||
private async safeClick(page: Page, selector: string) {
|
||||
try {
|
||||
await page.click(selector, { timeout: 5000 })
|
||||
} catch (e: any) {
|
||||
const msg = (e?.message || '')
|
||||
if (/Timeout.*click/i.test(msg) || /intercepts pointer events/i.test(msg)) {
|
||||
// Try to dismiss overlays then retry once
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
await this.bot.utils.wait(500)
|
||||
await page.click(selector, { timeout: 5000 })
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getSearchQuery(title: string): Promise<string> {
|
||||
interface Queries {
|
||||
title: string;
|
||||
|
||||
247
src/index.ts
247
src/index.ts
@@ -1,5 +1,6 @@
|
||||
import cluster from 'cluster'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
import Browser from './browser/Browser'
|
||||
import BrowserFunc from './browser/BrowserFunc'
|
||||
@@ -15,6 +16,8 @@ import Activities from './functions/Activities'
|
||||
|
||||
import { Account } from './interface/Account'
|
||||
import Axios from './util/Axios'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
|
||||
// Main bot class
|
||||
@@ -41,6 +44,9 @@ export class MicrosoftRewardsBot {
|
||||
private login = new Login(this)
|
||||
private accessToken: string = ''
|
||||
|
||||
// Summary collection (per process)
|
||||
private accountSummaries: AccountSummary[] = []
|
||||
|
||||
//@ts-expect-error Will be initialized later
|
||||
public axios: Axios
|
||||
|
||||
@@ -65,6 +71,7 @@ export class MicrosoftRewardsBot {
|
||||
}
|
||||
|
||||
async run() {
|
||||
this.printBanner()
|
||||
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||
|
||||
// Only cluster when there's more than 1 cluster demanded
|
||||
@@ -79,6 +86,37 @@ export class MicrosoftRewardsBot {
|
||||
}
|
||||
}
|
||||
|
||||
private printBanner() {
|
||||
// Only print once (primary process or single cluster execution)
|
||||
if (this.config.clusters > 1 && !cluster.isPrimary) return
|
||||
try {
|
||||
const pkgPath = path.join(__dirname, '../', 'package.json')
|
||||
let version = 'unknown'
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(raw)
|
||||
version = pkg.version || version
|
||||
}
|
||||
const banner = [
|
||||
' __ __ _____ _____ _ ',
|
||||
' | \/ |/ ____| | __ \\ | | ',
|
||||
' | \ / | (___ ______| |__) |_____ ____ _ _ __ __| |___ ',
|
||||
' | |\/| |\\___ \\______| _ // _ \\ \\ /\\ / / _` | \'__/ _` / __|',
|
||||
' | | | |____) | | | \\ \\ __/ \\ V V / (_| | | | (_| \\__ \\',
|
||||
' |_| |_|_____/ |_| \\_\\___| \\_/\\_/ \\__,_|_| \\__,_|___/',
|
||||
'',
|
||||
` Version: v${version}`,
|
||||
''
|
||||
].join('\n')
|
||||
console.log(banner)
|
||||
} catch { /* ignore banner errors */ }
|
||||
}
|
||||
|
||||
// Return summaries (used when clusters==1)
|
||||
public getSummaries() {
|
||||
return this.accountSummaries
|
||||
}
|
||||
|
||||
private runMaster() {
|
||||
log('main', 'MAIN-PRIMARY', 'Primary process started')
|
||||
|
||||
@@ -87,18 +125,27 @@ export class MicrosoftRewardsBot {
|
||||
for (let i = 0; i < accountChunks.length; i++) {
|
||||
const worker = cluster.fork()
|
||||
const chunk = accountChunks[i]
|
||||
worker.send({ chunk })
|
||||
;(worker as any).send?.({ chunk })
|
||||
// Collect summaries from workers
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg && msg.type === 'summary' && Array.isArray(msg.data)) {
|
||||
this.accountSummaries.push(...msg.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cluster.on('exit', (worker, code) => {
|
||||
cluster.on('exit', (worker: any, code: number) => {
|
||||
this.activeWorkers -= 1
|
||||
|
||||
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
||||
|
||||
// Check if all workers have exited
|
||||
if (this.activeWorkers === 0) {
|
||||
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||
process.exit(0)
|
||||
// All workers done -> send conclusion (if enabled) then exit
|
||||
this.sendConclusion(this.accountSummaries).finally(() => {
|
||||
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -106,7 +153,7 @@ export class MicrosoftRewardsBot {
|
||||
private runWorker() {
|
||||
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
|
||||
// Receive the chunk of accounts from the master
|
||||
process.on('message', async ({ chunk }) => {
|
||||
;(process as any).on('message', async ({ chunk }: { chunk: Account[] }) => {
|
||||
await this.runTasks(chunk)
|
||||
})
|
||||
}
|
||||
@@ -115,29 +162,74 @@ export class MicrosoftRewardsBot {
|
||||
for (const account of accounts) {
|
||||
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||
|
||||
const accountStart = Date.now()
|
||||
let desktopInitial = 0
|
||||
let mobileInitial = 0
|
||||
let desktopCollected = 0
|
||||
let mobileCollected = 0
|
||||
const errors: string[] = []
|
||||
|
||||
this.axios = new Axios(account.proxy)
|
||||
if (this.config.parallel) {
|
||||
await Promise.all([
|
||||
this.Desktop(account),
|
||||
(() => {
|
||||
const mobileInstance = new MicrosoftRewardsBot(true)
|
||||
mobileInstance.axios = this.axios
|
||||
|
||||
return mobileInstance.Mobile(account)
|
||||
})()
|
||||
const mobileInstance = new MicrosoftRewardsBot(true)
|
||||
mobileInstance.axios = this.axios
|
||||
// Run both and capture results
|
||||
const [desktopResult, mobileResult] = await Promise.all([
|
||||
this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null }),
|
||||
mobileInstance.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null })
|
||||
])
|
||||
if (desktopResult) {
|
||||
desktopInitial = desktopResult.initialPoints
|
||||
desktopCollected = desktopResult.collectedPoints
|
||||
}
|
||||
if (mobileResult) {
|
||||
mobileInitial = mobileResult.initialPoints
|
||||
mobileCollected = mobileResult.collectedPoints
|
||||
}
|
||||
} else {
|
||||
this.isMobile = false
|
||||
await this.Desktop(account)
|
||||
const desktopResult = await this.Desktop(account).catch(e => { errors.push(`desktop:${shortErr(e)}`); return null })
|
||||
if (desktopResult) {
|
||||
desktopInitial = desktopResult.initialPoints
|
||||
desktopCollected = desktopResult.collectedPoints
|
||||
}
|
||||
|
||||
this.isMobile = true
|
||||
await this.Mobile(account)
|
||||
const mobileResult = await this.Mobile(account).catch(e => { errors.push(`mobile:${shortErr(e)}`); return null })
|
||||
if (mobileResult) {
|
||||
mobileInitial = mobileResult.initialPoints
|
||||
mobileCollected = mobileResult.collectedPoints
|
||||
}
|
||||
}
|
||||
|
||||
const accountEnd = Date.now()
|
||||
const durationMs = accountEnd - accountStart
|
||||
const totalCollected = desktopCollected + mobileCollected
|
||||
const initialTotal = (desktopInitial || 0) + (mobileInitial || 0)
|
||||
this.accountSummaries.push({
|
||||
email: account.email,
|
||||
durationMs,
|
||||
desktopCollected,
|
||||
mobileCollected,
|
||||
totalCollected,
|
||||
initialTotal,
|
||||
endTotal: initialTotal + totalCollected,
|
||||
errors
|
||||
})
|
||||
|
||||
log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
|
||||
}
|
||||
|
||||
log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
|
||||
// If in worker mode (clusters>1) send summaries to primary
|
||||
if (this.config.clusters > 1 && !cluster.isPrimary) {
|
||||
if (process.send) {
|
||||
process.send({ type: 'summary', data: this.accountSummaries })
|
||||
}
|
||||
} else {
|
||||
// Single process mode -> build and send conclusion directly
|
||||
await this.sendConclusion(this.accountSummaries)
|
||||
}
|
||||
process.exit()
|
||||
}
|
||||
|
||||
@@ -155,7 +247,8 @@ export class MicrosoftRewardsBot {
|
||||
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
|
||||
this.pointsInitial = data.userStatus.availablePoints
|
||||
this.pointsInitial = data.userStatus.availablePoints
|
||||
const initial = this.pointsInitial
|
||||
|
||||
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`)
|
||||
|
||||
@@ -206,9 +299,14 @@ export class MicrosoftRewardsBot {
|
||||
// Save cookies
|
||||
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
|
||||
|
||||
// Fetch points BEFORE closing (avoid page closed reload error)
|
||||
const after = await this.browser.func.getCurrentPoints().catch(()=>initial)
|
||||
// Close desktop browser
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return
|
||||
return {
|
||||
initialPoints: initial,
|
||||
collectedPoints: (after - initial) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
@@ -224,7 +322,8 @@ export class MicrosoftRewardsBot {
|
||||
|
||||
await this.browser.func.goHome(this.homePage)
|
||||
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0
|
||||
|
||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
||||
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
|
||||
@@ -239,9 +338,11 @@ export class MicrosoftRewardsBot {
|
||||
|
||||
// Close mobile browser
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Do daily check in
|
||||
if (this.config.workers.doDailyCheckIn) {
|
||||
await this.activities.doDailyCheckIn(this.accessToken, data)
|
||||
@@ -292,13 +393,113 @@ export class MicrosoftRewardsBot {
|
||||
|
||||
const afterPointAmount = await this.browser.func.getCurrentPoints()
|
||||
|
||||
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - this.pointsInitial} points today`)
|
||||
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
|
||||
|
||||
// Close mobile browser
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: (afterPointAmount - initialPoints) || 0
|
||||
}
|
||||
}
|
||||
|
||||
private async sendConclusion(summaries: AccountSummary[]) {
|
||||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||
const cfg = this.config
|
||||
if (!cfg.conclusionWebhook || !cfg.conclusionWebhook.enabled) return
|
||||
|
||||
const totalAccounts = summaries.length
|
||||
if (totalAccounts === 0) return
|
||||
|
||||
let totalCollected = 0
|
||||
let totalInitial = 0
|
||||
let totalEnd = 0
|
||||
let totalDuration = 0
|
||||
let accountsWithErrors = 0
|
||||
|
||||
const accountFields: any[] = []
|
||||
for (const s of summaries) {
|
||||
totalCollected += s.totalCollected
|
||||
totalInitial += s.initialTotal
|
||||
totalEnd += s.endTotal
|
||||
totalDuration += s.durationMs
|
||||
if (s.errors.length) accountsWithErrors++
|
||||
|
||||
const statusEmoji = s.errors.length ? '⚠️' : '✅'
|
||||
const diff = s.totalCollected
|
||||
const duration = formatDuration(s.durationMs)
|
||||
const valueLines: string[] = [
|
||||
`Points: ${s.initialTotal} → ${s.endTotal} ( +${diff} )`,
|
||||
`Breakdown: 🖥️ ${s.desktopCollected} | 📱 ${s.mobileCollected}`,
|
||||
`Duration: ⏱️ ${duration}`
|
||||
]
|
||||
if (s.errors.length) {
|
||||
valueLines.push(`Errors: ${s.errors.slice(0,2).join(' | ')}`)
|
||||
}
|
||||
accountFields.push({
|
||||
name: `${statusEmoji} ${s.email}`.substring(0, 256),
|
||||
value: valueLines.join('\n').substring(0, 1024),
|
||||
inline: false
|
||||
})
|
||||
}
|
||||
|
||||
const avgDuration = totalDuration / totalAccounts
|
||||
const embed = {
|
||||
title: '🎯 Microsoft Rewards Summary',
|
||||
description: `Processed **${totalAccounts}** account(s)${accountsWithErrors ? ` • ${accountsWithErrors} with issues` : ''}`,
|
||||
color: accountsWithErrors ? 0xFFAA00 : 0x32CD32,
|
||||
fields: [
|
||||
{
|
||||
name: 'Global Totals',
|
||||
value: [
|
||||
`Total Points: ${totalInitial} → ${totalEnd} ( +${totalCollected} )`,
|
||||
`Average Duration: ${formatDuration(avgDuration)}`,
|
||||
`Cumulative Runtime: ${formatDuration(totalDuration)}`
|
||||
].join('\n')
|
||||
},
|
||||
...accountFields
|
||||
].slice(0, 25), // Discord max 25 fields
|
||||
timestamp: new Date().toISOString(),
|
||||
footer: {
|
||||
text: 'Script conclusion webhook'
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback plain text (rare) & embed send
|
||||
const fallback = `Microsoft Rewards Summary\nAccounts: ${totalAccounts}\nTotal: ${totalInitial} -> ${totalEnd} (+${totalCollected})\nRuntime: ${formatDuration(totalDuration)}`
|
||||
await ConclusionWebhook(cfg, fallback, { embeds: [embed] })
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountSummary {
|
||||
email: string
|
||||
durationMs: number
|
||||
desktopCollected: number
|
||||
mobileCollected: number
|
||||
totalCollected: number
|
||||
initialTotal: number
|
||||
endTotal: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
function shortErr(e: any): string {
|
||||
if (!e) return 'unknown'
|
||||
if (e instanceof Error) return e.message.substring(0, 120)
|
||||
const s = String(e)
|
||||
return s.substring(0, 120)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms || ms < 1000) return `${ms}ms`
|
||||
const sec = Math.floor(ms / 1000)
|
||||
const h = Math.floor(sec / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const s = sec % 60
|
||||
const parts: string[] = []
|
||||
if (h) parts.push(`${h}h`)
|
||||
if (m) parts.push(`${m}m`)
|
||||
if (s) parts.push(`${s}s`)
|
||||
return parts.join(' ') || `${ms}ms`
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface Config {
|
||||
webhookLogExcludeFunc: string[];
|
||||
proxy: ConfigProxy;
|
||||
webhook: ConfigWebhook;
|
||||
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
|
||||
}
|
||||
|
||||
export interface ConfigSaveFingerprint {
|
||||
|
||||
78
src/types/node-shim.d.ts
vendored
Normal file
78
src/types/node-shim.d.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
// Fallback in case @types/node not installed yet; ensures Process/stubs to reduce red squiggles.
|
||||
// Prefer installing @types/node for full types.
|
||||
|
||||
interface ProcessEnv { [key: string]: string | undefined }
|
||||
|
||||
interface Process {
|
||||
pid: number
|
||||
exit(code?: number): never
|
||||
send?(message: any): void
|
||||
on(event: string, listener: (...args: any[]) => void): any
|
||||
stdin: { on(event: string, listener: (...args: any[]) => void): any }
|
||||
stdout: { write(chunk: any): boolean }
|
||||
env: ProcessEnv
|
||||
}
|
||||
|
||||
declare var process: Process
|
||||
|
||||
// Minimal axios module declaration
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig { [key: string]: any }
|
||||
export interface AxiosResponse<T = any> { data: T }
|
||||
export interface AxiosInstance {
|
||||
defaults: any
|
||||
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
}
|
||||
export interface AxiosStatic {
|
||||
(config: AxiosRequestConfig): Promise<AxiosResponse>
|
||||
request<T=any>(config: AxiosRequestConfig): Promise<AxiosResponse<T>>
|
||||
create(config?: AxiosRequestConfig): AxiosInstance
|
||||
}
|
||||
const axios: AxiosStatic
|
||||
export default axios
|
||||
}
|
||||
|
||||
// Minimal readline
|
||||
declare module 'readline' {
|
||||
export interface Interface { question(query: string, cb: (answer: string)=>void): void; close(): void }
|
||||
export function createInterface(opts: any): Interface
|
||||
export default {} as any
|
||||
}
|
||||
|
||||
// Minimal crypto
|
||||
declare module 'crypto' {
|
||||
export function randomBytes(size: number): { toString(encoding: string): string }
|
||||
}
|
||||
|
||||
// Minimal os module
|
||||
declare module 'os' {
|
||||
export function platform(): string
|
||||
}
|
||||
|
||||
// Minimal cheerio subset
|
||||
declare module 'cheerio' {
|
||||
export interface CheerioAPI {
|
||||
(selector: any): any
|
||||
load(html: string): CheerioAPI
|
||||
text(): string
|
||||
}
|
||||
export function load(html: string): CheerioAPI
|
||||
}
|
||||
|
||||
declare module 'cluster' {
|
||||
import { EventEmitter } from 'events'
|
||||
interface WorkerLike extends EventEmitter {
|
||||
id: number
|
||||
process: { pid: number }
|
||||
send?(message: any): void
|
||||
on(event: 'message', listener: (msg: any) => void): any
|
||||
}
|
||||
interface Cluster extends EventEmitter {
|
||||
isPrimary: boolean
|
||||
fork(env?: NodeJS.ProcessEnv): WorkerLike
|
||||
workers?: Record<string, WorkerLike>
|
||||
on(event: 'exit', listener: (worker: WorkerLike, code: number) => void): any
|
||||
}
|
||||
const cluster: Cluster
|
||||
export default cluster
|
||||
}
|
||||
57
src/types/rebrowser-playwright.d.ts
vendored
Normal file
57
src/types/rebrowser-playwright.d.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
// Minimal module declaration to silence TS complaints if upstream types not found.
|
||||
// You should replace with actual types if the package provides them.
|
||||
|
||||
// Basic playwright stubs (only what we currently need). Replace with real @types if available.
|
||||
declare module 'playwright' {
|
||||
export interface Cookie { name: string; value: string; domain?: string; path?: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: 'Lax'|'Strict'|'None' }
|
||||
export interface BrowserContext {
|
||||
newPage(): Promise<Page>
|
||||
setDefaultTimeout(timeout: number): void
|
||||
addCookies(cookies: Cookie[]): Promise<void>
|
||||
cookies(): Promise<Cookie[]>
|
||||
pages(): Page[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
export interface Browser {
|
||||
newPage(): Promise<Page>
|
||||
context(): BrowserContext
|
||||
close(): Promise<void>
|
||||
pages?(): Page[]
|
||||
}
|
||||
export interface Keyboard {
|
||||
type(text: string): Promise<any>
|
||||
press(key: string): Promise<any>
|
||||
down(key: string): Promise<any>
|
||||
up(key: string): Promise<any>
|
||||
}
|
||||
export interface Locator {
|
||||
first(): Locator
|
||||
click(opts?: any): Promise<any>
|
||||
isVisible(opts?: any): Promise<boolean>
|
||||
nth(index: number): Locator
|
||||
}
|
||||
export interface Page {
|
||||
goto(url: string, opts?: any): Promise<any>
|
||||
waitForLoadState(state?: string, opts?: any): Promise<any>
|
||||
waitForSelector(selector: string, opts?: any): Promise<any>
|
||||
fill(selector: string, value: string): Promise<any>
|
||||
keyboard: Keyboard
|
||||
click(selector: string, opts?: any): Promise<any>
|
||||
close(): Promise<any>
|
||||
url(): string
|
||||
route(match: string, handler: any): Promise<any>
|
||||
locator(selector: string): Locator
|
||||
$: (selector: string) => Promise<any>
|
||||
context(): BrowserContext
|
||||
reload(opts?: any): Promise<any>
|
||||
evaluate<R=any>(pageFunction: any, arg?: any): Promise<R>
|
||||
content(): Promise<string>
|
||||
waitForTimeout(timeout: number): Promise<void>
|
||||
}
|
||||
export interface ChromiumType { launch(opts?: any): Promise<Browser> }
|
||||
export const chromium: ChromiumType
|
||||
}
|
||||
|
||||
declare module 'rebrowser-playwright' {
|
||||
export * from 'playwright'
|
||||
}
|
||||
25
src/types/shims-node.d.ts
vendored
Normal file
25
src/types/shims-node.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
// Minimal shims to silence TypeScript errors in environments without @types/node
|
||||
// If possible, install @types/node instead for full typing.
|
||||
|
||||
declare const __dirname: string
|
||||
|
||||
declare namespace NodeJS { interface Process { pid: number; send?: (msg: any) => void; exit(code?: number): void; } }
|
||||
|
||||
declare const process: NodeJS.Process
|
||||
|
||||
declare module 'cluster' {
|
||||
interface Worker { process: { pid: number }; on(event: 'message', cb: (msg: any) => void): void }
|
||||
const isPrimary: boolean
|
||||
function fork(): Worker
|
||||
function on(event: 'exit', cb: (worker: Worker, code: number) => void): void
|
||||
export { isPrimary, fork, on, Worker }
|
||||
export default { isPrimary, fork, on }
|
||||
}
|
||||
|
||||
declare module 'fs' { const x: any; export = x }
|
||||
|
||||
declare module 'path' { const x: any; export = x }
|
||||
|
||||
// Do NOT redeclare 'Page' to avoid erasing actual Playwright types if present.
|
||||
// If types are missing, install: npm i -D @types/node
|
||||
|
||||
32
src/util/ConclusionWebhook.ts
Normal file
32
src/util/ConclusionWebhook.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { Config } from '../interface/Config'
|
||||
|
||||
interface ConclusionPayload {
|
||||
content?: string
|
||||
embeds?: any[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a final structured summary to the dedicated conclusion webhook (if enabled),
|
||||
* otherwise do nothing. Does NOT fallback to the normal logging webhook to avoid spam.
|
||||
*/
|
||||
export async function ConclusionWebhook(configData: Config, content: string, embed?: ConclusionPayload) {
|
||||
const webhook = configData.conclusionWebhook
|
||||
|
||||
if (!webhook || !webhook.enabled || webhook.url.length < 10) return
|
||||
|
||||
const body: ConclusionPayload = embed?.embeds ? { embeds: embed.embeds } : { content }
|
||||
if (content && !body.content && !body.embeds) body.content = content
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: webhook.url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: body
|
||||
}
|
||||
|
||||
await axios(request).catch(() => { })
|
||||
}
|
||||
@@ -39,7 +39,9 @@
|
||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "types": ["node"], // Removed explicit requirement; using local shims. Install @types/node for full typings.
|
||||
"typeRoots": ["./src/types", "./node_modules/@types"],
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
|
||||
Reference in New Issue
Block a user