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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 76 additions & 10 deletions src/browser/ReactFunc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,35 @@ export default class ReactFunc {
return out
}

/**
* Walk a parsed RSC object tree and return the first text content
* found inside a node whose `className` includes `targetClass`.
* Completely language-agnostic — relies only on Fluent UI design tokens.
*/
private findTextByClass(node: unknown, targetClass: string): string | null {
if (!node || typeof node !== 'object') return null
if (Array.isArray(node)) {
for (const item of node) {
const res = this.findTextByClass(item, targetClass)
if (res) return res
}
return null
}
const record = node as Record<string, unknown>
if (typeof record.className === 'string' && record.className.includes(targetClass)) {
if (typeof record.children === 'string') return record.children
if (Array.isArray(record.children)) {
return record.children.filter(c => typeof c === 'string').join('')
}
}
for (const key of Object.keys(record)) {
if (key === 'className') continue
const res = this.findTextByClass(record[key], targetClass)
if (res) return res
}
return null
}

// Section parsers
private parseOffers(combined: string): ParsedOffer[] {
try {
Expand Down Expand Up @@ -291,12 +320,40 @@ export default class ReactFunc {
// Never try future-dated offers, lol
const reportable = !!hash && !isCompleted && !isLocked && (date === null || date <= today)

const isExploreOnBing = offerId.toLowerCase().includes('exploreonbing')

// Top-level fields exist for dashboard-sourced offers; for RSC-only
// offers (e.g. Explore on Bing) they are buried inside React children
// nodes keyed by Fluent UI CSS class tokens.
let title = (obj.title as string) ?? ''
let description = (obj.description as string) ?? ''
let points = (obj.points as number) ?? (obj.pointProgressMax as number) ?? 0

if (!title) {
title = this.findTextByClass(obj, 'text-globalBody2Strong') ?? ''
}
if (!description) {
description = this.findTextByClass(obj, 'text-fgCtrlNeutralSecondaryRest') ?? ''
}
if (!points) {
const badgeText = this.findTextByClass(obj, 'text-statusInformativeTintFg') ?? ''
const m = badgeText.match(/\d+/)
// In RSC flight streams, shared subtrees (such as the +10 pts badge) are often
// deduplicated into reference chunks ($Lxx) and not inlined. Explore on Bing
// activities always award 10 points on Rewards.
points = m ? parseInt(m[0], 10) : (isExploreOnBing ? 10 : 0)
}

// RSC payloads may embed zero-width chars in rendered text
title = title.replace(/[\u200B-\u200D\uFEFF]/g, '').trim()
description = description.replace(/[\u200B-\u200D\uFEFF]/g, '').trim()

const candidate: ParsedOffer = {
offerId,
hash,
title: (obj.title as string) ?? '',
description: (obj.description as string) ?? '',
points: (obj.points as number) ?? (obj.pointProgressMax as number) ?? 0,
title: title || offerId,
description,
points,
promotionSubtype: (obj.promotionSubtype as string | null) ?? null,
destination: (obj.destination as string) ?? (obj.destinationUrl as string) ?? '',
isCompleted,
Expand Down Expand Up @@ -720,26 +777,35 @@ export default class ReactFunc {
const { id, at } = anchors[k]!
if (!this.isParentQuestId(id)) continue

const next = anchors[k + 1]?.at ?? combined.length
const region = combined.slice(at, Math.min(next, at + 3000))
// Find the start of the next *different* parent quest,
// skipping sub-anchors that belong to the same quest id.
let next = combined.length
for (let j = k + 1; j < anchors.length; j++) {
if (anchors[j]!.id !== id && this.isParentQuestId(anchors[j]!.id)) {
next = anchors[j]!.at
break
}
}
const region = combined.slice(at, Math.min(next, at + 4000))

const title =
region.match(/"alt":"((?:[^"\\]|\\.)*)"/)?.[1] ??
region.match(/"title":"((?:[^"\\]|\\.)*)"/)?.[1] ??
''

const pointsMatch = region.match(/\["\+","([\d,]+)"\]/)
// Points badge may render as ["+","150"] or ["","150"]
const pointsMatch = region.match(/\["(?:|\+)","([\d,]+)"\]/)
const points = pointsMatch ? Number(pointsMatch[1]!.replace(/,/g, '')) : 0
const taskM = region.match(/(\d+)\s*\/\s*(\d+)\s*tasks/)
// Progress is always rendered with a slash (e.g. "1/5", "Einchecken: 1/1")
const taskM = region.match(/(\d+)\s*\/\s*(\d+)/)
const complete = !!taskM && Number(taskM[1]) >= Number(taskM[2]!) && Number(taskM[2]) > 0

// First wins for title/points
const prev = byId.get(id)
byId.set(id, {
offerId: id,
title: prev?.title || title,
pointProgressMax: prev?.pointProgressMax || points,
complete: prev?.complete || complete
pointProgressMax: Math.max(prev?.pointProgressMax || 0, points),
complete: (prev && prev.complete) || complete
})
}

Expand Down
7 changes: 4 additions & 3 deletions src/functions/activities/api/UrlReward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,11 @@ export class UrlReward extends BaseActivity {
timezoneOffset: this.bot.userData.timezoneOffset
}
],
// Use /earn context where offers and action IDs are fetched (matching bootstrap)
{
url: URLs.rewards.dashboard,
referer: URLs.rewards.dashboard,
routerStateTree: this.bot.browser.react.routerStateTree('dashboard')
url: URLs.rewards.earn,
referer: URLs.rewards.earn,
routerStateTree: this.bot.browser.react.routerStateTree('earn')
}
)

Expand Down
16 changes: 14 additions & 2 deletions src/functions/activities/rewards/DailySet.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { BaseActivity } from '../BaseActivity'
import type { DashboardData } from '../../../interface/DashboardData'
import type { BasePromotion, DashboardData } from '../../../interface/DashboardData'
import { PromotionActivityRunner } from './PromotionActivityRunner'
import { toBasePromotion } from './offerAdapter'

export class DailySet extends BaseActivity {
public async run(data: DashboardData): Promise<void> {
const today = this.bot.utils.getFormattedDate()
const pending =
let pending: BasePromotion[] =
data.dashboard.dailySetPromotions[today]?.filter(item => !item.complete && item.pointProgressMax > 0) ?? []

// Fallback: dashboard API may return an empty daily set while the
// RSC payload still contains the offers keyed by offerId.
if (!pending.length && this.bot.reactSnapshot?.offers) {
const fromRsc = this.bot.reactSnapshot.offers.filter(
o => o.offerId.toLowerCase().includes('dailyset') && !o.isCompleted && o.points > 0 && o.reportable
)
if (fromRsc.length) {
pending = fromRsc.map(toBasePromotion)
}
}

if (!pending.length) {
this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have already been completed')
return
Expand Down
10 changes: 10 additions & 0 deletions src/functions/activities/rewards/MorePromotions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseActivity } from '../BaseActivity'
import type { BasePromotion, DashboardData } from '../../../interface/DashboardData'
import { PromotionActivityRunner } from './PromotionActivityRunner'
import { toBasePromotion } from './offerAdapter'

export class MorePromotions extends BaseActivity {
public async run(data: DashboardData): Promise<void> {
Expand All @@ -15,6 +16,15 @@ export class MorePromotions extends BaseActivity {
).values()
]

// Append RSC-only offers (e.g. Explore on Bing) that the dashboard
// API did not return. Skip dailyset items — handled by DailySet.
const existingIds = new Set(promotions.map(p => p.offerId))
for (const offer of this.bot.reactSnapshot?.reportable ?? []) {
if (offer.offerId.toLowerCase().includes('dailyset') || existingIds.has(offer.offerId)) continue
promotions.push(toBasePromotion(offer))
existingIds.add(offer.offerId)
}

const pending = promotions.filter(promotion => this.isActionable(promotion))
if (!pending.length) {
this.bot.logger.info(
Expand Down
3 changes: 2 additions & 1 deletion src/functions/activities/rewards/PromotionActivityRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class PromotionActivityRunner extends BaseActivity {
return
}

const isSearchOnBing = name.includes('exploreonbing')
// Offers sourced via RSC flight chunks have name set to offerId, so check both
const isSearchOnBing = name.includes('exploreonbing') || offerId.toLowerCase().includes('exploreonbing')
if (isSearchOnBing && !this.bot.config.activities.searchOnBing) {
this.logDisabled('SearchOnBing', offerId)
return
Expand Down
3 changes: 2 additions & 1 deletion src/functions/activities/rewards/PunchCards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ export class PunchCards extends BaseActivity {
}

private isClaimChild(offerId: string, promotion?: BasePromotion): boolean {
if (/urlreward/i.test(offerId)) return false
if (/\/redeem\//.test((promotion?.destinationUrl ?? '').toLowerCase())) return true
return /(redeem|claim|(?<!url)reward)/i.test(offerId)
return /(redeem|claim)/i.test(offerId)
}

private getAttribute(promotion: BasePromotion, key: string): unknown {
Expand Down
30 changes: 30 additions & 0 deletions src/functions/activities/rewards/offerAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { ParsedOffer } from '../../../browser/ReactFunc'
import type { BasePromotion } from '../../../interface/DashboardData'

/**
* Convert a RSC-parsed offer into the BasePromotion shape expected by
* PromotionActivityRunner. Provides safe defaults for every field that
* downstream consumers (isActionable, UrlReward, SearchOnBing) inspect.
*
* BasePromotion has 80+ fields; only the subset actually read at runtime
* is populated here, so the double cast is intentional.
*/
export function toBasePromotion(offer: ParsedOffer): BasePromotion {
return {
offerId: offer.offerId,
name: offer.offerId,
title: offer.title || offer.offerId,
description: offer.description,
complete: offer.isCompleted,
pointProgressMax: offer.points,
pointProgress: 0,
promotionType: 'urlreward',
promotionSubtype: offer.promotionSubtype ?? '',
destinationUrl: offer.destination,
hash: offer.hash ?? '',
activityType: String(offer.activityType ?? 11),
priority: 1,
attributes: null,
exclusiveLockedFeatureStatus: '',
} as unknown as BasePromotion
}