diff --git a/README.md b/README.md index 8ea07f5..e49db30 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,39 @@ Every Comprar tap that reached the orchestrator appears here β€” including the a --- +## Ofertas: Wallapop "hacer oferta" + +Wallapop lets a buyer propose a lower price ("hacer oferta"); the seller accepts, rejects, or counters. Salvager can send those offers β€” but, like Comprar, **only when the operator taps the "πŸ’° Ofertar" button on a Telegram alert**. No offer is ever sent autonomously. + +### Enabling offers for a wishlist entry + +By default every entry has `offer.enabled: false` and nothing changes. To opt an entry in: + +```bash +uv run salvager offer enable # offers aim at the entry ceiling +uv run salvager offer enable -t 70.00 # …or at a lower delivered-total target +``` + +Two things happen for an opted-in entry: + +- **Negotiable alerts.** Wallapop listings whose delivered total (item + shipping + ProtecciΓ³n) is over the entry ceiling but within `ceiling Γ— (1 + offer.band_pct)` β€” listings that used to be silently filtered β€” now produce a `πŸ’°` alert showing the computed offer and an "Ofertar Β· Saltar Β· Ver" keyboard (never Comprar: they're over ceiling). +- **Ofertar on standard alerts.** When a computed offer would undercut the asking price (routine only if you set a target below the ceiling), the ordinary πŸ“¦/🟒 alerts grow a `πŸ’° Ofertar` row. + +The offer amount is never chosen by hand: it's the largest whole-euro item price whose delivered total fits your target, bounded by Wallapop's own floor of 70 % of the asking price. It's shown on the alert before you tap, and recomputed from a fresh re-fetch at tap time β€” drift aborts the send. + +### What an Ofertar tap does β€” and does NOT do + +Tap β†’ preflight (entry still opted in, no lockout, daily budget free, no prior offer on this listing) β†’ re-fetch by internal id (gone/reserved/price-drift aborts fail-closed) β†’ TinyFish drives the offer form with EXACTLY the computed amount β†’ `πŸ’° Oferta enviada`. + +**v1 ends there.** Salvager does not watch for the seller's response. If they accept, Wallapop gives you **24 h to buy at the accepted price β€” and the item is NOT reserved meanwhile**: follow through in the Wallapop app. Guardrails: one offer per listing ever; a self-imposed budget of `offer.daily_limit` sends per rolling 24 h (default 5, under Wallapop's 10/day account cap); `offer.lockout_threshold` consecutive failures lock the path until `salvager offer enable` clears it; `offer.kill_switch_global` kills it unconditionally. Every attempt lands in the append-only `offers` audit table. + +```bash +uv run salvager offer status # enablement table + lockout + budget +uv run salvager offer disable --all # kill-everything path (typed confirmation) +``` + +--- + ## Planning artifacts The BMAD planning artifacts that drove the design and implementation plan live in [`_bmad-output/planning-artifacts/`](_bmad-output/planning-artifacts): diff --git a/_bmad-output/planning-artifacts/prd.md b/_bmad-output/planning-artifacts/prd.md index 5df952b..697c3e6 100644 --- a/_bmad-output/planning-artifacts/prd.md +++ b/_bmad-output/planning-artifacts/prd.md @@ -791,6 +791,17 @@ This section provides the strategic context behind the Phase 1 / Phase 2 / Visio - **FR47.** The operator can inspect agent health (`health`) β€” adapter status, scheduler status, last poll, last alert, last Phase 2 event β€” to diagnose problems without reading raw logs. - **FR48.** All read-only operator commands support a `--format json` flag for scripting; daemon logs emit structured JSON Lines on stdout. Operator commands return stable, documented exit codes (0 success / 1 usage / 2 validation / 3 adapter / 4 auth / 5 Phase 2 guardrail). - **FR49.** Configuration is split across `wishlist.yaml` (user content), `config.yaml` (operational tunables: rates, thresholds, paths, log level), and `.env` (credentials only, never logged); the agent loads `.env` once at process start with no hot-reload. + +**Wallapop offer flow (wallapop-make-offer amendment, 2026-07-22; ids FR58-FR65 β€” FR50-FR54 were already taken by the ops/repo sections below):** + +- **FR58.** The operator can send a price offer ("hacer oferta") on a Wallapop listing by tapping a `πŸ’° Ofertar` inline button; no offer is ever sent without an operator tap (the FR29 no-autonomous-action rule extends to offers). +- **FR59.** The offer amount is computed, not chosen: the largest whole-euro item price whose delivered buyer total fits the entry's offer target (`offer.target_total_eur`, defaulting to the entry ceiling), bounded by Wallapop's platform floor of 70 % of the asking price, shown on the alert before the tap and recomputed from the reconciled listing at tap time. +- **FR60.** Wallapop listings on offer-enabled entries whose buyer total exceeds the ceiling but sits within `ceiling Γ— (1 + offer.band_pct)` produce a distinct negotiable alert (`πŸ’°` severity token, offer row, `Ofertar Β· Saltar Β· Ver` keyboard, never Comprar) instead of being silently filtered; listings beyond the band, on offer-disabled entries, or on eBay filter exactly as before. +- **FR61.** Offer sending enforces a self-imposed daily budget (`offer.daily_limit`, default 5 per rolling 24 h, deliberately under Wallapop's 10-per-calendar-day account cap) and recognises the platform's own exhausted-counter state; neither limit-hit increments the failure lockout. +- **FR62.** At most one successful offer is ever sent per listing (per-listing dedupe); a listing with a sent offer keeps a terminal `πŸ’° Oferta enviada` badge across keyboard reconstructions. +- **FR63.** Offer outcomes use the closed `OfferFailureReason` set, each variant rendered with a Spanish cause label, detail rows, next steps, and the reassurance line "No se ha enviado ninguna oferta." (with a documented ambiguity variant for missing confirmation evidence); v1 ends at "offer sent" β€” seller responses are handled by the operator in the Wallapop app. +- **FR64.** Consecutive offer execution failures reaching `offer.lockout_threshold` disable the offer path globally until `salvager offer enable ` clears the lockout; the offer lockout and `offer.kill_switch_global` are fully independent from the Phase 2 circuit breaker and kill switch. +- **FR65.** Offers are opt-in per wishlist entry (`offer.enabled`, default false, toggled via `salvager offer enable/disable`); with no entry opted in, alert filtering, rendering, and callbacks are byte-identical to the pre-offer behaviour, and every executed offer attempt is recorded in the append-only `offers` audit table. - **FR50.** The agent handles SIGTERM gracefully β€” drains in-flight LLM evaluations, flushes the audit log, completes pending Telegram alerts, exits within 30 seconds. ### Project Distribution & Artifacts diff --git a/config.example.yaml b/config.example.yaml index 36e523b..f9563a8 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -102,6 +102,26 @@ alerts: # message (Telegram edits are silent; a big drop deserves a ping). price_drop_ping_pct: 10 +# ───────────────────────────────────────────────────────────────────────── +# offer β€” Wallapop "hacer oferta" flow (wallapop-offer-flow) +# ───────────────────────────────────────────────────────────────────────── +offer: + # Wallapop listings on offer-enabled entries with a buyer total over the + # ceiling but within ceiling Γ— (1 + band_pct) produce a negotiable alert + # (πŸ’° Ofertar, no Comprar) instead of being filtered. Wallapop's own + # -30 % offer floor makes bands much past 0.40 useless. + band_pct: 0.20 + # Self-imposed budget of successful offer sends per rolling 24 h. + # Wallapop allows 10 offers per calendar day per account; keeping this + # under it leaves headroom for your own manual offers. + daily_limit: 5 + # Consecutive execution failures that disable the offer path until + # `salvager offer enable` clears it. Independent from the Phase 2 + # circuit breaker β€” offer failures never block real buys. + lockout_threshold: 3 + # Master off-switch for offer sending, regardless of per-entry settings. + kill_switch_global: false + # ───────────────────────────────────────────────────────────────────────── # telegram β€” delivery semantics (NFR-I6) # ───────────────────────────────────────────────────────────────────────── diff --git a/docs/release-audits/v1.0/SUMMARY.md b/docs/release-audits/v1.0/SUMMARY.md index 27d1fbc..6bf80b3 100644 --- a/docs/release-audits/v1.0/SUMMARY.md +++ b/docs/release-audits/v1.0/SUMMARY.md @@ -383,3 +383,22 @@ If any **critical** anomaly per the checklist (emoji collapse under simulator Β· primary command unnavigable in VoiceOver Β· severity emoji corruption on a Telegram client), flip to `BLOCKED β€” ` and open a release-gating bug. + +--- + +## Pending delta β€” wallapop-make-offer (2026-07-23) + +The offer flow (OpenSpec change `wallapop-make-offer`) adds 21 rendering +variants to the catalog (registry 45 β†’ 66): 2 negotiable listing shapes, +2 with-offer listing shapes, `offer_sent`, 12 `offer_failure_*`, and 4 +operational events. When the change ships in a release: + +- the code-level audit extends over the new surfaces (golden snapshots + already pin all 34 text+keyboard variants in + `tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr`); +- the on-device capture pass gains the emit-able newcomers + (`dev emit-alert negotiable_listing_direct`, `offer_sent`, the 12 + failures) plus a colorblind glance at the `πŸ’°` token (informational β€” + the banner word/keyboard carries state, per the Β§2 rule); +- the live-event items mirror the buy path: one real Ofertar tap + eyeballed through `🟑 Ofertando…` β†’ `πŸ’° Oferta enviada` / restored row. diff --git a/openspec/changes/wallapop-make-offer/.openspec.yaml b/openspec/changes/wallapop-make-offer/.openspec.yaml new file mode 100644 index 0000000..7250f8f --- /dev/null +++ b/openspec/changes/wallapop-make-offer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-22 diff --git a/openspec/changes/wallapop-make-offer/captures/NOTES.md b/openspec/changes/wallapop-make-offer/captures/NOTES.md new file mode 100644 index 0000000..b952eaf --- /dev/null +++ b/openspec/changes/wallapop-make-offer/captures/NOTES.md @@ -0,0 +1,28 @@ +# Capture notes β€” wallapop-make-offer + +## App captures (2026-07-22, files in this directory) + +- `app-listing-hacer-oferta-button.jpg` β€” listing page with "Hacer oferta" + next to Comprar. +- `app-offer-form-10-restantes.jpg` β€” offer form showing the + "10 ofertas restantes para hoy" counter. +- `app-offer-form-floor-30pct.jpg` β€” inline floor validation: + "Tu oferta debe ser de al menos 35€ (-30%)" on a 50 € item. + +## Web verification (2026-07-23, operator screenshots β€” task 5.1) + +Confirmed on es.wallapop.com (desktop web, authenticated session): + +- The listing page (`/item/`) shows a "Hacer oferta" button + directly under Comprar β€” same entry point as the app. +- Clicking opens the "Hacer una oferta" modal, addressable at + `/app/chat/offer?itemId=` (the SAME internal id the + reconciliation re-fetch uses; carried in the agent goal as a + recovery hint). +- The same "10 ofertas restantes para hoy" counter renders in the modal. +- The amount field accepts cents using a COMMA separator ("20,22" + observed accepted, Enviar enabled). Salvager's amounts are whole + euros, so no decimals are ever entered. +- No -30 % floor message was visible client-side at a below-floor + amount on web β€” validation may happen on submit; the domain + pre-validates the floor and `amount_rejected` covers a refusal. diff --git a/openspec/changes/wallapop-make-offer/captures/app-listing-hacer-oferta-button.jpg b/openspec/changes/wallapop-make-offer/captures/app-listing-hacer-oferta-button.jpg new file mode 100644 index 0000000..b371e84 Binary files /dev/null and b/openspec/changes/wallapop-make-offer/captures/app-listing-hacer-oferta-button.jpg differ diff --git a/openspec/changes/wallapop-make-offer/captures/app-offer-form-10-restantes.jpg b/openspec/changes/wallapop-make-offer/captures/app-offer-form-10-restantes.jpg new file mode 100644 index 0000000..e5eda29 Binary files /dev/null and b/openspec/changes/wallapop-make-offer/captures/app-offer-form-10-restantes.jpg differ diff --git a/openspec/changes/wallapop-make-offer/captures/app-offer-form-floor-30pct.jpg b/openspec/changes/wallapop-make-offer/captures/app-offer-form-floor-30pct.jpg new file mode 100644 index 0000000..22f7dc3 Binary files /dev/null and b/openspec/changes/wallapop-make-offer/captures/app-offer-form-floor-30pct.jpg differ diff --git a/openspec/changes/wallapop-make-offer/design.md b/openspec/changes/wallapop-make-offer/design.md new file mode 100644 index 0000000..c8a17f8 --- /dev/null +++ b/openspec/changes/wallapop-make-offer/design.md @@ -0,0 +1,76 @@ +## Context + +Wallapop's "hacer oferta" lets a buyer propose a price; the seller accepts, rejects, or counters inside the listing's chat. Salvager's pipeline today is binary around the buyer-total ceiling: at-or-under β†’ alert (and optionally Comprar), over β†’ silently filtered. The buy path (v0.3.x–v0.4.4) established the safety patterns this feature must reuse: operator-tap gating (FR29), preflight β†’ cross-source reconciliation re-fetch by internal listing id β†’ TinyFish browser-agent execution with a strict output contract, closed failure-reason enums with full Spanish render tables, guaranteed keyboard restore on every outcome (the v0.4.3 zombie-keyboard lesson), append-only audit tables, and a lockout on consecutive failures. + +Scope decisions locked with the operator (2026-07-22): operator-tap only; offer amount derived from the entry ceiling (not a percentage, not operator-picked); eligibility = alerted listings + a configurable over-ceiling "negotiable band"; v1 ends at "offer sent" (no acceptance detection). Wallapop only. + +Platform rules, confirmed 2026-07-22 from Wallapop's help pages and the operator's app captures (stored in this change dir): the offer entry point is a "Hacer oferta" button on the listing page itself (and inside the chat); the offer form enforces a **minimum of 70 % of the asking price** ("Tu oferta debe ser de al menos 35 € (-30 %)" on a 50 € item) and shows a **"N ofertas restantes para hoy" counter β€” 10 offers per calendar day per account, with repeat offers on the same product counted once**; offers are unavailable on PRO-seller products, refurbished products, and the Inmobiliaria/Coches/Empleo categories; on acceptance the buyer gets 24 h to buy at the accepted price, the item is NOT reserved meanwhile, and the seller cannot revert an acceptance. + +## Goals / Non-Goals + +**Goals:** +- A `πŸ’° Ofertar` button whose tap sends one bounded, deterministic, fully audited offer through the same safety stack as Comprar. +- Surface the over-ceiling negotiable band the pipeline currently drops on the floor, without touching non-opted-in entries or eBay. +- The operator always sees the exact offer amount on the alert before tapping. +- Zero behaviour change when no wishlist entry carries an `offer:` block. + +**Non-Goals:** +- Detecting seller acceptance/rejection/counter (v1 ends at `Oferta enviada`; the operator continues in the Wallapop app). The data model must not preclude a future acceptance-detection change. +- eBay Best Offer. +- Automatic (untapped) offers, multi-round negotiation strategies, or re-offering after a rejection. +- Any change to the Comprar path. + +## Decisions + +**D1 β€” Execution via a TinyFish browser goal, not the Wallapop chat API.** +The offer is sent by a new `wallapop_offer.py` adapter in `adapters/tinyfish_browser/`, a sibling of `wallapop_pay.py`: a natural-language goal contract (open listing β†’ tap the "Hacer oferta" button on the listing page β†’ enter EXACTLY the bounded amount β†’ submit β†’ verify the sent state β†’ screenshot) plus a JSON output contract mirroring `BUY_OUTPUT_CONTRACT`. The app UI is documented by the operator's captures; the remaining authoring work is verifying the web UI TinyFish actually drives exposes the same flow. *Alternative considered:* reverse-engineering the `/api/v3` conversations/offer endpoint β€” rejected for v1: it is undocumented, unexplored in the fetcher, and a schema drift there fails silently in the worst way (an offer we can't confirm was sent). The browser path inherits the proven error taxonomy and screenshot evidence. The API route remains the natural candidate for v2 acceptance polling. Adapter discipline (NFR-M1) and the payment-rail lint apply unchanged: the `tinyfish` import stays inside the adapter package. + +**D2 β€” Offer amount = "fit the target", with an optional per-entry target below the ceiling.** +`offer_item_price_eur(listing, target)` = the largest whole-euro item price `O` such that `buyer_total_eur(O, shipping, ProtecciΓ³n)` ≀ `target` (whole euros read naturally in a negotiation; flooring is always conservative). The target defaults to the entry's effective ceiling (`max_price_solo`), and an optional `offer.target_total_eur` lets the operator aim lower. This resolves a real tension in the locked decisions: for a listing already under the ceiling, the ceiling-fit price is β‰₯ the asking price by construction, so a pure ceiling formula would never produce a meaningful offer on normal alerts. The uniform rule is: **render the Ofertar button iff the computed offer is strictly below the asking item price** (and > 0). With the default target this fires essentially only on the negotiable band; setting `offer.target_total_eur` below the ceiling activates it on under-ceiling alerts too ("I'd accept 80 € but I want to pay 70 €"). One formula, no percentage knob, operator-controlled. The amount additionally respects the **platform floor**: Wallapop rejects offers below 70 % of the asking price, so the amount function returns no offer when the fit price falls under that floor (the button simply doesn't render β€” `amount_rejected` becomes a rare fallback for drift, not a routine outcome). The floor also bounds how wide a useful band can be: with the buyer-total model, a listing beyond roughly +40 % over the target can never be offered into it, so the default `band_pct = 0.20` sits comfortably inside the floor (at +20 % the offer is β‰₯ ~83 % of asking) and values much past ~0.40 just waste evaluations. The amount is computed at render time (shown on the alert) and **recomputed at tap time from the reconciled listing** β€” if the fresh amount differs from the displayed one beyond the Phase 2 reconciliation tolerance (reuse `phase2.reconciliation_tolerance_eur/pct`), or no longer undercuts the asking price, the tap aborts (stale-snapshot protection, same philosophy that blocked the 60β†’65 € Corsair tap). + +**D3 β€” Negotiable band = a carve-out at the existing over-ceiling filter, upstream of evaluation.** +`poll_loop._filter_over_ceiling` (pre-eval) gains one branch: a Wallapop listing on an offer-enabled entry whose buyer total is over the ceiling but ≀ `ceiling Γ— (1 + offer.band_pct)` (config, default 0.20) is tagged *negotiable* and kept; everything beyond the band is dropped exactly as today. Tagged listings flow through the unchanged LLM evaluation and confidence gate β€” junk in the band must not alert just because it is cheap-ish. At render time the tag selects the negotiable renderer: distinct severity token, the standard buyer-total breakdown, plus an offer line (`πŸ’° Oferta: 74 € (total ≀ 80,00 €)` shape) and the Ofertar row β€” never Comprar (over ceiling by definition; the Phase 2 preflight would reject it anyway). *Cost note:* the band widens the LLM-eval funnel; bounded by per-entry opt-in and the band width. Seen-listing dedupe applies unchanged. + +**D4 β€” Offer lockout is separate from the Phase 2 circuit breaker.** +Consecutive offer failures β‰₯ `offer.lockout_threshold` (config, default 3) disable the offer path globally until `salvager offer enable ` clears it β€” same semantics as Phase 2's breaker, but a separate counter and a separate `offer.kill_switch_global`: offer failures must never block real buys, and vice versa. Aborts that prove safety (listing gone, stale amount, duplicate) do not increment it β€” the v0.4.1 lesson. + +**D5 β€” Daily offer budget, enforced on our side before the platform's.** +Wallapop caps offers at **10 per calendar day per account** (repeat offers on the same product count once β€” moot for us given D6's per-listing dedupe), and the offer form displays a live "N ofertas restantes para hoy" counter. Two layers: (a) a **self-imposed budget** β€” `offer.daily_limit` (config, default 5) counted as successful sends in a rolling 24 h window from the `offers` table; the preflight rejects a tap once the budget is spent (the tap aborts with a clear message β€” simpler than recomputing eligibility on every render); a rolling window avoids timezone/reset guesswork and is always at least as conservative as the platform's calendar-day reset, and the default keeps half the platform cap free for the operator's own manual offers. (b) a **platform-reported limit** β€” the agent goal must recognise the exhausted-counter state and report it as a distinct `daily_limit_reached` failure that does NOT increment the lockout counter (the account is healthy; the path is not broken) and whose copy says when to retry. The goal SHOULD also capture the on-screen remaining-count into the attempt's audit row when visible β€” free observability of the platform budget. + +**D6 β€” One offer per listing, ever (v1).** +Before rendering an Ofertar row and again in the tap preflight, the orchestrator checks the offers table for a prior *successful* send for the same `(marketplace, listing_id)`. Dedupe is absolute in v1 β€” no age-out, no re-offer after price drops (a price drop that brings the listing under the ceiling makes Comprar available instead, which is strictly better). The offers table carries a `status` column (`sent` is the only v1 value) so acceptance detection can extend it without a schema break. + +**D7 β€” Persistence mirrors the Phase 2 audit shape.** +Migration `0004_offer_schema.sql`, all additive: `offers` (append-only: one row per executed attempt β€” amount, asking price at tap, outcome, failure reason, screenshot path, timestamps) and `offer_state` (single mutable row: `globally_disabled`, `disabled_reason/at`, `consecutive_failures`) β€” the same split `phase2_state` uses. Taps themselves are already captured by the existing `callbacks` table (the `offer` verb rides the standard callback audit path). Writer methods on the audit writer stay INSERT-only for `offers` so `test_audit_writer_append_only.py` keeps passing. + +**D8 β€” Telegram surface: a new verb, an added row, and the proven keyboard lifecycle.** +`CallbackVerb.offer`; callback data stays `listing:offer:` (64-byte cap holds). The Ofertar button is its own keyboard row appended to eligible Phase 1/Phase 2 alerts and the primary row on negotiable alerts, so the Comprar contract is untouched. Lifecycle mirrors v0.4.3 exactly: tap β†’ all rows repainted with a `🟑 Ofertando…` noop badge β†’ on success a terminal non-tappable `πŸ’° Oferta enviada` badge (Comprar row NOT restored on a phase2 alert β€” the listing is over ceiling or being negotiated; if a later price drop makes it buyable, the live-edit machinery re-derives the keyboard) β†’ on failure/abort the original rows are restored so the operator can retry (preflight re-gates every tap). `alert_updater.reconstruct_keyboard` learns the offer states, and the buy-suppression window pattern is reused for in-flight offers (never repaint under a running offer). + +**D9 β€” Failure taxonomy is a new closed enum, not an extension of `BuyFailureReason`.** +`OfferFailureReason`: `listing_gone`, `reconciliation_tripped` (price/state drift beyond tolerance, incl. amount-no-longer-undercuts), `offer_unavailable` (the listing exposes no "hacer oferta" affordance β€” PRO sellers, refurbished, excluded categories), `amount_rejected` (the platform refused the amount β€” Wallapop may enforce offer bounds), `daily_limit_reached` (our budget or the platform's cap β€” the render says which and when to retry), `duplicate_offer`, `lockout_engaged`, `missing_element`, `marketplace_error`, `timeout`, `screenshot_missing`, `ui_check_failed`. Each gets a Spanish label, detail rows, next steps, and the reassurance line **"No se ha enviado ninguna oferta."** (with the `screenshot_missing`-style ambiguity carve-out: the send succeeded but evidence is missing β†’ say exactly that). Buy and offer taxonomies evolve independently; sharing the enum would couple two closed sets that the render tables and variant-count pins treat as exhaustive. + +**D10 β€” Config and wishlist shape.** +New `OfferConfig` on `ConfigModel` (`offer:` section): `band_pct` (default 0.20), `daily_limit` (default 5), `lockout_threshold` (default 3), `kill_switch_global` (default false). New per-entry `OfferSettings` (`offer:` block, `extra="forbid"`): `enabled` (default false), `target_total_eur: Decimal | None`. CLI `salvager offer enable ` / `disable ` / `disable --all` / `status` mirror the `phase2` command group (enable clears the lockout; wishlist reload semantics identical β€” poll loop needs restart, tap preflight re-reads). Both example configs (root + bundled twin) updated in lockstep (byte-identical test). + +## Risks / Trade-offs + +- [TinyFish goal fragility β€” the app's offer flow is documented, but the WEB UI the agent drives may differ] β†’ First adapter task verifies the web flow against the operator's app captures; strict output contract + mandatory sent-state verification + screenshot; `ui_check_failed`/`missing_element` surface divergence without ambiguity; lockout stops repeated burn. +- [Dead taps on offer-ineligible listings (PRO sellers, refurbished, excluded categories)] β†’ The agent reports `offer_unavailable` cleanly (no lockout-worthy malfunction); implementation checks whether the search/detail payload exposes a PRO-seller or offer-eligibility flag to pre-filter negotiable alerts before they render a button that cannot work. +- [Ambiguity about whether an offer was sent when the agent times out or the screenshot is missing] β†’ Same posture as the buy path: reason-specific reassurance copy that never overclaims, plus the dedupe check (a re-tap after an ambiguous outcome re-checks; the operator can verify in the Wallapop app β€” the alert's next-steps say exactly where to look). +- [Wallapop may bound the allowed offer range and reject deep offers, especially in the band] β†’ `amount_rejected` failure reason renders the platform's constraint to the operator; no retry loop. +- [Band widens the LLM-eval funnel and Telegram volume] β†’ Opt-in per entry, band default +20 %, confidence gate unchanged; if noisy, the operator narrows `band_pct` or disables `offer:` on loud entries. +- [Acceptance starts a 24 h clock v1 cannot see β€” the accepted price is only buyable for 24 h, the item is NOT reserved meanwhile, and another buyer can still take it] β†’ v1 next-steps copy on `Oferta enviada` states exactly this: watch the Wallapop chat, and on acceptance buy within 24 h in the app. The bounded window plus no-reserve is the main driver for a v2 acceptance-detection change (and the accepted-price buy would slot into the existing Comprar machinery). +- [Keyboard-state matrix grows (buy Γ— offer Γ— reserved Γ— edits)] β†’ All transitions concentrated in `reconstruct_keyboard` + the two orchestrators' restore paths; snapshot tests pin every rendered keyboard variant, and the FR22-locked formats get their PRD amendment in this change. +- [Variant-count pins and snapshot registry churn (currently 45)] β†’ Mechanical but mandatory: registry, count pins, and golden snapshots updated together (repo convention; Sonar duplication gate satisfied the same way as PR #50 β€” fixtures built FROM the registry). + +## Migration Plan + +Additive throughout: migration `0004` creates two new tables (existing rows untouched); config/wishlist keys are new and optional; no behaviour change until an entry opts in. Deploy as a normal tagged release under the hard rules (specific run id β†’ GHCR manifest 200 β†’ `podman pull` before touching the quadlet). Rollback = repoint `Image=` at the previous tag; the 0004 tables are simply ignored by older code. First arming in production should mirror the Corsair pattern: one abundant test entry, `offer enable`, restart, wait for a real band listing. + +## Open Questions + +*(Resolved 2026-07-22 by Wallapop's help pages + the operator's app captures: entry point = listing-page button; bounds = minimum 70 % of asking, must undercut asking; cap = 10/calendar day/account, same-product repeats count once.)* + +- ~~Does the WEB UI TinyFish drives expose the same listing-page "Hacer oferta" flow as the app?~~ β€” **resolved (operator web captures, 2026-07-23): YES.** The web listing page shows "Hacer oferta" directly under Comprar; clicking opens the "Hacer una oferta" modal at `es.wallapop.com/app/chat/offer?itemId=` (the same internal id the re-fetch uses β€” the goal carries it as a recovery hint). Same "N ofertas restantes para hoy" counter. The amount field accepts cents **with a comma separator** ("20,22") β€” our whole-euro amounts need no decimals; the goal documents the separator anyway. One web-specific nuance: no -30 % floor message was visible client-side at a below-floor amount, so the web may validate on submit β€” irrelevant in practice since the domain pre-validates the floor and `amount_rejected` covers a server-side refusal. +- Does the search/detail payload expose a PRO-seller or offer-eligibility flag usable for pre-filtering negotiable alerts? +- ~~Whether `audit show` should grow a `--type offer` view~~ β€” **resolved during apply (2026-07-23): deferred for parity** with the buy path (`tap_events`/`transactions` aren't surfaced there either). Offer taps ride the callbacks audit; `offers` rows are queryable via `salvager offer status` (budget/lockout) and raw SQLite. A future change can add `--type offer` alongside `--type phase2`. diff --git a/openspec/changes/wallapop-make-offer/proposal.md b/openspec/changes/wallapop-make-offer/proposal.md new file mode 100644 index 0000000..66cc429 --- /dev/null +++ b/openspec/changes/wallapop-make-offer/proposal.md @@ -0,0 +1,45 @@ +## Why + +Wallapop has a native "hacer oferta" flow: a buyer can propose a lower price on a listing, and the seller can accept, reject, or counter. Salvager currently ignores it entirely β€” the pipeline is binary (alert at or under the ceiling, silence above it), which leaves two kinds of money on the table: + +1. **Listings slightly over the ceiling are invisible.** A Corsair kit at 88 € against an 80 € ceiling never alerts, even though a routine 10 % offer would land it inside budget. During burn-in the operator has watched exactly this band of near-miss listings scroll past with no way to act on them. +2. **Even alertable listings are bought at asking price.** The Comprar path pays whatever the seller listed. On a marketplace where haggling is the norm, that is systematically overpaying. + +This change gives the operator a **πŸ’° Ofertar** button β€” the negotiation sibling of Comprar β€” that sends a bounded, auditable offer through the same safety stack the buy path already proved during burn-in (preflight, reconciliation re-fetch, TinyFish execution, append-only audit, keyboard lifecycle). Wallapop only; eBay Best Offer is explicitly out of scope for v1. + +## What Changes + +- **New `πŸ’° Ofertar` button on Wallapop alerts.** Operator-tap only, mirroring FR29's no-autonomous-action rule: tap β†’ offer preflight β†’ cross-source reconciliation re-fetch β†’ TinyFish sends the offer β†’ outcome reported to Telegram and audited. No offer is ever sent without a tap. +- **Offer amount is computed, not chosen.** The offer is the highest item price whose buyer total (item + shipping + ProtecciΓ³n) still fits the entry's ceiling β€” i.e. "make this listing fit my budget", derived from the existing `buyer_total_eur` pricing. No new amount-picking UI; the amount is shown on the alert before the operator taps. +- **New "negotiable band" alert.** Wallapop listings whose buyer total is over the ceiling but within a configurable band (default +20 %) stop being silently filtered and instead produce a distinct negotiable alert carrying the Ofertar button (no Comprar β€” they are over ceiling by definition). Listings beyond the band stay filtered. Alerted (at-or-under-ceiling) Wallapop listings additionally get the Ofertar button when an offer would still undercut the asking price. +- **v1 ends at "offer sent".** The outcome of a successful execution is `πŸ’° Oferta enviada` (alert edited in place, audit row written). Detecting seller acceptance/rejection/counter is out of scope β€” the operator continues the negotiation in the Wallapop app. The design must not paint v1 into a corner for a future acceptance-detection change. +- **Per-entry opt-in.** A new `offer:` block on wishlist entries (`enabled`, default `false`), mirroring `phase2:`. Entries without it see zero behaviour change β€” no button, no negotiable alerts, no new filtering semantics. +- **Same guardrails as the buy path, plus a daily budget.** Offer-specific closed failure-reason enum with a full render table, in-flight `🟑 Ofertando…` badge with guaranteed keyboard restore on every outcome, append-only offer audit tables (new migration), offer events wired into the operational-event registry, a per-listing dedupe so the same listing is never offered twice, and a self-imposed daily offer budget (`offer.daily_limit`, default 5 per rolling 24 h) so Salvager never blindly burns Wallapop's own daily offer cap β€” which the agent also detects and reports distinctly when the platform enforces it first. + +## Capabilities + +### New Capabilities + +- `wallapop-offer-flow`: The end-to-end operator-confirmed offer capability β€” eligibility (which Wallapop listings render the Ofertar button and when a negotiable-band alert is emitted), the ceiling-derived offer-amount computation, the tap β†’ preflight β†’ reconciliation β†’ TinyFish send pipeline, the `Oferta enviada` terminal state with in-place alert edit, the closed offer failure-reason set with rendered outcomes, keyboard lifecycle across all outcomes, per-listing offer dedupe, and the append-only offer audit trail. + +### Modified Capabilities + +- `shipping-aware-pricing`: The alert-gate requirement "buyer total over ceiling β†’ filtered" gains a carve-out: Wallapop listings over ceiling but within the configured negotiable band, on offer-enabled entries, are routed to a negotiable alert instead of dropped. Everything else about buyer-total filtering is unchanged (eBay unaffected; over-band unchanged). +- `listing-alert-state-updates`: Keyboard reconstruction after a live edit must preserve offer state β€” an alert whose offer was already sent keeps its `πŸ’° Oferta enviada` badge (and the negotiable-alert keyboard must survive reserved-flip/price-drop edits the same way the Phase 2 keyboard does). Price-drop edits also re-derive Ofertar eligibility (a drop can move a listing from negotiable band to under-ceiling). + +## Impact + +**Affected code**: +- `src/salvager/domain/alert.py` β€” `CallbackVerb.offer`, `BUTTON_LABELS`/`SEVERITY_TOKENS` additions (PRD-amendment-gated sets), Ofertar button rows, negotiable-alert renderer, offer success/failure renderers, `OfferFailureReason` render table. +- `src/salvager/domain/` β€” offer amount derivation next to `pricing.py`; `OfferFailureReason` in `errors.py`; offer audit write-models; `offer:` block in `wishlist.py`. +- `src/salvager/orchestration/` β€” `offer_orchestrator.py` (sibling of `buy_orchestrator.py`), offer preflight, `callback_handler.py` routing for the `offer` verb, poll-loop alert gate carve-out for the negotiable band, `alert_updater.py` keyboard-reconstruction awareness, `composer.py` wiring. +- `src/salvager/interfaces/` + `src/salvager/adapters/tinyfish_browser/` β€” offer-send port and a `wallapop_offer.py` TinyFish goal (adapter discipline NFR-M1: the SDK import stays inside the adapter package; payment-rail lint still applies). +- `src/salvager/adapters/sqlite_store/` + `migrations/0004_*.sql` β€” offer tap/outcome tables, append-only writer methods (the append-only lint test must keep passing). +- `src/salvager/cli/dev_alert_fixtures.py` + `tests/unit/test_dev_emit_alert.py` β€” new variants; the 45-variant registry pin and snapshot suites grow accordingly. +- `src/salvager/config/config_yaml.py` + `config.example.yaml` (and its byte-identical bundled twin in `src/salvager/templates/`) β€” `offer:` config section (band percentage, kill switch); `wishlist.example.yaml` β€” per-entry `offer:` block. + +**Affected docs**: PRD gains new FR ids for the offer flow (referenced from the new spec); `README.md` gains an "Ofertas" subsection; `CHANGELOG.md` on release. + +**Real-money posture**: an offer is a commitment signal, not a payment β€” no money moves in v1 (Wallapop charges only when a resulting purchase completes, which stays behind the existing Comprar path). Still gated like Phase 2: per-entry opt-in, operator tap, preflight, circuit-breaker-style lockout on repeated failures, kill switch. + +**Backwards compatibility**: with no `offer:` blocks in the wishlist (the default), rendering, filtering, callbacks, and the daemon lifecycle are byte-identical to v0.4.4 behaviour. The rendering re-audit (v1.0 criterion 3) is unaffected until this ships, at which point the new alert surfaces join the audit scope like every prior release. diff --git a/openspec/changes/wallapop-make-offer/specs/listing-alert-state-updates/spec.md b/openspec/changes/wallapop-make-offer/specs/listing-alert-state-updates/spec.md new file mode 100644 index 0000000..33f7727 --- /dev/null +++ b/openspec/changes/wallapop-make-offer/specs/listing-alert-state-updates/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Phase 2 Keyboards Are Reconstructed Safely On Edit + +An edit SHALL send the keyboard the message currently deserves, reconstructed from the `callbacks` table: original phase row when no callback fired, the ack row after view/skip/snooze, and β€” when the last verb is `buy` and the tap is younger than a bounded suppression window (callbacks are append-only, so the marker MUST age out or a completed buy would suppress edits forever) β€” the edit SHALL be SKIPPED entirely (never repaint under a running buy; the diff re-fires next cycle). On reserved, a Phase 2 alert's `βœ… Comprar` row SHALL be replaced with a non-tappable `πŸ”΄ Reservado` badge; on flip-back the row SHALL be restored. Phase 2 price drops SHALL receive no special keyboard treatment (preflight and reconciliation re-validate price at tap time). + +Reconstruction SHALL additionally account for offer state (see `wallapop-offer-flow`): when the last verb is `offer` and the tap is younger than the same bounded suppression window, the edit SHALL be SKIPPED entirely (never repaint under a running offer); an alert with a recorded successful offer SHALL keep its terminal `πŸ’° Oferta enviada` badge across every edit; an un-offered Ofertar row SHALL go dead on reserved and be restored on flip-back, like the Comprar row; and price-change edits SHALL re-derive Ofertar eligibility and the offer amount from the listing's current values (a drop into the ceiling re-renders the alert with its standard body and keyboard for the entry). + +#### Scenario: Comprar goes dead on reserved + +- **WHEN** a watched Phase 2 alert's listing flips to reserved +- **THEN** the edited message carries the `πŸ”΄ Reservado` badge row instead of `βœ… Comprar` + +#### Scenario: Flip-back restores the buy row + +- **WHEN** that listing later returns to available within the watch window +- **THEN** the edited message shows the `🟒 Disponible de nuevo` banner and the original Phase 2 button row again + +#### Scenario: Never repaint under an in-flight buy + +- **WHEN** a state change is detected while the alert's last callback verb is `buy` +- **THEN** no edit is attempted that cycle + +#### Scenario: Never repaint under an in-flight offer + +- **WHEN** a state change is detected while the alert's last callback verb is `offer` and the tap is within the suppression window +- **THEN** no edit is attempted that cycle + +#### Scenario: Oferta enviada badge survives reconstruction + +- **WHEN** a watched alert with a recorded successful offer is edited for any state change +- **THEN** the reconstructed keyboard still carries the `πŸ’° Oferta enviada` badge + +#### Scenario: Price drop re-derives the offer surface + +- **WHEN** a watched negotiable alert's listing drops in price +- **THEN** the edited message's offer line and keyboard reflect the recomputed amount β€” or the standard alert surface when the buyer total is now at or under the ceiling diff --git a/openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md b/openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md new file mode 100644 index 0000000..92c206b --- /dev/null +++ b/openspec/changes/wallapop-make-offer/specs/shipping-aware-pricing/spec.md @@ -0,0 +1,32 @@ +## MODIFIED Requirements + +### Requirement: Price Ceilings Compare The Buyer Total + +The Phase 1 alert gate and the Phase 2 buy gate SHALL compare the buyer total (not the item price) against the entry's ceiling. The eBay search MAY keep an item-level API pre-filter for quota economy, but the authoritative ceiling check SHALL be applied post-fetch against the buyer total. + +The over-ceiling alert-gate filter SHALL have exactly one carve-out: a **Wallapop** listing on an entry with `offer.enabled = true` whose buyer total exceeds the ceiling but is at or below `ceiling Γ— (1 + offer.band_pct)` β€” and for which a valid offer amount exists (`wallapop-offer-flow` amount requirement; the 70 % platform floor can rule one out) β€” SHALL NOT be dropped β€” it SHALL be tagged negotiable and routed onward to evaluation per the `wallapop-offer-flow` capability. Listings beyond the band, listings on offer-disabled entries, and all eBay listings SHALL be filtered over-ceiling exactly as before. The carve-out SHALL NOT affect the Phase 2 buy gate: a buyer total over the ceiling SHALL remain ineligible to buy regardless of offer settings. + +#### Scenario: Phase 2 buy blocked when total exceeds ceiling + +- **WHEN** a listing's item price is at or below the Phase 2 ceiling but its buyer total exceeds it +- **THEN** the Phase 2 buy gate returns ineligible (`phase2_max_price_below_listing` or equivalent) and the alert renders Phase 1 (no Comprar) + +#### Scenario: A within-ceiling delivered total stays buyable + +- **WHEN** a listing's buyer total is at or below the Phase 2 ceiling +- **THEN** the Phase 2 gate's price check passes + +#### Scenario: In-band Wallapop listing survives the alert gate + +- **WHEN** a Wallapop listing on an offer-enabled entry has a buyer total of 88 € against an 80 € ceiling with `offer.band_pct = 0.20` +- **THEN** the alert gate keeps the listing, tagged negotiable, instead of filtering it + +#### Scenario: The carve-out never applies to eBay or offer-disabled entries + +- **WHEN** an eBay listing, or a Wallapop listing on an entry without `offer.enabled`, has a buyer total over the ceiling +- **THEN** it is filtered exactly as before this change + +#### Scenario: Negotiable never means buyable + +- **WHEN** a negotiable-tagged listing reaches the Phase 2 buy gate with a buyer total over the ceiling +- **THEN** the buy gate returns ineligible diff --git a/openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md b/openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md new file mode 100644 index 0000000..7a3ed04 --- /dev/null +++ b/openspec/changes/wallapop-make-offer/specs/wallapop-offer-flow/spec.md @@ -0,0 +1,216 @@ +## ADDED Requirements + +### Requirement: Offer Amount Is Derived From The Entry's Target Total + +The domain SHALL provide a pure offer-amount function that returns the largest whole-euro item price `O` such that `buyer_total_eur(O)` (same shipping and ProtecciΓ³n model as the pricing capability) is at or below the entry's **offer target**. The offer target SHALL be `offer.target_total_eur` when set on the entry, else the entry's effective ceiling (`max_price_solo`). The function SHALL return no amount (offer not possible) when `O` is not strictly below the listing's asking item price, when `O` would be ≀ 0, or when `O` is below the platform floor of 70 % of the asking item price (Wallapop rejects discounts deeper than 30 %). The computed amount SHALL be rendered on the alert before any tap, and SHALL be recomputed from the reconciled listing at tap time. + +#### Scenario: Band listing gets a ceiling-fit offer + +- **WHEN** a Wallapop listing asks 88,00 € with known shipping 3,50 € against an 80,00 € target +- **THEN** the offer amount is the largest whole-euro item price whose item + shipping + ProtecciΓ³n total is ≀ 80,00 € +- **AND** the amount is strictly below 88,00 € + +#### Scenario: Under-ceiling listing with default target yields no offer + +- **WHEN** a listing's buyer total is already at or below the entry ceiling and the entry has no `offer.target_total_eur` +- **THEN** the ceiling-fit amount is not strictly below the asking price and no offer amount is produced (no Ofertar button) + +#### Scenario: A lower per-entry target activates offers under the ceiling + +- **WHEN** the entry sets `offer.target_total_eur` = 70,00 € and a listing's buyer total is 78,00 € (under the 80,00 € ceiling) +- **THEN** an offer amount fitting the 70,00 € target is produced and it is strictly below the asking price + +#### Scenario: Platform floor blocks too-deep offers + +- **WHEN** the target-fit price would be below 70 % of the listing's asking item price (e.g. a 40 € fit against a 60 € asking price) +- **THEN** no offer amount is produced and no Ofertar button renders + +--- + +### Requirement: The Ofertar Button Renders Only Where An Offer Is Possible + +Wallapop listing alerts (Phase 1 and Phase 2) SHALL carry a `πŸ’° Ofertar` row when and only when: the entry has `offer.enabled = true`, an offer amount was produced per the amount requirement, and no successful offer has previously been sent for the listing. eBay alerts SHALL never carry the row. The button's callback data SHALL follow the locked `::` format with a new `offer` verb, referencing the alert snapshot id. The `πŸ’° Ofertar` label and any new status tokens SHALL be added to the locked `BUTTON_LABELS` / severity-token sets via PRD amendment as part of this change. + +#### Scenario: Offer-enabled entry with headroom shows the button + +- **WHEN** a negotiable-band Wallapop alert renders for an offer-enabled entry with a computed offer of 74 € +- **THEN** the keyboard includes a `πŸ’° Ofertar` row with callback data `listing:offer:` + +#### Scenario: Offer-disabled entry renders unchanged + +- **WHEN** a Wallapop alert renders for an entry without `offer.enabled` +- **THEN** the keyboard is byte-identical to the pre-feature keyboard for that alert type + +#### Scenario: eBay alerts never offer + +- **WHEN** an eBay alert renders for an entry with `offer.enabled = true` +- **THEN** no Ofertar row is present + +--- + +### Requirement: Negotiable-Band Listings Produce A Distinct Alert + +Wallapop listings on offer-enabled entries whose buyer total exceeds the entry ceiling but is at or below `ceiling Γ— (1 + offer.band_pct)` (config, default 0.20) β€” AND for which a valid offer amount exists per the amount requirement (the platform's 70 % floor can rule one out) β€” SHALL NOT be filtered by the over-ceiling gate; they SHALL pass through the unchanged LLM evaluation and confidence gate and, when they pass, render a **negotiable alert**: a distinct severity token, the standard buyer-total breakdown, an offer line showing the computed amount and the target it fits, and the Ofertar row β€” never a Comprar row. Listings beyond the band, on offer-disabled entries, or on eBay SHALL be filtered exactly as before. Negotiable alerts SHALL create watch rows and participate in seen-listing dedupe like any listing alert. + +#### Scenario: In-band listing alerts as negotiable + +- **WHEN** a Wallapop listing's buyer total is 88 € against an 80 € ceiling with `band_pct = 0.20` on an offer-enabled entry, and its evaluation passes the entry's confidence threshold +- **THEN** a negotiable alert is dispatched with the offer line and the Ofertar row and no Comprar row + +#### Scenario: Beyond-band listing stays filtered + +- **WHEN** the same entry sees a listing with buyer total 97 € (> 80 € Γ— 1.20) +- **THEN** the listing is filtered with no alert, exactly as before this change + +#### Scenario: Low-confidence band listing does not alert + +- **WHEN** an in-band listing's evaluation falls below the entry's confidence threshold +- **THEN** no alert is dispatched + +--- + +### Requirement: An Offer Is Sent Only Through The Operator-Tap Safety Stack + +An offer SHALL be sent only in response to an operator tap on the Ofertar button (no autonomous offers). The tap SHALL drive, in order: an offer preflight (entry still offer-enabled per a fresh wishlist read, offer path not locked out or kill-switched, daily offer budget not exhausted, listing not reserved, no prior successful offer for the listing), a cross-source reconciliation re-fetch of the listing by internal id, recomputation of the offer amount from the fresh listing (aborting when the fresh amount drifts from the displayed amount beyond the configured reconciliation tolerance, or no longer undercuts the asking price), and execution through the browser-session port with the exact bounded amount β€” the agent goal SHALL forbid sending any other amount. A 404 on the re-fetch SHALL abort with `listing_gone`. Safety aborts SHALL send no offer. + +#### Scenario: Happy path sends the displayed amount + +- **WHEN** the operator taps Ofertar on an alert showing a 74 € offer and reconciliation returns the listing unchanged +- **THEN** the browser adapter is invoked with exactly 74 € and, on success, the outcome is `Oferta enviada` + +#### Scenario: Gone listing aborts fail-closed + +- **WHEN** the reconciliation re-fetch returns 404 +- **THEN** no offer is sent, the outcome is a `listing_gone` abort, and the lockout counter is not incremented + +#### Scenario: Price rise invalidates the displayed offer + +- **WHEN** the reconciled listing's price changed such that the recomputed amount drifts beyond tolerance from the displayed amount +- **THEN** no offer is sent and the outcome is a `reconciliation_tripped` abort + +#### Scenario: Duplicate offer is refused + +- **WHEN** the operator taps Ofertar for a listing that already has a successful offer recorded +- **THEN** no offer is sent and the outcome is a `duplicate_offer` abort + +--- + +### Requirement: Offer Failure Reasons Are A Closed Rendered Set + +Offer outcomes SHALL use a dedicated closed enum `OfferFailureReason` (`listing_gone`, `reconciliation_tripped`, `offer_unavailable`, `amount_rejected`, `daily_limit_reached`, `duplicate_offer`, `lockout_engaged`, `missing_element`, `marketplace_error`, `timeout`, `screenshot_missing`, `ui_check_failed`), separate from `BuyFailureReason`. Every variant SHALL have a Spanish label, detail rows, and next-steps in the render table β€” a variant without a render entry SHALL fail loudly. Failure alerts SHALL carry the reassurance line "No se ha enviado ninguna oferta.", except reasons where the send may have happened but cannot be proven (e.g. `screenshot_missing`), whose copy SHALL state that ambiguity and direct the operator to verify in the Wallapop app. + +#### Scenario: Every variant renders + +- **WHEN** a failure alert is rendered for each `OfferFailureReason` variant +- **THEN** each produces a complete message with label, details, next steps, and the reason-appropriate reassurance or ambiguity line + +#### Scenario: Platform-rejected amount is explained + +- **WHEN** the agent reports the platform refused the offered amount +- **THEN** the failure alert renders `amount_rejected` with the attempted amount and states no offer was sent + +--- + +### Requirement: Offer Outcomes Drive The Keyboard Lifecycle + +On tap, the alert's keyboard SHALL immediately repaint to a non-tappable `🟑 Ofertando…` badge. On success, the keyboard SHALL show a terminal non-tappable `πŸ’° Oferta enviada` badge. On any failure or abort, the alert's original rows (including Comprar where previously present) SHALL be restored so the operator can retry β€” the preflight re-gates every tap. Keyboard restoration SHALL be attempted for every outcome; no outcome may leave the in-flight badge in place. + +#### Scenario: In-flight badge during execution + +- **WHEN** the offer tap is accepted for execution +- **THEN** the message's keyboard shows only the `🟑 Ofertando…` badge until an outcome lands + +#### Scenario: Failure restores a tappable keyboard + +- **WHEN** the offer fails with `timeout` +- **THEN** the original keyboard rows are restored and a subsequent tap runs the full preflight again + +#### Scenario: Success is terminal + +- **WHEN** the offer succeeds +- **THEN** the keyboard shows the `πŸ’° Oferta enviada` badge and the Ofertar row never returns for that listing + +--- + +### Requirement: Live Edits Keep The Offer Surface Truthful + +When the alert-update machinery edits a watched alert, the re-rendered body SHALL re-derive the offer line from the listing's current values, and the reconstructed keyboard SHALL reflect current offer eligibility: a price drop that moves a negotiable listing to at-or-under ceiling SHALL re-render it with the standard (Phase 1/Phase 2) body and keyboard for its entry; a sent offer SHALL keep its `πŸ’° Oferta enviada` badge across edits; a reserved flip on a negotiable alert SHALL dead the Ofertar row (restored on flip-back). No edit SHALL be attempted while an offer is in flight. + +#### Scenario: Drop into budget upgrades the alert + +- **WHEN** a watched negotiable listing's price drops so its buyer total is at or below the entry ceiling +- **THEN** the edited message renders the standard alert body and keyboard (including Comprar when the entry qualifies for Phase 2) + +#### Scenario: Sent badge survives edits + +- **WHEN** a reserved-flip edit fires on an alert whose offer was already sent +- **THEN** the edited message still shows the `πŸ’° Oferta enviada` badge + +--- + +### Requirement: Offers Respect A Daily Budget + +The system SHALL enforce a self-imposed daily offer budget: when the number of successful offer sends in the trailing 24 hours (counted from the `offers` table) has reached `offer.daily_limit` (config, default 5 β€” deliberately under Wallapop's cap of 10 offers per calendar day per account, leaving headroom for the operator's manual offers), the offer preflight SHALL abort the tap with `daily_limit_reached` before any execution. Independently, when the browser agent reports Wallapop's own exhausted counter, the outcome SHALL also be `daily_limit_reached`; in both cases the rendered message SHALL say which limit was hit and when a retry becomes possible, and the outcome SHALL NOT increment the lockout counter (the offer path is healthy). When the offer form's "ofertas restantes" counter is visible to the agent, its value SHALL be captured into the attempt's audit row. + +#### Scenario: Budget spent blocks before execution + +- **WHEN** `offer.daily_limit` successful offers were sent within the trailing 24 hours and the operator taps Ofertar +- **THEN** no execution starts, the outcome is `daily_limit_reached`, the keyboard is restored, and the lockout counter is unchanged + +#### Scenario: Platform cap reported mid-flow + +- **WHEN** the agent reports Wallapop refused the offer because the account's daily offer limit is reached +- **THEN** the outcome is `daily_limit_reached` attributing the platform's limit, and the lockout counter is unchanged + +#### Scenario: Budget window rolls + +- **WHEN** the oldest of the counted sends becomes older than 24 hours +- **THEN** the next tap passes the budget check again + +--- + +### Requirement: Repeated Offer Failures Lock The Offer Path + +Consecutive offer execution failures reaching `offer.lockout_threshold` (config, default 3) SHALL globally disable offer sending until the operator clears the lockout. Safety aborts (`listing_gone`, `reconciliation_tripped`, `duplicate_offer`, `lockout_engaged`, `daily_limit_reached`) SHALL NOT increment the counter; a successful send SHALL reset it. `offer.kill_switch_global = true` SHALL disable offer sending unconditionally. The offer lockout and the Phase 2 circuit breaker SHALL be independent: neither counter's state affects the other path. + +#### Scenario: Third failure engages the lockout + +- **WHEN** three consecutive offer executions fail with agent errors +- **THEN** the next tap aborts with `lockout_engaged` before any execution + +#### Scenario: Offer lockout leaves buys untouched + +- **WHEN** the offer lockout is engaged +- **THEN** Phase 2 Comprar taps preflight and execute exactly as before + +--- + +### Requirement: Offers Are Opt-In Per Entry And Armable Via CLI + +Wishlist entries SHALL support an `offer:` block (`enabled: bool`, default false; `target_total_eur: Decimal | None`), rejected on unknown fields. The CLI SHALL provide `salvager offer enable `, `offer disable `, `offer disable --all`, and `offer status`, mirroring the `phase2` command group; `offer enable` SHALL clear the lockout. With no `offer:` block on any entry, the system's observable behaviour (filtering, rendering, callbacks) SHALL be identical to the pre-feature behaviour. + +#### Scenario: Arming an entry + +- **WHEN** the operator runs `salvager offer enable ` and restarts the daemon +- **THEN** `offer status` shows the entry enabled with its target, and its Wallapop alerts become offer-eligible + +#### Scenario: No opt-in means no change + +- **WHEN** no wishlist entry carries an `offer:` block +- **THEN** alert filtering and rendering are byte-identical to pre-feature behaviour + +--- + +### Requirement: Every Offer Attempt Is Audited Append-Only + +Every executed offer attempt (success or failure) SHALL append a row to a new `offers` table (migration `0004`): listing id and marketplace, entry key, alert id, offered amount, asking item price at tap time, outcome, failure reason where applicable, screenshot path where available, a `status` column (`sent` in v1), and timestamps. Offer taps SHALL be recorded through the existing callback audit path. Writer methods for `offers` SHALL be INSERT-only (the append-only lint SHALL keep passing); the lockout state SHALL live in a separate single-row mutable `offer_state` table, mirroring `phase2_state`. + +#### Scenario: Success and failure both leave rows + +- **WHEN** one offer succeeds and a later one times out +- **THEN** the `offers` table contains one row per attempt with amount, outcome, and reason + +#### Scenario: Append-only contract holds + +- **WHEN** the audit-writer lint inspects the offer writer methods +- **THEN** no update or delete method exists for the `offers` table diff --git a/openspec/changes/wallapop-make-offer/tasks.md b/openspec/changes/wallapop-make-offer/tasks.md new file mode 100644 index 0000000..3a99c10 --- /dev/null +++ b/openspec/changes/wallapop-make-offer/tasks.md @@ -0,0 +1,52 @@ +## 1. Domain: amount, settings, errors + +- [x] 1.1 Add `offer_item_price_eur(listing, target, *, assumed_shipping_eur)` next to `domain/pricing.py` β€” largest whole-euro item price whose buyer total fits the target; returns `None` when not strictly below the asking price or ≀ 0 β€” with unit tests covering the band case, the under-ceiling default-target case (no offer), a lower per-entry target, the 70 %-of-asking platform floor (no offer below it), the ProtecciΓ³n 13 € boundary, and unknown shipping (buffer) +- [x] 1.2 Add `OfferSettings` (`enabled: bool = False`, `target_total_eur: Decimal | None`, `extra="forbid"`) to `domain/wishlist.py` as `WishlistEntry.offer`, mirroring `Phase2Settings`; update `wishlist.example.yaml` and wishlist-yaml tests +- [x] 1.3 Add closed `OfferFailureReason` enum (12 variants per spec) to `domain/errors.py` with `@enum.unique` +- [x] 1.4 Add `OfferConfig` (`band_pct = 0.20`, `daily_limit = 5`, `lockout_threshold = 3`, `kill_switch_global = False`) to `config/config_yaml.py` as `ConfigModel.offer`; update `config.example.yaml` AND its byte-identical bundled twin `src/salvager/templates/config.example.yaml`; extend config tests + +## 2. Persistence: migration 0004 + writers + +- [x] 2.1 Write `migrations/0004_offer_schema.sql`: append-only `offers` table (listing id, marketplace, entry key, alert id, offered amount, asking price at tap, outcome, failure reason, screenshot path, `platform_remaining` nullable β€” the "ofertas restantes" counter when the agent saw it, `status` default `'sent'`, timestamps) + single-row mutable `offer_state` (`CHECK id=1`: `globally_disabled`, `disabled_reason/at`, `consecutive_failures`) +- [x] 2.2 Add INSERT-only `record_offer_attempt` plus `offer_state` counter/lockout methods to the sqlite audit writer, and a `has_successful_offer(marketplace, listing_id)` reader; verify `test_audit_writer_append_only.py` still passes (no `update_*`/`delete_*` on `offers`) +- [x] 2.3 Migration test on a copy of a real-schema DB: 0004 applies cleanly over 0003, existing rows untouched + +## 3. Pipeline: negotiable band carve-out + +- [x] 3.1 Extend the over-ceiling filter in `poll_loop` with the single carve-out (Wallapop + `offer.enabled` + buyer total ≀ ceiling Γ— (1 + `offer.band_pct`) β†’ keep, tagged negotiable); over-band / offer-disabled / eBay unchanged; thread the negotiable tag through evaluation to render time +- [x] 3.2 Tests: in-band kept and tagged, over-band filtered, eBay and offer-disabled entries byte-identical to current behaviour, confidence gate still applies to band listings, Phase 2 buy gate still rejects negotiable (over-ceiling) listings + +## 4. Rendering: alerts, buttons, failure table + +- [x] 4.1 Add `CallbackVerb.offer`, the `πŸ’° Ofertar` / `🟑 Ofertando…` / `πŸ’° Oferta enviada` labels and the negotiable severity token to the locked sets in `domain/alert.py`; record the PRD amendment (new FR ids for the offer flow) in `_bmad-output/planning-artifacts/prd.md` and reference them from the spec +- [x] 4.2 Implement the negotiable-alert renderer (distinct token, buyer-total breakdown, offer line with amount + target, Ofertar row, no Comprar) within the photo-caption cap +- [x] 4.3 Append the Ofertar row to eligible Phase 1/Phase 2 Wallapop alerts (eligibility: offer-enabled entry + computed amount + no prior successful offer); eBay and offer-disabled rendering byte-identical (existing snapshots must not change) +- [x] 4.4 Implement offer success renderer (`πŸ’° Oferta enviada`, amount, next-steps pointing at the Wallapop app for the negotiation) and the failure renderer with the full `OfferFailureReason` render table (labels, details, next steps, "No se ha enviado ninguna oferta." reassurance; ambiguity copy for `screenshot_missing`) +- [x] 4.5 Extend `cli/dev_alert_fixtures.py` VARIANT_REGISTRY with every new variant (negotiable listing shapes, offer success, 12 failures, any new operational events); update the 45-variant count pins in `test_dev_emit_alert.py` (with the derivation comment) and regenerate golden snapshots β€” fixtures built FROM the registry, Sonar-safe + +## 5. Adapter: TinyFish offer goal + +- [x] 5.1 Verify the WEB "hacer oferta" flow against the operator's app captures in `captures/` (app rules already pinned: listing-page button, 70 %-of-asking floor, 10/day counter, whole-euro amounts): confirm the web UI exposes the same flow and sent-state confirmation, and whether the amount field accepts cents β€” the remaining open questions in design.md, before goal authoring +- [x] 5.1b Check whether the Wallapop search/detail payload exposes a PRO-seller or offer-eligibility flag; if yes, pre-filter offer eligibility (no Ofertar button / no negotiable alert on ineligible listings) and cover with schema tests +- [x] 5.2 Add an `execute_offer(listing, amount_eur) -> OfferResult` port (either on `BrowserSession` or a sibling `OfferSession` protocol β€” decide against the walkthrough) and `adapters/tinyfish_browser/wallapop_offer.py` with the goal contract (exact-amount clause, sent-state verification, screenshot) + an `OFFER_OUTPUT_CONTRACT` mirroring the buy one; map agent outcomes β†’ `OfferFailureReason`; `tinyfish` import stays in the adapter package; payment-rail lint passes +- [x] 5.3 Unit tests with a faked TinyFish client: success, each mapped failure outcome, malformed agent payload β†’ `marketplace_error` + +## 6. Orchestration: preflight, orchestrator, callbacks + +- [x] 6.1 Implement `OfferPreflight` (offer-enabled per fresh wishlist read, kill switch, lockout, daily budget from the trailing-24h `offers` count, not reserved, no prior successful offer) with stable string reasons, cheapβ†’expensive ordering like `Phase2Preflight` +- [x] 6.2 Implement `orchestration/offer_orchestrator.py` (sibling of `BuyOrchestrator`): snapshot lookup β†’ preflight β†’ reconciliation re-fetch by internal id (404 β†’ `listing_gone`, no lockout increment) β†’ recompute amount from fresh listing (drift beyond `phase2.reconciliation_tolerance_*` or amount β‰₯ asking β†’ `reconciliation_tripped`) β†’ `execute_offer` β†’ append `offers` row β†’ lockout `record_outcome` (aborts don't count, success resets) β†’ dispatch outcome alert β†’ keyboard restore/terminal badge on EVERY path +- [x] 6.3 Route the `offer` verb in `callback_handler.py`: HANDLED_VERBS, audit-first contract, `🟑 Ofertando…` repaint, tracked background task set (the `_buy_tasks` pattern) +- [x] 6.4 Extend `alert_updater.reconstruct_keyboard` + edit-skip logic: skip under in-flight offer (suppression window like buy), preserve `Oferta enviada` badge, dead/restore Ofertar on reserved/flip-back, re-derive offer line + eligibility on price edits (drop into ceiling β†’ standard alert surface) +- [x] 6.5 Wire everything in `composer.py` (orchestrator deps, offer writer/reader, dispatching browser gains the offer flow) +- [x] 6.6 Orchestrator/integration tests: happy path, every abort path, lockout engage at threshold + independence from the Phase 2 circuit breaker, daily budget blocks the N+1th tap and rolls off after 24 h (no lockout increment), keyboard restored on every non-success outcome, dedupe blocks a second tap + +## 7. CLI + +- [x] 7.1 Add the `salvager offer` command group: `enable ` (clears lockout), `disable `, `disable --all`, `status` β€” mirroring `phase2` semantics incl. non-TTY behaviour; exit-code tests +- [x] 7.2 Decide (per design open question) whether `audit show` grows `--type offer` now; if yes, wire the `offers` table into `audit_cmd.py`, else record the deferral in the change notes + +## 8. Docs & release readiness + +- [x] 8.1 README "Ofertas" subsection: how to opt in, what the negotiable alert means, what `Oferta enviada` does and does NOT promise (v1 has no acceptance detection; follow through in the Wallapop app) +- [x] 8.2 Full gate: ruff/format/mypy clean, pytest green (the 2 known `/app` sandbox fails excepted), `openspec validate --strict`, snapshot suites regenerated deliberately (no drift in pre-existing reference texts β€” re-audit posture), variant-count pins consistent +- [x] 8.3 Note for the release PR: new alert surfaces enter the v1.0 criterion-3 audit scope; plan the capture additions (negotiable alert + offer outcome variants) in `docs/release-audits/` diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..b4bbeb9 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1 @@ +schema: spec-driven diff --git a/src/salvager/adapters/sqlite_store/offer_writer.py b/src/salvager/adapters/sqlite_store/offer_writer.py new file mode 100644 index 0000000..1008a20 --- /dev/null +++ b/src/salvager/adapters/sqlite_store/offer_writer.py @@ -0,0 +1,212 @@ +"""Append-only offer audit writer (wallapop-offer-flow, NFR-S4). + +:class:`OfferAuditWriter` is the *only* write path into the ``offers`` +audit table and the mutable single-row ``offer_state`` lockout β€” +mirroring :class:`Phase2AuditWriter`'s split exactly, and covered by the +same mechanical append-only lint (``test_audit_writer_append_only.py``): +no ``update_*``/``delete_*`` method, no UPDATE/DELETE against ``offers``. + +The lockout is deliberately independent from ``phase2_state``: offer +failures must never block real buys, and vice versa (design D4). + +Read helpers live here too (same connection, read-only queries): the +per-listing dedupe (``has_successful_offer``) and the rolling-24 h daily +budget count (``count_recent_successes``) that the offer preflight gates +on, plus the ``offer_state`` snapshot. + +Threading model mirrors :class:`Phase2AuditWriter`: every DB call runs in +``asyncio.to_thread`` and a per-instance ``asyncio.Lock`` serializes +writes; the writer owns its own WAL connection. +""" + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from salvager.adapters.sqlite_store.connection import open_connection +from salvager.domain.offer_audit import OfferAttemptRecord, OfferStateSnapshot +from salvager.interfaces.store import EntryKey +from salvager.observability.logging import get_logger + +#: The self-imposed daily budget counts successful sends inside this window. +_DAILY_WINDOW = timedelta(hours=24) + + +class OfferAuditWriter: + """INSERT-only writer for ``offers`` + the mutable ``offer_state`` row.""" + + def __init__(self, db_path: str | Path) -> None: + self._db_path = Path(db_path) + self._connection = open_connection(self._db_path) + self._write_lock = asyncio.Lock() + self._log = get_logger("adapter.offer_audit_writer") + + async def close(self) -> None: + """Close the underlying SQLite connection.""" + async with self._write_lock: + await asyncio.to_thread(self._connection.close) + + # ───────────────────────────────────────────────────────────────── + # Append-only audit rows + # ───────────────────────────────────────────────────────────────── + + async def record_offer_attempt(self, attempt: OfferAttemptRecord) -> int: + def _write() -> int: + cursor = self._connection.execute( + """ + INSERT INTO offers ( + alert_id, listing_id, marketplace, + entry_manufacturer, entry_model, entry_ref, + offered_eur, asking_eur, outcome, failure_reason, + screenshot_path, platform_remaining, status, attempted_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(attempt.alert_id), + attempt.listing_id, + attempt.marketplace, + attempt.entry_key[0], + attempt.entry_key[1], + attempt.entry_key[2], + str(attempt.offered_eur), + str(attempt.asking_eur), + attempt.outcome, + attempt.failure_reason.value if attempt.failure_reason else None, + attempt.screenshot_path, + attempt.platform_remaining, + attempt.status, + attempt.attempted_at.isoformat(), + ), + ) + return int(cursor.lastrowid or 0) + + async with self._write_lock: + return await asyncio.to_thread(_write) + + # ───────────────────────────────────────────────────────────────── + # Read helpers β€” dedupe, daily budget, lockout snapshot + # ───────────────────────────────────────────────────────────────── + + async def has_successful_offer(self, marketplace: str, listing_id: str) -> bool: + """Per-listing dedupe: one successful send per listing, ever (v1).""" + + def _read() -> bool: + cursor = self._connection.execute( + "SELECT 1 FROM offers WHERE marketplace = ? AND listing_id = ? " + "AND outcome = 'success' LIMIT 1", + (marketplace, listing_id), + ) + return cursor.fetchone() is not None + + return await asyncio.to_thread(_read) + + async def count_recent_successes(self, *, now: datetime | None = None) -> int: + """Successful sends inside the trailing 24 h β€” the daily-budget count.""" + reference = now if now is not None else datetime.now(UTC) + cutoff = (reference - _DAILY_WINDOW).isoformat() + + def _read() -> int: + cursor = self._connection.execute( + "SELECT COUNT(*) FROM offers WHERE outcome = 'success' AND attempted_at >= ?", + (cutoff,), + ) + return int(cursor.fetchone()[0]) + + return await asyncio.to_thread(_read) + + async def read_state(self) -> OfferStateSnapshot: + """The mutable lockout row, as of now.""" + + def _read() -> OfferStateSnapshot: + cursor = self._connection.execute( + "SELECT globally_disabled, disabled_at, disabled_reason, consecutive_failures " + "FROM offer_state WHERE id = 1" + ) + row = cursor.fetchone() + return OfferStateSnapshot( + globally_disabled=bool(row[0]), + disabled_at=datetime.fromisoformat(row[1]) if row[1] else None, + disabled_reason=row[2], + consecutive_failures=int(row[3]), + ) + + return await asyncio.to_thread(_read) + + # ───────────────────────────────────────────────────────────────── + # offer_state β€” the single mutable lockout row + # ───────────────────────────────────────────────────────────────── + + async def set_global_disable(self, reason: str) -> None: + """Lock the offer path globally. Durable across restarts.""" + now = datetime.now(UTC).isoformat() + + def _write() -> None: + self._connection.execute( + """ + UPDATE offer_state + SET globally_disabled = 1, disabled_at = ?, disabled_reason = ? + WHERE id = 1 + """, + (now, reason), + ) + + async with self._write_lock: + await asyncio.to_thread(_write) + self._log.warning("offer_globally_disabled", extra={"reason": reason}) + + async def clear_global_disable(self, entry_key: EntryKey) -> None: + """Lift the lockout β€” only via ``salvager offer enable ``. + + ``entry_key`` is the explicit operator-action context (same contract + as the Phase 2 sibling): the lockout never clears automatically. + Also zeroes the consecutive-failure counter β€” re-enabling is a fresh + start, not a resume at threshold-minus-one. + """ + + def _write() -> None: + self._connection.execute( + """ + UPDATE offer_state + SET globally_disabled = 0, disabled_at = NULL, disabled_reason = NULL, + consecutive_failures = 0 + WHERE id = 1 + """, + ) + + async with self._write_lock: + await asyncio.to_thread(_write) + self._log.info( + "offer_global_disable_cleared", + extra={"entry_key": list(entry_key)}, + ) + + async def increment_failure_counter(self) -> int: + """Bump the consecutive-failure counter; return the new value.""" + + def _write() -> int: + self._connection.execute( + "UPDATE offer_state SET consecutive_failures = consecutive_failures + 1 " + "WHERE id = 1" + ) + cursor = self._connection.execute( + "SELECT consecutive_failures FROM offer_state WHERE id = 1" + ) + return int(cursor.fetchone()[0]) + + async with self._write_lock: + return await asyncio.to_thread(_write) + + async def reset_failure_counter(self) -> None: + """Zero the consecutive-failure counter (a successful send).""" + + def _write() -> None: + self._connection.execute("UPDATE offer_state SET consecutive_failures = 0 WHERE id = 1") + + async with self._write_lock: + await asyncio.to_thread(_write) + + +__all__ = ["OfferAuditWriter"] diff --git a/src/salvager/adapters/telegram_bot/surface.py b/src/salvager/adapters/telegram_bot/surface.py index 7593340..ed4c0fe 100644 --- a/src/salvager/adapters/telegram_bot/surface.py +++ b/src/salvager/adapters/telegram_bot/surface.py @@ -59,7 +59,7 @@ #: Pause-between-retries in seconds. Defaults give ~3 attempts in ~20s. DEFAULT_RETRY_DELAYS: tuple[float, ...] = (5.0, 15.0) -_KNOWN_VERBS: frozenset[str] = frozenset({"view", "skip", "snooze", "buy"}) +_KNOWN_VERBS: frozenset[str] = frozenset({"view", "skip", "snooze", "buy", "offer"}) @runtime_checkable diff --git a/src/salvager/adapters/tinyfish_browser/__init__.py b/src/salvager/adapters/tinyfish_browser/__init__.py index c3c3dd6..0075a1d 100644 --- a/src/salvager/adapters/tinyfish_browser/__init__.py +++ b/src/salvager/adapters/tinyfish_browser/__init__.py @@ -14,6 +14,7 @@ """ from salvager.adapters.tinyfish_browser.ebay_checkout import EbayCheckoutFlow +from salvager.adapters.tinyfish_browser.wallapop_offer import WallapopOfferFlow from salvager.adapters.tinyfish_browser.wallapop_pay import WallapopPayFlow -__all__ = ["EbayCheckoutFlow", "WallapopPayFlow"] +__all__ = ["EbayCheckoutFlow", "WallapopOfferFlow", "WallapopPayFlow"] diff --git a/src/salvager/adapters/tinyfish_browser/wallapop_offer.py b/src/salvager/adapters/tinyfish_browser/wallapop_offer.py new file mode 100644 index 0000000..7c53153 --- /dev/null +++ b/src/salvager/adapters/tinyfish_browser/wallapop_offer.py @@ -0,0 +1,392 @@ +"""Wallapop "hacer oferta" flow (wallapop-offer-flow, FR58-FR65). + +Drives Wallapop's native offer form through TinyFish. The operator's +authenticated session opens the listing page, taps the "Hacer oferta" +button (present next to the buy button on offer-eligible listings β€” +operator-captured 2026-07-22, `openspec/changes/wallapop-make-offer/ +captures/`), enters EXACTLY the bounded amount, submits, verifies the +sent state, and captures a screenshot. + +Platform rules the goal encodes (from Wallapop's help pages + the +operator's captures): + + - offers are unavailable on PRO-seller / refurbished / excluded- + category listings β€” the button simply isn't there (`offer_unavailable`); + - the form rejects amounts below 70 % of asking ("Tu oferta debe ser + de al menos X €") β€” `amount_rejected`; the domain pre-validates this + floor so it should never fire on a computed amount; + - the form shows "N ofertas restantes para hoy" (10/day account cap); + an exhausted counter is `daily_limit_reached`, and the value is + captured into the audit row when visible. + +No money moves in this flow β€” an offer is a negotiation message, and +the purchase (if the seller accepts) stays behind the Comprar path. +The sent state, not the screenshot, is the success criterion: a +verified send with a failed capture returns success with +``screenshot_url = None``; an unverified submit returns the ambiguous +``screenshot_missing`` failure so the operator checks the chat. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from decimal import Decimal +from typing import Any, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError +from tinyfish import ( + AgentRunResponse, + AsyncTinyFish, + AuthenticationError, + PermissionDeniedError, + RateLimitError, + RunStatus, + SDKError, +) + +from salvager.adapters.tinyfish_browser._runtime import ( + DEFAULT_MAX_DURATION_S, + build_client, +) +from salvager.domain.errors import OfferFailureReason +from salvager.domain.listing import Listing +from salvager.interfaces.offer_session import ( + OfferResult, + OfferSendFailure, + OfferSession, + OfferSuccess, +) +from salvager.observability.logging import get_logger + +#: The structured-output contract the offer agent MUST honour. +OFFER_OUTPUT_CONTRACT: Final[str] = ( + "Return STRICTLY this JSON shape and nothing else (no prose, no markdown):\n" + "{\n" + ' "outcome": "success" | "offer_unavailable" | "amount_rejected" ' + '| "daily_limit_reached" | "missing_element" | "screenshot_missing" ' + '| "marketplace_error" | "timeout" | "ui_check_failed",\n' + ' "offered_eur": "",\n' + ' "screenshot_url": "",\n' + ' "platform_remaining": ,\n" + ' "missing": ["", ...] // required when outcome=missing_element ' + "or outcome=ui_check_failed\n" + ' "detail": "" // optional, copied into ctx\n' + "}\n" +) + +#: The offer-flow agent goal. ``render_offer_goal`` injects the exact +#: amount as a hard constraint before this contract. +_WALLAPOP_OFFER_GOAL: Final[str] = ( + "Open the Wallapop listing page and send a price offer via the native " + '"Hacer oferta" flow. This sends a negotiation message to the seller β€” ' + "do NOT buy, do NOT initiate any checkout, do NOT touch any other button.\n" + "\n" + "Step-by-step:\n" + "1. Navigate to the listing URL (the operator's existing Wallapop session " + " is loaded).\n" + '2. Look for the "Hacer oferta" button on the listing page (next to the ' + " buy button). If it is absent (PRO seller, refurbished product, or an " + " excluded category), return " + '{"outcome": "offer_unavailable", "detail": "no offer button"}.\n' + '3. Click "Hacer oferta". The offer form shows the listing, an amount ' + ' field ("Tu oferta"), and a counter like "N ofertas restantes para ' + ' hoy". Record the counter value as `platform_remaining` when visible.\n' + "4. If the counter shows 0 offers remaining, or the form says the daily " + " offer limit is reached, ABORT without submitting β€” return " + '{"outcome": "daily_limit_reached", "platform_remaining": 0}.\n' + "5. Enter the offer amount in the amount field. The field uses a COMMA " + ' as the decimal separator (e.g. "20,22"); our amounts are whole ' + ' euros, so enter just the integer (e.g. "61"). Verify the field ' + " shows EXACTLY that amount before continuing.\n" + '6. If the form rejects the amount (e.g. "Tu oferta debe ser de al menos ' + ' X €"), ABORT without submitting β€” return ' + '{"outcome": "amount_rejected", "detail": ""}.\n' + '7. Tap "Enviar" and await the sent state: the form closes and the offer ' + " appears as sent (in the form's confirmation or the listing's chat). " + " Budget: 30 s.\n" + "8. If you submitted but CANNOT verify the sent state, return " + '{"outcome": "screenshot_missing", "detail": "submitted, sent state ' + 'unverified"}.\n' + "9. Capture a screenshot of the sent confirmation and record its URL in " + " `screenshot_url` (when the capture fails but the sent state IS " + " verified, leave `screenshot_url` null). Return " + '{"outcome": "success", "offered_eur": "", ' + '"screenshot_url": , "platform_remaining": }.\n' + "\n" + "If the marketplace shows an error page, returns a 4xx/5xx, or the " + "listing no longer exists, return " + '{"outcome": "marketplace_error", "detail": ""}.\n' + "If any expected element is missing mid-flow, return " + '{"outcome": "missing_element", "missing": [list of the missing names]}.\n' + "\n" + "Recovery hint: the offer form is also directly addressable at " + "https://es.wallapop.com/app/chat/offer?itemId= β€” if the listing page's button is present but unresponsive, you " + "may navigate there instead. Never use that URL to bypass step 2's " + "missing-button check.\n" +) + +_AGENT_OUTCOME_TO_REASON: Final[dict[str, OfferFailureReason]] = { + "offer_unavailable": OfferFailureReason.offer_unavailable, + "amount_rejected": OfferFailureReason.amount_rejected, + "daily_limit_reached": OfferFailureReason.daily_limit_reached, + "missing_element": OfferFailureReason.missing_element, + "screenshot_missing": OfferFailureReason.screenshot_missing, + "marketplace_error": OfferFailureReason.marketplace_error, + "timeout": OfferFailureReason.timeout, + "ui_check_failed": OfferFailureReason.ui_check_failed, +} + + +class OfferAgentResponse(BaseModel): + """The shape the offer agent must return β€” parsed at the adapter + boundary so the orchestrator never sees raw JSON. A payload that + fails validation surfaces as ``ui_check_failed``.""" + + model_config = ConfigDict(extra="forbid") + + outcome: Literal[ + "success", + "offer_unavailable", + "amount_rejected", + "daily_limit_reached", + "missing_element", + "screenshot_missing", + "marketplace_error", + "timeout", + "ui_check_failed", + ] + offered_eur: str | None = None + screenshot_url: str | None = None + platform_remaining: int | None = None + missing: list[str] = Field(default_factory=list) + detail: str | None = None + + +def render_offer_goal(amount_eur: Decimal) -> str: + """Compose the offer goal with the exact-amount hard constraint and + the JSON contract. Every send passes through here so the amount + guard is uniform.""" + amount_clause = ( + f"Hard constraint: the offer amount is EXACTLY {amount_eur} EUR. Never " + "enter any other value; if the form will not accept exactly this " + 'amount, ABORT without submitting and return {"outcome": ' + '"amount_rejected"}.\n\n' + ) + return f"{_WALLAPOP_OFFER_GOAL.rstrip()}\n\n{amount_clause}{OFFER_OUTPUT_CONTRACT}" + + +class WallapopOfferFlow(OfferSession): + """Concrete :class:`OfferSession` for Wallapop's native offer form.""" + + def __init__( + self, + api_key: SecretStr, + *, + client: AsyncTinyFish | None = None, + max_duration_s: int = DEFAULT_MAX_DURATION_S, + ) -> None: + self._owned_client = client is None + if client is None: + client = build_client(api_key) + self._client = client + self._max_duration_s = max_duration_s + self._log = get_logger("adapter.tinyfish_browser.wallapop_offer") + + async def close(self) -> None: + """Close the underlying TinyFish client. Idempotent.""" + if self._owned_client: + await self._client.close() + + async def execute_offer(self, listing: Listing, amount_eur: Decimal) -> OfferResult: + if listing.marketplace != "wallapop": + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={ + "detail": ( + f"WallapopOfferFlow refuses {listing.marketplace} listings β€” " + "wrong marketplace" + ), + "marketplace": listing.marketplace, + }, + ) + goal = render_offer_goal(amount_eur) + return await _execute_offer_via_tinyfish( + self._client, + goal=goal, + url=str(listing.url), + amount_eur=amount_eur, + max_duration_s=self._max_duration_s, + log=self._log, + ) + + +async def _execute_offer_via_tinyfish( + client: AsyncTinyFish, + *, + goal: str, + url: str, + amount_eur: Decimal, + max_duration_s: int, + log: logging.Logger, +) -> OfferResult: + """Drive one offer through the TinyFish agent. Never raises β€” every + failure path produces an :class:`OfferSendFailure`.""" + started = time.perf_counter() + + try: + response = await asyncio.wait_for( + client.agent.run(goal=goal, url=url), + timeout=max_duration_s, + ) + except TimeoutError: + log.warning("tinyfish_offer_timeout", extra={"url": url, "budget_s": max_duration_s}) + return OfferSendFailure( + reason=OfferFailureReason.timeout, + ctx={"budget_s": max_duration_s, "url": url}, + ) + except (AuthenticationError, PermissionDeniedError) as exc: + log.exception("tinyfish_offer_auth_failed", extra={"error_class": exc.__class__.__name__}) + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={"error_class": exc.__class__.__name__, "detail": "tinyfish_auth_failed"}, + ) + except RateLimitError as exc: + log.warning("tinyfish_offer_rate_limited", extra={"error_class": exc.__class__.__name__}) + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={"error_class": exc.__class__.__name__, "detail": "tinyfish_rate_limited"}, + ) + except SDKError as exc: + log.exception("tinyfish_offer_sdk_error", extra={"error_class": exc.__class__.__name__}) + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={"error_class": exc.__class__.__name__, "detail": str(exc)}, + ) + + elapsed = int(time.perf_counter() - started) + + if response.status != RunStatus.COMPLETED: + log.error( + "tinyfish_offer_run_not_completed", + extra={"run_id": response.run_id, "status": str(response.status)}, + ) + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={ + "run_id": response.run_id, + "status": str(response.status), + "detail": "tinyfish run did not complete", + }, + ) + + parsed = _parse_offer_response(response, log=log) + if isinstance(parsed, OfferSendFailure): + return parsed + + return _payload_to_offer_result(parsed, amount_eur=amount_eur, total_seconds=elapsed, log=log) + + +def _parse_offer_response( + response: AgentRunResponse, + *, + log: logging.Logger, +) -> OfferAgentResponse | OfferSendFailure: + """Walk the TinyFish ``response.result`` into :class:`OfferAgentResponse`.""" + if response.result is None: + log.error("tinyfish_offer_empty_result", extra={"run_id": response.run_id}) + return OfferSendFailure( + reason=OfferFailureReason.ui_check_failed, + ctx={"run_id": response.run_id, "detail": "agent returned no result"}, + ) + try: + return OfferAgentResponse.model_validate(response.result) + except ValidationError as exc: + log.exception( + "tinyfish_offer_response_schema_drift", + extra={"run_id": response.run_id, "errors": str(exc)}, + ) + return OfferSendFailure( + reason=OfferFailureReason.marketplace_error, + ctx={ + "run_id": response.run_id, + "detail": "agent response failed schema validation", + }, + ) + + +def _payload_to_offer_result( + payload: OfferAgentResponse, + *, + amount_eur: Decimal, + total_seconds: int, + log: logging.Logger, +) -> OfferResult: + """Translate the parsed agent payload into an :class:`OfferResult`. + + The exact-amount invariant is re-checked at the boundary: a success + whose echoed amount differs from the bounded amount is a UI failure, + never a success β€” the agent sent (or claims to have sent) a number + we did not authorise. + """ + if payload.outcome != "success": + reason = _AGENT_OUTCOME_TO_REASON[payload.outcome] + ctx: dict[str, Any] = {} + if payload.detail: + ctx["detail"] = payload.detail + if payload.missing: + ctx["missing"] = list(payload.missing) + if payload.platform_remaining is not None: + ctx["platform_remaining"] = payload.platform_remaining + return OfferSendFailure(reason=reason, ctx=ctx) + + echoed = _coerce_amount(payload.offered_eur) + if echoed is None or echoed != amount_eur: + log.error( + "tinyfish_offer_amount_echo_mismatch", + extra={"expected": str(amount_eur), "echoed": payload.offered_eur}, + ) + return OfferSendFailure( + reason=OfferFailureReason.ui_check_failed, + ctx={ + "detail": "agent claimed success with a different amount than authorised", + "expected": str(amount_eur), + "echoed": payload.offered_eur, + }, + ) + + log.info( + "tinyfish_offer_sent", + extra={ + "offered_eur": str(amount_eur), + "platform_remaining": payload.platform_remaining, + "total_seconds": total_seconds, + }, + ) + return OfferSuccess( + offered_eur=amount_eur, + screenshot_url=payload.screenshot_url, + platform_remaining=payload.platform_remaining, + total_seconds=total_seconds, + ) + + +def _coerce_amount(raw: str | None) -> Decimal | None: + """Parse the echoed amount; None on garbage.""" + if not raw: + return None + cleaned = raw.strip().replace(",", ".").removesuffix(".EUR").removesuffix("€").strip() + try: + value = Decimal(cleaned) + except (ArithmeticError, ValueError): + return None + if value <= 0: + return None + return value + + +__all__ = ["OFFER_OUTPUT_CONTRACT", "OfferAgentResponse", "WallapopOfferFlow", "render_offer_goal"] diff --git a/src/salvager/adapters/wallapop_api/fetcher.py b/src/salvager/adapters/wallapop_api/fetcher.py index 4e262c4..beaceb6 100644 --- a/src/salvager/adapters/wallapop_api/fetcher.py +++ b/src/salvager/adapters/wallapop_api/fetcher.py @@ -302,6 +302,12 @@ async def fetch_listing(self, listing: Listing) -> Listing: description=detail.description.original, price_eur=Decimal(str(detail.price.cash.amount)), fetched_at=datetime.now(UTC), + # The detail payload does NOT expose the refurbished flag + # (live-probed 2026-07-22) β€” carry the search-derived value + # forward instead of silently resetting it to False, so the + # offer path's eligibility never widens on a re-fetch. The + # agent goal's missing-button check is the runtime backstop. + is_refurbished=listing.is_refurbished, ) # ───────────────────────────────────────────────────────────────── @@ -357,6 +363,7 @@ def _item_to_listing(item: WallapopApiItem) -> Listing: published_at=_unix_millis_to_dt(item.created_at), fetched_at=datetime.now(UTC), is_reserved=bool(item.reserved and item.reserved.flag), + is_refurbished=bool(item.is_refurbished and item.is_refurbished.flag), ) diff --git a/src/salvager/adapters/wallapop_api/schema.py b/src/salvager/adapters/wallapop_api/schema.py index c21afe2..d9aee69 100644 --- a/src/salvager/adapters/wallapop_api/schema.py +++ b/src/salvager/adapters/wallapop_api/schema.py @@ -103,6 +103,10 @@ class WallapopApiItem(BaseModel): location: WallapopApiLocation | None = None images: list[WallapopApiImage] = Field(default_factory=list) reserved: WallapopApiReserved | None = None + #: Same ``{"flag": bool}`` wrapper as ``reserved`` (live-probed + #: 2026-07-22). Refurbished listings don't accept offers, so the + #: offer surface pre-filters on it; absent = not refurbished. + is_refurbished: WallapopApiReserved | None = None web_slug: str | None = None #: Unix milliseconds. ``None`` is tolerated for forward-compat, #: but the live API always emits it. diff --git a/src/salvager/cli/app.py b/src/salvager/cli/app.py index ec24f55..4f9f4e5 100644 --- a/src/salvager/cli/app.py +++ b/src/salvager/cli/app.py @@ -53,11 +53,13 @@ help="Authenticate with a marketplace (Wallapop cookie capture, eBay OAuth).", ) phase2_app = typer.Typer(help="Control Phase 2 autonomous-purchase enablement per entry.") +offer_app = typer.Typer(help="Control Wallapop offer ('hacer oferta') enablement per entry.") audit_app = typer.Typer(help="Inspect the local append-only audit log.") wishlist_app = typer.Typer(help="Read-only inspection of the loaded wishlist.") app.add_typer(login_app, name="login") app.add_typer(phase2_app, name="phase2") +app.add_typer(offer_app, name="offer") app.add_typer(audit_app, name="audit") app.add_typer(wishlist_app, name="wishlist") @@ -860,6 +862,105 @@ def cmd_phase2_status( raise typer.Exit(code=exit_code) +@offer_app.command("enable") +def cmd_offer_enable( + entry: Annotated[str, typer.Argument(help="Entry key from wishlist.")], + target: Annotated[ + str | None, + typer.Option( + "--target", + "-t", + help="Aim offers at this delivered total (EUR); omit to target the entry ceiling.", + ), + ] = None, + wishlist_path: Annotated[ + Path, + typer.Option("--wishlist-path", "-w", help="Path to wishlist.yaml."), + ] = _DEFAULT_WISHLIST_PATH, + data_dir: Annotated[ + Path, + typer.Option("--data-dir", "-d", help="Daemon state dir (default: /app/data)."), + ] = _DEFAULT_DATA_DIR, +) -> None: + """Enable Wallapop offers for an entry (wallapop-offer-flow, FR65).""" + from salvager.cli.commands import offer_cmd + + exit_code = offer_cmd.run_enable( + query=entry, + wishlist_path=wishlist_path, + data_dir=data_dir, + target_total_eur=target, + ) + if exit_code != 0: + raise typer.Exit(code=exit_code) + + +@offer_app.command("disable") +def cmd_offer_disable( + entry: Annotated[str | None, typer.Argument(help="Entry key, or omit with --all.")] = None, + all_entries: Annotated[bool, typer.Option("--all", help="Disable offers globally.")] = False, + wishlist_path: Annotated[ + Path, + typer.Option("--wishlist-path", "-w", help="Path to wishlist.yaml."), + ] = _DEFAULT_WISHLIST_PATH, + data_dir: Annotated[ + Path, + typer.Option("--data-dir", "-d", help="Daemon state dir (default: /app/data)."), + ] = _DEFAULT_DATA_DIR, +) -> None: + """Disable Wallapop offers β€” per entry or globally (UX-DR23).""" + from salvager.cli.commands import offer_cmd + + exit_code = offer_cmd.run_disable( + query=entry, + all_entries=all_entries, + wishlist_path=wishlist_path, + data_dir=data_dir, + ) + if exit_code != 0: + raise typer.Exit(code=exit_code) + + +@offer_app.command("status") +def cmd_offer_status( + wishlist_path: Annotated[ + Path, + typer.Option("--wishlist-path", "-w", help="Path to wishlist.yaml."), + ] = _DEFAULT_WISHLIST_PATH, + data_dir: Annotated[ + Path, + typer.Option("--data-dir", "-d", help="Daemon state dir (default: /app/data)."), + ] = _DEFAULT_DATA_DIR, + config_path: Annotated[ + Path, + typer.Option("--config-path", "-c", help="Path to config.yaml (for offer.daily_limit)."), + ] = _DEFAULT_CONFIG_PATH, + output_format: Annotated[ + str, + typer.Option("--format", "-f", help="Output format: human | json."), + ] = "human", +) -> None: + """Print offer enablement table + lockout/budget state.""" + from salvager.cli.commands import offer_cmd + from salvager.config.config_yaml import load_config + + # Best-effort: the budget denominator should reflect the operator's + # configured offer.daily_limit, but a missing/broken config.yaml must + # not take a read-only status command down with it. + try: + daily_limit = load_config(config_path).offer.daily_limit + except Exception: + daily_limit = 5 + exit_code = offer_cmd.run_status( + wishlist_path=wishlist_path, + data_dir=data_dir, + output_format=output_format, + daily_limit=daily_limit, + ) + if exit_code != 0: + raise typer.Exit(code=exit_code) + + @audit_app.command("show") def cmd_audit_show( last: Annotated[ diff --git a/src/salvager/cli/commands/dev_cmd.py b/src/salvager/cli/commands/dev_cmd.py index 29256d8..e6a977b 100644 --- a/src/salvager/cli/commands/dev_cmd.py +++ b/src/salvager/cli/commands/dev_cmd.py @@ -124,10 +124,16 @@ def _group_of(variant_name: str) -> str: return "phase1 listing" if variant_name.startswith("phase2_listing"): return "phase2 listing" + if variant_name.startswith("negotiable_listing"): + return "negotiable listing" if variant_name.startswith("buy_success"): return "phase2 buy success" if variant_name.startswith("buy_failure_"): return "phase2 buy failure" + if variant_name == "offer_sent": + return "offer sent" + if variant_name.startswith("offer_failure_"): + return "offer failure" return "operational" diff --git a/src/salvager/cli/commands/offer_cmd.py b/src/salvager/cli/commands/offer_cmd.py new file mode 100644 index 0000000..9ec5259 --- /dev/null +++ b/src/salvager/cli/commands/offer_cmd.py @@ -0,0 +1,334 @@ +"""``salvager offer enable/disable/status`` (wallapop-offer-flow, FR65). + +The offer sibling of ``phase2_cmd``: per-entry opt-in lives in +``wishlist.yaml`` (AR12 β€” rewritten through the ruamel round-trip +loader so comments survive), the global offer lockout lives in SQLite +(``offer_state``), and ``enable`` is the only path that lifts it. + +``disable`` (per-entry) never touches the lockout; ``disable --all`` +is the kill-everything path with the UX-DR23 typed-number confirmation +and engages the global lockout with ``operator_disable_all``. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from collections.abc import Callable +from decimal import Decimal, InvalidOperation +from pathlib import Path + +from salvager.adapters.sqlite_store.migrations import db_path_under +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.config.wishlist_yaml import load_wishlist, save_wishlist +from salvager.domain.offer_audit import OfferStateSnapshot +from salvager.domain.wishlist import OfferSettings, Wishlist, WishlistEntry +from salvager.observability.logging import get_logger +from salvager.observability.styling import ( + ColumnSpec, + print_table, + render_prose, + render_table, +) + +_USAGE_EXIT = 2 +_USER_CANCELLED_EXIT = 1 + + +def _resolve_entry(wishlist: Wishlist, query: str) -> WishlistEntry | None: + """Same match rules as ``phase2_cmd``: exact ref wins, else a unique + case-insensitive substring match on ref / model / display name.""" + needle = query.casefold() + exact = [e for e in wishlist.entries if e.ref.casefold() == needle] + if len(exact) == 1: + return exact[0] + if len(exact) > 1: + return None + candidates = [ + e + for e in wishlist.entries + if needle in e.ref.casefold() + or needle in e.model.casefold() + or needle in e.display_name.casefold() + ] + if len(candidates) == 1: + return candidates[0] + return None + + +def _entry_not_found(query: str) -> int: + render_prose( + f"entry {query!r} not found in wishlist.yaml", + style="error", + hint="salvager offer status to see valid entry IDs", + ) + return _USAGE_EXIT + + +def _save(wishlist: Wishlist, new_wishlist: Wishlist, wishlist_path: Path) -> None: + yaml_doc = getattr(wishlist, "__yaml_doc__", None) + if yaml_doc is not None: + object.__setattr__(new_wishlist, "__yaml_doc__", yaml_doc) + save_wishlist(wishlist_path, new_wishlist) + + +# ───────────────────────────────────────────────────────────────────────── +# `offer enable` +# ───────────────────────────────────────────────────────────────────────── + + +def run_enable( + *, + query: str, + wishlist_path: Path, + data_dir: Path, + target_total_eur: str | None = None, +) -> int: + """Flip an entry's ``offer.enabled`` and lift the global offer lockout.""" + wishlist = load_wishlist(wishlist_path) + entry = _resolve_entry(wishlist, query) + if entry is None: + return _entry_not_found(query) + + target: Decimal | None = entry.offer.target_total_eur + if target_total_eur is not None: + try: + target = Decimal(target_total_eur.strip().replace(",", ".")) + except InvalidOperation: + render_prose(f"not a number: {target_total_eur!r}", style="error") + return _USAGE_EXIT + if target <= Decimal("0"): + render_prose("target must be > 0", style="error") + return _USAGE_EXIT + + new_offer = OfferSettings(enabled=True, target_total_eur=target) + updated_entry = entry.model_copy(update={"offer": new_offer}) + new_entries = [updated_entry if e is entry else e for e in wishlist.entries] + _save(wishlist, wishlist.model_copy(update={"entries": new_entries}), wishlist_path) + + asyncio.run(_clear_lockout(data_dir, entry)) + target_label = ( + f"target: {target} €" if target is not None else "target: entry ceiling (max_price_solo)" + ) + render_prose( + f"Offers enabled for {entry.display_name} ({target_label}; lockout cleared)", + style="success", + ) + return 0 + + +async def _clear_lockout(data_dir: Path, entry: WishlistEntry) -> None: + writer = OfferAuditWriter(db_path_under(data_dir)) + try: + await writer.clear_global_disable(entry.entry_key) + finally: + await writer.close() + + +# ───────────────────────────────────────────────────────────────────────── +# `offer disable` and `offer disable --all` +# ───────────────────────────────────────────────────────────────────────── + + +def run_disable( + *, + query: str | None, + all_entries: bool, + wishlist_path: Path, + data_dir: Path, + is_tty: Callable[[], bool] = sys.stdin.isatty, + input_fn: Callable[[str], str] = input, +) -> int: + if all_entries: + return _disable_all( + wishlist_path=wishlist_path, data_dir=data_dir, is_tty=is_tty, input_fn=input_fn + ) + if query is None: + render_prose( + "offer disable requires or --all", + style="error", + hint="salvager offer disable ", + ) + return _USAGE_EXIT + + wishlist = load_wishlist(wishlist_path) + entry = _resolve_entry(wishlist, query) + if entry is None: + return _entry_not_found(query) + + new_offer = OfferSettings(enabled=False, target_total_eur=entry.offer.target_total_eur) + updated_entry = entry.model_copy(update={"offer": new_offer}) + new_entries = [updated_entry if e is entry else e for e in wishlist.entries] + _save(wishlist, wishlist.model_copy(update={"entries": new_entries}), wishlist_path) + render_prose(f"Offers disabled for {entry.display_name}", style="success") + return 0 + + +def _disable_all( + *, + wishlist_path: Path, + data_dir: Path, + is_tty: Callable[[], bool], + input_fn: Callable[[str], str], +) -> int: + if not is_tty(): + render_prose( + "--all requires an interactive terminal", + style="error", + hint="re-run in a TTY, or disable entries individually", + ) + return _USER_CANCELLED_EXIT + + wishlist = load_wishlist(wishlist_path) + enabled_entries = [e for e in wishlist.entries if e.offer.enabled] + count = len(enabled_entries) + if count == 0: + render_prose("no entries currently have offers enabled", style="info") + return 0 + + try: + confirmation = input_fn(f"Type the number {count} to confirm: ") + except (EOFError, KeyboardInterrupt): + confirmation = "" + if confirmation.strip() != str(count): + render_prose("aborted β€” no changes made", style="info") + return _USER_CANCELLED_EXIT + + new_entries = [ + e.model_copy( + update={ + "offer": OfferSettings(enabled=False, target_total_eur=e.offer.target_total_eur) + } + ) + if e.offer.enabled + else e + for e in wishlist.entries + ] + _save(wishlist, wishlist.model_copy(update={"entries": new_entries}), wishlist_path) + + asyncio.run(_set_lockout(data_dir, reason="operator_disable_all")) + log = get_logger("cli.offer") + log.warning( + "offer_disabled", + extra={ + "reason": "operator_disable_all", + "entries_disabled": count, + "last_affected_entry": enabled_entries[-1].display_name, + }, + ) + render_prose( + f"Offers disabled for {count} entries Β· global lockout activated " + "(reason: operator_disable_all)", + style="success", + ) + return 0 + + +async def _set_lockout(data_dir: Path, *, reason: str) -> None: + writer = OfferAuditWriter(db_path_under(data_dir)) + try: + await writer.set_global_disable(reason) + finally: + await writer.close() + + +# ───────────────────────────────────────────────────────────────────────── +# `offer status` +# ───────────────────────────────────────────────────────────────────────── + + +def run_status( + *, + wishlist_path: Path, + data_dir: Path, + output_format: str = "human", + daily_limit: int = 5, + width: int = 80, +) -> int: + if output_format not in ("human", "json"): + render_prose( + f"unknown --format value: {output_format!r}", + style="error", + hint="use --format human or --format json", + ) + return _USAGE_EXIT + + if not wishlist_path.exists(): + render_prose( + f"wishlist not found at {wishlist_path}", + style="error", + hint="run `salvager init` to scaffold one", + ) + return _USER_CANCELLED_EXIT + wishlist = load_wishlist(wishlist_path) + state, sent_24h = asyncio.run(_read_state_and_budget(data_dir)) + + rows: list[dict[str, object]] = [] + json_entries: list[dict[str, object]] = [] + for entry in wishlist.entries: + target = ( + f"{entry.offer.target_total_eur} €" + if entry.offer.target_total_eur is not None + else "ceiling" + ) + rows.append( + { + "Entry": entry.display_name, + "Offers Enabled?": "yes" if entry.offer.enabled else "no", + "Target": target if entry.offer.enabled else None, + } + ) + json_entries.append( + { + "entry_key": list(entry.entry_key), + "display_name": entry.display_name, + "offer_enabled": entry.offer.enabled, + "target_total_eur": ( + str(entry.offer.target_total_eur) + if entry.offer.target_total_eur is not None + else None + ), + } + ) + + footer = ( + f"Globally disabled: {'yes' if state.globally_disabled else 'no'} Β· " + f"Failures: {state.consecutive_failures} Β· " + f"Sent last 24h: {sent_24h}/{daily_limit}" + ) + + if output_format == "json": + payload = { + "entries": json_entries, + "globally_disabled": state.globally_disabled, + "disabled_reason": state.disabled_reason, + "consecutive_failures": state.consecutive_failures, + "sent_last_24h": sent_24h, + "daily_limit": daily_limit, + } + sys.stdout.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n") + return 0 + + columns: list[ColumnSpec] = [ + {"key": "Entry"}, + {"key": "Offers Enabled?"}, + {"key": "Target", "align": "right"}, + ] + print_table(render_table(rows, columns, width=width), width=width) + render_prose(footer, style="info") + return 0 + + +async def _read_state_and_budget(data_dir: Path) -> tuple[OfferStateSnapshot, int]: + db_path = db_path_under(data_dir) + if not db_path.exists(): + return OfferStateSnapshot(globally_disabled=False, consecutive_failures=0), 0 + writer = OfferAuditWriter(db_path) + try: + return await writer.read_state(), await writer.count_recent_successes() + finally: + await writer.close() + + +__all__ = ["run_disable", "run_enable", "run_status"] diff --git a/src/salvager/cli/dev_alert_fixtures.py b/src/salvager/cli/dev_alert_fixtures.py index 7610062..1aca80b 100644 --- a/src/salvager/cli/dev_alert_fixtures.py +++ b/src/salvager/cli/dev_alert_fixtures.py @@ -1,9 +1,11 @@ """Variant β†’ :class:`RenderedAlert` registry β€” Story 5.17 release-audit. -One closed map: every release-gate variant name (45 entries at v0.4.x: -37 at the original v1.0 audit + ``listing_gone`` at v0.4.1 + the πŸ’Ά -buyer-total and edit-surface variants added by the v0.4.3 re-audit) -to a zero-arg builder that returns a :class:`RenderedAlert`. The +One closed map: every release-gate variant name (66 entries: 45 at +v0.4.4 β€” 37 at the original v1.0 audit + ``listing_gone`` at v0.4.1 + +the πŸ’Ά buyer-total and edit-surface variants of the v0.4.3 re-audit β€” +plus the 21 wallapop-offer-flow surfaces: 4 offer-eligible listing +shapes, ``offer_sent``, 12 offer failures, 4 operational events) to a +zero-arg builder that returns a :class:`RenderedAlert`. The fixture data mirrors what the snapshot tests use, so the dispatched Telegram message and the file under ``docs/release-audits/v1.0/reference-text/.txt`` are @@ -33,6 +35,9 @@ Severity, apply_update_banner, phase2_dead_reserved_row, + render_negotiable_listing_alert, + render_offer_failure, + render_offer_sent, render_operational_alert, render_phase1_listing_alert, render_phase2_buy_failure, @@ -41,7 +46,7 @@ render_price_drop_ping, update_banner_line, ) -from salvager.domain.errors import BuyFailureReason +from salvager.domain.errors import BuyFailureReason, OfferFailureReason from salvager.domain.evaluation import ListingEvaluation from salvager.domain.listing import Listing from salvager.domain.phase2_audit import TransactionRecord @@ -230,6 +235,94 @@ def _price_drop_ping() -> RenderedAlert: ) +# ───────────────────────────────────────────────────────────────────────── +# Offer-surface builders β€” wallapop-offer-flow +# ───────────────────────────────────────────────────────────────────────── + +#: Negotiable fixture: 70 € asking against a 60 € target β†’ 51 € fit +#: (largest whole euro with item + 3,50 shipping buffer + ProtecciΓ³n ≀ 60, +#: and β‰₯ 70 % of asking). Values pinned as literals so a pricing change +#: breaks these fixtures loudly instead of silently re-deriving. +_NEGOTIABLE_ASKING = Decimal("70.00") +_OFFER_TARGET = Decimal("60.00") +_OFFER_FIT = Decimal("51") +#: Lower-target fixture on an under-ceiling listing: 55 € asking, 50 € +#: target β†’ 42 € fit. +_UNDER_TARGET = Decimal("50.00") +_UNDER_FIT = Decimal("42") + + +def _negotiable_direct() -> RenderedAlert: + overrides: dict[str, Any] = {"price_eur": _NEGOTIABLE_ASKING} + return render_negotiable_listing_alert( + _snapshot(phase="negotiable", listing_overrides=overrides), + offer_eur=_OFFER_FIT, + offer_target_total_eur=_OFFER_TARGET, + buyer_cost=_cost(_listing(**overrides)), + ) + + +def _negotiable_missing_photo() -> RenderedAlert: + overrides: dict[str, Any] = {"price_eur": _NEGOTIABLE_ASKING, "photo_urls": []} + return render_negotiable_listing_alert( + _snapshot(phase="negotiable", listing_overrides=overrides), + offer_eur=_OFFER_FIT, + offer_target_total_eur=_OFFER_TARGET, + buyer_cost=_cost(_listing(**overrides)), + ) + + +def _phase1_with_offer() -> RenderedAlert: + return render_phase1_listing_alert( + _snapshot(), + buyer_cost=_cost(_listing()), + offer_eur=_UNDER_FIT, + offer_target_total_eur=_UNDER_TARGET, + ) + + +def _phase2_with_offer() -> RenderedAlert: + return render_phase2_listing_alert( + _snapshot(phase="phase2"), + _PHASE2_MAX, + buyer_cost=_cost(_listing()), + offer_eur=_UNDER_FIT, + offer_target_total_eur=_UNDER_TARGET, + ) + + +def _offer_sent() -> RenderedAlert: + return render_offer_sent( + entry_display_name=_ENTRY_DISPLAY, + offered_eur=_OFFER_FIT, + audit_id=7, + screenshot_path="https://placehold.co/600x400/png?text=Oferta+enviada", + platform_remaining=9, + ) + + +_GENERIC_OFFER_FAILURE_CTX: Final[dict[str, Any]] = { + "displayed_offer": _OFFER_FIT, + "recomputed_offer": Decimal("49"), + "offered": _OFFER_FIT, + "limit_source": "propio", + "consecutive_failures": 3, + "threshold": 3, + "missing": ["offer_button"], + "error_class": "TinyFishUnavailable", +} + + +def _make_offer_failure(reason: OfferFailureReason) -> Callable[[], RenderedAlert]: + def _build() -> RenderedAlert: + return render_offer_failure( + reason, entry_display_name=_ENTRY_DISPLAY, ctx=_GENERIC_OFFER_FAILURE_CTX + ) + + _build.__name__ = f"_offer_failure_{reason.value}" + return _build + + # ───────────────────────────────────────────────────────────────────────── # Phase 2 buy success + failure builders # ───────────────────────────────────────────────────────────────────────── @@ -343,6 +436,16 @@ def _build() -> RenderedAlert: "warn", {"error_class": "TinyFishSessionLost", "alert_id": str(_FIXED_ALERT_ID)}, ), + EventName.offer_lockout_engaged: ( + "warn", + {"consecutive_failures": 3, "threshold": 3, "last_affected_entry": _ENTRY_DISPLAY}, + ), + EventName.offer_disabled: ("warn", {"reason": "kill_switch_global"}), + EventName.offer_re_enabled: ("info", {"entry": _ENTRY_DISPLAY}), + EventName.offer_orchestrator_error: ( + "warn", + {"error_class": "TinyFishSessionLost", "alert_id": str(_FIXED_ALERT_ID)}, + ), } @@ -377,9 +480,16 @@ def _build_registry() -> dict[str, Callable[[], RenderedAlert]]: "phase2_listing_edited_reserved": _phase2_edited_reserved, "price_drop_ping": _price_drop_ping, "buy_success": _buy_success, + "negotiable_listing_direct": _negotiable_direct, + "negotiable_listing_missing_photo": _negotiable_missing_photo, + "phase1_listing_with_offer": _phase1_with_offer, + "phase2_listing_with_offer": _phase2_with_offer, + "offer_sent": _offer_sent, } for reason in BuyFailureReason: registry[f"buy_failure_{reason.value}"] = _make_buy_failure(reason) + for offer_reason in OfferFailureReason: + registry[f"offer_failure_{offer_reason.value}"] = _make_offer_failure(offer_reason) for event in _OPERATIONAL_FIXTURES: registry[event.value] = _make_operational(event) return registry diff --git a/src/salvager/config/config_yaml.py b/src/salvager/config/config_yaml.py index 9f70a5f..ad92711 100644 --- a/src/salvager/config/config_yaml.py +++ b/src/salvager/config/config_yaml.py @@ -145,6 +145,31 @@ class AlertsConfig(BaseModel): price_drop_ping_pct: Annotated[Decimal, Field(ge=0)] = Decimal("10") +class OfferConfig(BaseModel): + """Wallapop offer-flow knobs (wallapop-offer-flow). + + ``band_pct`` widens the alert gate for offer-enabled entries: Wallapop + listings with a buyer total over the ceiling but within ``ceiling x + (1 + band_pct)`` produce a negotiable alert instead of being filtered. + Wallapop's own -30 % offer floor makes bands much past ~0.40 useless + (such listings can never be offered into the ceiling anyway). + + ``daily_limit`` is the self-imposed budget of successful offer sends per + rolling 24 h β€” deliberately under Wallapop's cap of 10 offers per + calendar day per account, leaving headroom for the operator's own manual + offers. ``lockout_threshold`` consecutive execution failures disable the + offer path until ``salvager offer enable`` clears it (independent from + the Phase 2 circuit breaker). ``kill_switch_global`` disables offer + sending unconditionally.""" + + model_config = ConfigDict(extra="forbid") + + band_pct: Annotated[Decimal, Field(ge=0, le=1)] = Decimal("0.20") + daily_limit: Annotated[int, Field(ge=1, le=10)] = 5 + lockout_threshold: Annotated[int, Field(ge=1)] = 3 + kill_switch_global: bool = False + + class LoggingConfig(BaseModel): """Structured-log threshold + output format (NFR-O1, NFR-O4). @@ -202,6 +227,7 @@ class ConfigModel(BaseModel): wallapop: WallapopConfig = Field(default_factory=WallapopConfig) pricing: PricingConfig = Field(default_factory=PricingConfig) alerts: AlertsConfig = Field(default_factory=AlertsConfig) + offer: OfferConfig = Field(default_factory=OfferConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig) observability: ObservabilityConfig = Field(default_factory=ObservabilityConfig) paths: PathsConfig = Field(default_factory=PathsConfig) diff --git a/src/salvager/config/wishlist_yaml.py b/src/salvager/config/wishlist_yaml.py index ce0f6e8..d832715 100644 --- a/src/salvager/config/wishlist_yaml.py +++ b/src/salvager/config/wishlist_yaml.py @@ -298,9 +298,16 @@ def _sync_model_into_yaml(model: BaseModel, yaml_node: Any) -> None: """Walk a pydantic model and update the parallel ruamel node only for fields whose typed value diverges. Untouched cells keep their original quoting, comments, and ordering.""" - for field_name in model.__class__.model_fields: + for field_name, field_info in model.__class__.model_fields.items(): typed_value = getattr(model, field_name) if field_name not in yaml_node: + # An absent cell whose typed value still equals the model default + # stays absent β€” otherwise adding an optional field to the schema + # (e.g. `offer:`) would inject noise into every saved wishlist + # and break the byte-identical round-trip guarantee. + default = field_info.get_default(call_default_factory=True) + if _values_equal(typed_value, default): + continue yaml_node[field_name] = _to_yaml(typed_value) continue yaml_value = yaml_node[field_name] diff --git a/src/salvager/domain/alert.py b/src/salvager/domain/alert.py index 4ef0f82..330498f 100644 --- a/src/salvager/domain/alert.py +++ b/src/salvager/domain/alert.py @@ -27,13 +27,13 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from salvager.domain.comps import CompSummary -from salvager.domain.errors import BuyFailureReason +from salvager.domain.errors import BuyFailureReason, OfferFailureReason from salvager.domain.evaluation import ListingEvaluation from salvager.domain.listing import Listing from salvager.domain.phase2_audit import TransactionRecord from salvager.domain.pricing import BuyerCost -Phase = Literal["phase1", "phase2"] +Phase = Literal["phase1", "phase2", "negotiable"] ParseMode = Literal["MarkdownV2"] # Telegram caps inline-button callback_data at 64 bytes. The locked format @@ -61,6 +61,10 @@ "phase2_listing": "🟒", "phase2_buy_success": "βœ…", "phase2_buy_failure": "🚫", + # Wallapop offer surfaces (wallapop-offer-flow; PRD amendment FR58-FR65). + "negotiable_listing": "πŸ’°", + "offer_sent": "πŸ’°", + "offer_failure": "🚫", } #: Inline-keyboard button labels (Spanish per UX-DR27). PRD amendment to grow. @@ -70,6 +74,7 @@ "snooze": "😴 Posponer 24h", "buy": "βœ… Comprar", "skip_phase2": "❌ Saltar", + "offer": "πŸ’° Ofertar", } #: Locked callback_data format. Max 64 bytes per Telegram. @@ -126,7 +131,7 @@ class RenderedAlert(BaseModel): inline_keyboard: list[list[InlineButton]] | None = None -CallbackVerb = Literal["view", "skip", "snooze", "buy"] +CallbackVerb = Literal["view", "skip", "snooze", "buy", "offer"] class CallbackEvent(BaseModel): @@ -309,6 +314,8 @@ def render_phase1_listing_alert( *, comp_summary: CompSummary | None = None, buyer_cost: BuyerCost | None = None, + offer_eur: Decimal | None = None, + offer_target_total_eur: Decimal | None = None, ) -> RenderedAlert: """Render a Phase 1 listing alert (Direction A + Direction E hybrid). @@ -351,6 +358,8 @@ def render_phase1_listing_alert( ] if buyer_cost is not None: rows.append(_cost_line(buyer_cost)) + if offer_eur is not None and offer_target_total_eur is not None: + rows.append(_offer_line(offer_eur, offer_target_total_eur)) rows.append(_deeplink_row(listing)) if evaluation.is_container: @@ -366,11 +375,15 @@ def render_phase1_listing_alert( photo_url = listing.photo_urls[0] if listing.photo_urls else None + keyboard = [_phase1_button_row(str(snapshot.alert_id))] + if offer_eur is not None and offer_target_total_eur is not None: + keyboard.append(offer_button_row(str(snapshot.alert_id))) + return RenderedAlert( text="\n".join(rows), parse_mode="MarkdownV2", photo_url=photo_url, - inline_keyboard=[_phase1_button_row(str(snapshot.alert_id))], + inline_keyboard=keyboard, ) @@ -394,6 +407,8 @@ def render_phase2_listing_alert( *, comp_summary: CompSummary | None = None, buyer_cost: BuyerCost | None = None, + offer_eur: Decimal | None = None, + offer_target_total_eur: Decimal | None = None, ) -> RenderedAlert: """Render a Phase 2 listing alert (Story 5.2 / FR23 / FR24 / UX-DR7). @@ -433,6 +448,8 @@ def render_phase2_listing_alert( ] if buyer_cost is not None: rows.append(_cost_line(buyer_cost)) + if offer_eur is not None and offer_target_total_eur is not None: + rows.append(_offer_line(offer_eur, offer_target_total_eur)) rows.append(_deeplink_row(listing)) if evaluation.is_container: @@ -448,11 +465,125 @@ def render_phase2_listing_alert( photo_url = listing.photo_urls[0] if listing.photo_urls else None + keyboard = [_phase2_button_row(str(snapshot.alert_id))] + if offer_eur is not None and offer_target_total_eur is not None: + keyboard.append(offer_button_row(str(snapshot.alert_id))) + return RenderedAlert( text="\n".join(rows), parse_mode="MarkdownV2", photo_url=photo_url, - inline_keyboard=[_phase2_button_row(str(snapshot.alert_id))], + inline_keyboard=keyboard, + ) + + +# ───────────────────────────────────────────────────────────────────────── +# Wallapop offer surfaces β€” wallapop-offer-flow +# ───────────────────────────────────────────────────────────────────────── + + +def _offer_line(offer_eur: Decimal, target_total_eur: Decimal) -> str: + """Render the computed-offer row on offer-eligible alerts. + + ``πŸ’° Oferta: € (total ≀ €)`` β€” the exact amount the + Ofertar tap will send, shown BEFORE the operator taps (the orchestrator + recomputes it from the reconciled listing and aborts on drift). Plain + prose β†’ one escape pass. + """ + offer = _format_price_es(offer_eur) + target = _format_price_es(target_total_eur) + return escape_markdown_v2(f"πŸ’° Oferta: {offer} (total ≀ {target})") + + +def offer_button_row(alert_id: str) -> list[InlineButton]: + """The standalone ``πŸ’° Ofertar`` row appended to offer-eligible Phase 1/ + Phase 2 alerts β€” its own row so the locked Phase 1/Phase 2 button rows + stay byte-identical (FR22).""" + return [ + InlineButton(text=BUTTON_LABELS["offer"], callback_data=f"listing:offer:{alert_id}"), + ] + + +def negotiable_button_row(alert_id: str) -> list[InlineButton]: + """The negotiable-alert button row: Ofertar Β· Saltar Β· Ver. + + Mirrors the Phase 2 layout (affirmative action in the dominant left + slot) with Ofertar in place of Comprar β€” a negotiable listing is over + ceiling by definition, so it can never carry a buy button. + """ + return [ + InlineButton(text=BUTTON_LABELS["offer"], callback_data=f"listing:offer:{alert_id}"), + InlineButton(text=BUTTON_LABELS["skip_phase2"], callback_data=f"listing:skip:{alert_id}"), + InlineButton(text=BUTTON_LABELS["view"], callback_data=f"listing:view:{alert_id}"), + ] + + +def offer_sent_badge_row(alert_id: str) -> list[InlineButton]: + """Terminal non-tappable ``πŸ’° Oferta enviada`` badge after a successful + send β€” the Ofertar row never returns for that listing (per-listing + dedupe). ``noop`` pattern as the other badges.""" + return [ + InlineButton(text="πŸ’° Oferta enviada", callback_data=f"listing:noop:{alert_id}"), + InlineButton(text=BUTTON_LABELS["view"], callback_data=f"listing:view:{alert_id}"), + ] + + +def render_negotiable_listing_alert( + snapshot: AlertSnapshot, + *, + offer_eur: Decimal, + offer_target_total_eur: Decimal, + comp_summary: CompSummary | None = None, + buyer_cost: BuyerCost | None = None, +) -> RenderedAlert: + """Render a negotiable-band listing alert (wallapop-offer-flow). + + Same anatomy as :func:`render_phase1_listing_alert` with three locked + substitutions: + + - Severity prefix ``πŸ“¦`` β†’ ``πŸ’°`` (over ceiling, offerable into it). + - An offer row after the buyer-total breakdown carrying the computed + amount and the target it fits. + - Inline keyboard ``[Ofertar Β· Saltar Β· Ver]`` β€” never Comprar. + """ + listing = snapshot.listing + evaluation = snapshot.evaluation + + severity = SEVERITY_TOKENS["negotiable_listing"] + name = escape_markdown_v2(snapshot.entry_display_name) + price = escape_markdown_v2(_format_price_es(listing.price_eur)) + location = escape_markdown_v2(listing.location or "β€”") + marketplace = escape_markdown_v2(listing.marketplace.capitalize()) + take = escape_markdown_v2(evaluation.one_line_take) + confidence = escape_markdown_v2(evaluation.confidence) + + rows: list[str] = [ + f"{severity} *{name}* β€” *{price}*", + f"πŸ“ {location} Β· {marketplace}", + ] + if buyer_cost is not None: + rows.append(_cost_line(buyer_cost)) + rows.append(_offer_line(offer_eur, offer_target_total_eur)) + rows.append(_deeplink_row(listing)) + + if evaluation.is_container: + wrapper = escape_markdown_v2(evaluation.wrapper_text or "β€”") + extracted = escape_markdown_v2(evaluation.extracted_text or "β€”") + rows.append(f" β†ͺ︎ Wrapper: {wrapper}") + rows.append(f" β†ͺ︎ Extracted: {extracted}") + + rows.append(f"_{take}_") + rows.append(f"πŸ” Confidence: {confidence}") + if comp_summary is not None: + rows.append(_comp_line(comp_summary)) + + photo_url = listing.photo_urls[0] if listing.photo_urls else None + + return RenderedAlert( + text="\n".join(rows), + parse_mode="MarkdownV2", + photo_url=photo_url, + inline_keyboard=[negotiable_button_row(str(snapshot.alert_id))], ) @@ -753,6 +884,188 @@ def _price_or_dash(value: object) -> str: return escape_markdown_v2(str(value)) +# ───────────────────────────────────────────────────────────────────────── +# Offer outcome renderers β€” wallapop-offer-flow +# ───────────────────────────────────────────────────────────────────────── + +#: Mandatory reassurance line on every offer-failure variant: the operator +#: must answer "did an offer go out?" from the alert alone. +OFFER_REASSURANCE_LINE: Final[str] = "No se ha enviado ninguna oferta." + +#: Special-case reassurance for ``screenshot_missing`` β€” the send may have +#: actually happened, just without captured evidence. +OFFER_SCREENSHOT_MISSING_REASSURANCE: Final[str] = ( + "La oferta puede haberse enviado, pero no se capturΓ³ la confirmaciΓ³n." +) + +_CMD_OFFER_ENABLE: Final[str] = "salvager offer enable " + +#: Per-variant short cause label shown on row 2 of the offer-failure alert. +_OFFER_FAILURE_CAUSE_LABELS: Final[dict[str, str]] = { + "listing_gone": "El anuncio ya no estΓ‘ disponible (vendido o retirado)", + "reconciliation_tripped": "El anuncio cambiΓ³ desde la alerta (precio/estado)", + "offer_unavailable": "El anuncio no admite ofertas", + "amount_rejected": "Wallapop rechazΓ³ el importe ofertado", + "daily_limit_reached": "LΓ­mite diario de ofertas alcanzado", + "duplicate_offer": "Ya existe una oferta enviada para este anuncio", + "lockout_engaged": "EnvΓ­o de ofertas bloqueado por fallos consecutivos", + "missing_element": "Elemento esperado no encontrado", + "marketplace_error": "Error en el marketplace", + "timeout": "Timeout durante el envΓ­o de la oferta", + "screenshot_missing": "Captura de confirmaciΓ³n no disponible", + "ui_check_failed": "VerificaciΓ³n de UI fallΓ³", +} + + +def render_offer_sent( + *, + entry_display_name: str, + offered_eur: Decimal, + audit_id: int, + screenshot_path: str | None = None, + platform_remaining: int | None = None, +) -> RenderedAlert: + """Render the offer-sent confirmation (wallapop-offer-flow). + + v1 ends here: the negotiation continues in the Wallapop app. The body + states exactly what an acceptance means β€” a 24 h window to buy at the + accepted price, with the item NOT reserved meanwhile β€” so the operator + knows to watch the chat. ``platform_remaining`` is Wallapop's "ofertas + restantes" counter when the agent captured it. + """ + severity = SEVERITY_TOKENS["offer_sent"] + price = escape_markdown_v2(_format_price_es(offered_eur)) + entry = escape_markdown_v2(entry_display_name) + + rows = [ + f"{severity} *Oferta enviada* Β· {price}", + f"Listing: {entry}", + _prose("El vendedor puede aceptar, rechazar o contraofertar β€” vigila el chat de Wallapop."), + _prose( + "Si acepta: tienes 24 h para comprar al precio aceptado en la app " + "(el artΓ­culo NO queda reservado)." + ), + ] + if platform_remaining is not None: + rows.append(_prose(f"Ofertas restantes hoy: {platform_remaining}")) + rows.append( + _cmd(f"salvager audit show --id {audit_id}") + + _prose(" para el registro completo de eventos.") + ) + + return RenderedAlert( + text="\n".join(rows), + parse_mode="MarkdownV2", + photo_url=screenshot_path, + inline_keyboard=None, + ) + + +def render_offer_failure( + reason: OfferFailureReason, + *, + entry_display_name: str, + ctx: Mapping[str, Any] | None = None, +) -> RenderedAlert: + """Render an offer-failure alert (wallapop-offer-flow). + + Mirrors :func:`render_phase2_buy_failure`: the reassurance line β€” + :data:`OFFER_REASSURANCE_LINE` (or the ``screenshot_missing`` ambiguity + special case) β€” is non-optional. A variant without a cause label fails + loud (KeyError) rather than rendering a hole. + """ + ctx_map = dict(ctx) if ctx is not None else {} + severity = SEVERITY_TOKENS["offer_failure"] + entry = escape_markdown_v2(entry_display_name) + cause = escape_markdown_v2(_OFFER_FAILURE_CAUSE_LABELS[reason.value]) + + rows: list[str] = [ + f"{severity} *Oferta no enviada* Β· {entry}", + _prose("Causa: ") + cause, + ] + rows.extend(_offer_failure_detail_rows(reason, ctx_map)) + rows.append("") + if reason.value == "screenshot_missing": + rows.append(_prose(OFFER_SCREENSHOT_MISSING_REASSURANCE)) + else: + rows.append(_prose(OFFER_REASSURANCE_LINE)) + rows.extend(_offer_failure_next_steps(reason, ctx_map)) + + return RenderedAlert( + text="\n".join(rows), + parse_mode="MarkdownV2", + photo_url=None, + inline_keyboard=None, + ) + + +def _offer_failure_detail_rows(reason: OfferFailureReason, ctx: Mapping[str, Any]) -> list[str]: + """Variant-specific bullet rows that explain the offer failure.""" + name = reason.value + if name == "reconciliation_tripped": + displayed = _price_or_dash(ctx.get("displayed_offer")) + recomputed = ctx.get("recomputed_offer") + recomputed_row = ( + _prose("- Oferta recalculada: ") + _price_or_dash(recomputed) + if recomputed is not None + else _prose("- Oferta recalculada: ya no es posible (precio/estado nuevo)") + ) + return [_prose("- Oferta mostrada: ") + displayed, recomputed_row] + if name == "amount_rejected": + return [_prose("- Importe intentado: ") + _price_or_dash(ctx.get("offered"))] + if name == "daily_limit_reached": + source = ctx.get("limit_source", "propio") + label = ( + "presupuesto propio (offer.daily_limit)" + if source == "propio" + else "lΓ­mite de Wallapop (10/dΓ­a)" + ) + return [_prose(f"- LΓ­mite: {label}")] + if name == "lockout_engaged": + failures = ctx.get("consecutive_failures", "β€”") + threshold = ctx.get("threshold", "β€”") + return [_prose(f"- {failures} fallos consecutivos Β· umbral: {threshold}")] + if name == "offer_unavailable": + return [_prose("- Vendedor PRO, producto reacondicionado o categorΓ­a excluida")] + if name in {"ui_check_failed", "missing_element"}: + missing = ctx.get("missing", "β€”") + return [_prose(f"- Elementos faltantes: {missing}")] + if name in {"marketplace_error", "timeout"}: + detail = ctx.get("error_class") or ctx.get("detail") or "β€”" + return [_prose(f"- Detalle: {detail}")] + return [] + + +def _offer_failure_next_steps(reason: OfferFailureReason, _ctx: Mapping[str, Any]) -> list[str]: + """Variant-specific numbered next-step hints.""" + name = reason.value + if name == "lockout_engaged": + return [ + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. ") + _cmd(_CMD_AUDIT_SHOW_LAST5), + _prose("2. ") + _cmd(_CMD_OFFER_ENABLE), + ] + if name == "daily_limit_reached": + return [ + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. Reintenta cuando la ventana de 24 h libere presupuesto"), + ] + if name == "screenshot_missing": + return [ + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. Comprueba el chat del anuncio en la app de Wallapop"), + _prose("2. ") + _cmd(_CMD_AUDIT_SHOW_LAST5), + ] + return [ + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. ") + _cmd(_CMD_AUDIT_SHOW_LAST5), + ] + + # ───────────────────────────────────────────────────────────────────────── # Operational alert renderer β€” Story 4.1 (FR21 / UX-DR13 / UX-DR14 / UX-DR15) # ───────────────────────────────────────────────────────────────────────── @@ -796,6 +1109,12 @@ class EventName(enum.Enum): phase2_screenshot_missing = "phase2_screenshot_missing" phase2_buy_completion_slow = "phase2_buy_completion_slow" buy_orchestrator_error = "buy_orchestrator_error" + # Wallapop offer-flow operational variants (wallapop-offer-flow). The + # set stays closed: any new variant is a PRD amendment. + offer_lockout_engaged = "offer_lockout_engaged" + offer_disabled = "offer_disabled" + offer_re_enabled = "offer_re_enabled" + offer_orchestrator_error = "offer_orchestrator_error" def _prose(text: str) -> str: @@ -1031,6 +1350,51 @@ def _body_poll_cycle_error(ctx: Mapping[str, Any]) -> list[str]: ] +def _body_offer_lockout_engaged(ctx: Mapping[str, Any]) -> list[str]: + failures = ctx.get("consecutive_failures", "β€”") + threshold = ctx.get("threshold", "β€”") + last_entry = str(ctx.get("last_affected_entry", "β€”")) + return [ + _prose(f"Causa: {failures} fallos consecutivos (umbral: {threshold})"), + _prose(f"Última entrada afectada: {last_entry}"), + _prose("Estado actual: envΓ­o de ofertas desactivado globalmente"), + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. ") + _cmd(_CMD_AUDIT_SHOW_LAST5), + _prose("2. revisa la causa y parchea si es un bug"), + _prose("3. ") + _cmd(_CMD_OFFER_ENABLE), + ] + + +def _body_offer_disabled(ctx: Mapping[str, Any]) -> list[str]: + reason = str(ctx.get("reason", "β€”")) + return [ + _prose(f"Causa: {reason}"), + _prose("Estado actual: envΓ­o de ofertas desactivado globalmente"), + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. ") + _cmd(_CMD_OFFER_ENABLE), + ] + + +def _body_offer_re_enabled(ctx: Mapping[str, Any]) -> list[str]: + entry = str(ctx.get("entry", "β€”")) + return [_prose(f"Entrada: {entry}")] + + +def _body_offer_orchestrator_error(ctx: Mapping[str, Any]) -> list[str]: + error_class = str(ctx.get("error_class", "β€”")) + alert_id = str(ctx.get("alert_id", "β€”")) + return [ + _prose(f"Causa: {error_class}"), + _prose(f"Alert: {alert_id}"), + _prose("Estado: ninguna oferta enviada; el teclado se ha restaurado"), + "", + _prose(_NEXT_STEP_HEADER), + _prose("1. ") + _cmd(_CMD_AUDIT_SHOW_LAST5), + ] + + @dataclass(frozen=True) class _OperationalEventSpec: """Per-event rendering contract: canonical severity + headline + body builder.""" @@ -1118,6 +1482,20 @@ class _OperationalEventSpec: "Error en el orquestador de compra", _body_buy_orchestrator_error, ), + EventName.offer_lockout_engaged: _OperationalEventSpec( + "warn", "EnvΓ­o de ofertas desactivado", _body_offer_lockout_engaged + ), + EventName.offer_disabled: _OperationalEventSpec( + "warn", "EnvΓ­o de ofertas desactivado", _body_offer_disabled + ), + EventName.offer_re_enabled: _OperationalEventSpec( + "info", "EnvΓ­o de ofertas reactivado", _body_offer_re_enabled + ), + EventName.offer_orchestrator_error: _OperationalEventSpec( + "warn", + "Error en el orquestador de ofertas", + _body_offer_orchestrator_error, + ), } diff --git a/src/salvager/domain/audit.py b/src/salvager/domain/audit.py index 5429a2e..930de99 100644 --- a/src/salvager/domain/audit.py +++ b/src/salvager/domain/audit.py @@ -66,7 +66,7 @@ class CallbackAudit(BaseModel): alert_id: UUID telegram_message_id: int callback_data: str - verb: Literal["view", "skip", "snooze", "buy"] + verb: Literal["view", "skip", "snooze", "buy", "offer"] chat_id: int occurred_at: datetime diff --git a/src/salvager/domain/errors.py b/src/salvager/domain/errors.py index 9965d08..66c312f 100644 --- a/src/salvager/domain/errors.py +++ b/src/salvager/domain/errors.py @@ -261,3 +261,28 @@ class BuyFailureReason(enum.Enum): timeout = "timeout" screenshot_missing = "screenshot_missing" payment_rail_unavailable = "payment_rail_unavailable" + + +@enum.unique +class OfferFailureReason(enum.Enum): + """Closed set of reasons a Wallapop offer can abort or fail. + + Deliberately separate from :class:`BuyFailureReason` β€” both are closed + sets that render tables and variant-count pins treat as exhaustive, and + they evolve independently (wallapop-offer-flow design D9). Every variant + is renderable by the offer failure renderer; adding a variant requires a + PRD amendment AND a render-table entry, never a silent fall-through. + """ + + listing_gone = "listing_gone" + reconciliation_tripped = "reconciliation_tripped" + offer_unavailable = "offer_unavailable" + amount_rejected = "amount_rejected" + daily_limit_reached = "daily_limit_reached" + duplicate_offer = "duplicate_offer" + lockout_engaged = "lockout_engaged" + missing_element = "missing_element" + marketplace_error = "marketplace_error" + timeout = "timeout" + screenshot_missing = "screenshot_missing" + ui_check_failed = "ui_check_failed" diff --git a/src/salvager/domain/listing.py b/src/salvager/domain/listing.py index 624826f..6ca98fa 100644 --- a/src/salvager/domain/listing.py +++ b/src/salvager/domain/listing.py @@ -64,6 +64,12 @@ class Listing(BaseModel): published_at: datetime | None = None fetched_at: datetime is_reserved: bool = False + #: Wallapop marks refurbished listings in the search payload + #: (``is_refurbished.flag``, live-probed 2026-07-22). Refurbished + #: products don't accept offers, so the offer surface pre-filters on + #: this instead of burning a tap on a guaranteed ``offer_unavailable``. + #: ``False`` = not refurbished or marketplace doesn't expose it. + is_refurbished: bool = False # Set by the LLM evaluator; None pre-evaluation. entry_key_match: tuple[str, str, str] | None = None diff --git a/src/salvager/domain/offer_audit.py b/src/salvager/domain/offer_audit.py new file mode 100644 index 0000000..c34321d --- /dev/null +++ b/src/salvager/domain/offer_audit.py @@ -0,0 +1,61 @@ +"""Offer audit input models (wallapop-offer-flow). + +Typed write/read contracts for :class:`OfferAuditWriter` β€” the same split +as :mod:`salvager.domain.phase2_audit`: one append-only record model for +the ``offers`` table, one snapshot model for the mutable single-row +``offer_state`` lockout. Offer *taps* have no model here β€” they ride the +existing ``callbacks`` audit path like every other verb. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + +from salvager.domain.errors import OfferFailureReason + + +class OfferAttemptRecord(BaseModel): + """One executed offer attempt β€” written to ``offers``. + + ``platform_remaining`` is Wallapop's "N ofertas restantes" counter when + the agent saw it on the offer form (free observability of the platform's + 10/day budget); ``None`` when not visible. ``status`` is ``"sent"`` for + every v1 row β€” a future acceptance-detection change extends the set. + """ + + model_config = ConfigDict(extra="forbid") + + alert_id: UUID + listing_id: str + marketplace: str + entry_key: tuple[str, str, str] + offered_eur: Decimal + asking_eur: Decimal + outcome: Literal["success", "failure", "aborted"] + failure_reason: OfferFailureReason | None = None + screenshot_path: str | None = None + platform_remaining: int | None = None + status: Literal["sent"] = "sent" + attempted_at: datetime + + +class OfferStateSnapshot(BaseModel): + """The mutable ``offer_state`` row, as read at one point in time.""" + + model_config = ConfigDict(extra="forbid") + + globally_disabled: bool + disabled_at: datetime | None = None + disabled_reason: str | None = None + consecutive_failures: int + + +__all__ = [ + "OfferAttemptRecord", + "OfferStateSnapshot", +] diff --git a/src/salvager/domain/pricing.py b/src/salvager/domain/pricing.py index 1c50fa7..ba5b19c 100644 --- a/src/salvager/domain/pricing.py +++ b/src/salvager/domain/pricing.py @@ -160,6 +160,58 @@ def buyer_cost( ) +# Wallapop's offer form rejects discounts deeper than 30 % of the asking +# price ("Tu oferta debe ser de al menos X € (-30%)", operator-captured +# 2026-07-22). The floor is inclusive: exactly 70 % of asking is accepted. +OFFER_PLATFORM_FLOOR_RATIO: Final[Decimal] = Decimal("0.70") + +_ONE_EURO: Final[Decimal] = Decimal("1") + + +def offer_item_price_eur( + listing: Listing, + *, + target_total_eur: Decimal, + assumed_shipping_eur: Decimal, +) -> Decimal | None: + """Largest whole-euro item price whose Wallapop buyer total fits the target. + + The returned price ``O`` satisfies ``O + shipping + proteccion(O) <= + target_total_eur`` (shipping = the parsed value when known, else the + ``assumed_shipping_eur`` buffer β€” never zero). Whole euros because that is + how offers read in a negotiation, and flooring is always conservative. + + Returns ``None`` β€” offer not possible β€” when the listing is not Wallapop, + when no positive whole-euro price fits the target, when the fit price is + not strictly below the asking price (nothing to negotiate: the listing + already fits, or the target is above asking), or when the fit price falls + under the platform floor of :data:`OFFER_PLATFORM_FLOOR_RATIO` x asking + (Wallapop rejects offers below 70 % of the asking price). + """ + if listing.marketplace != "wallapop": + return None + shipping = listing.shipping_eur if listing.shipping_eur is not None else assumed_shipping_eur + budget = target_total_eur - shipping + # Closed-form guess on the variable-fee branch, then settle with the real + # fee schedule (flat branch ≀ 13 €, cap at the documented maximum). + if budget > 0: + guess = int((budget - _PROTECCION_BASE_EUR) / (_ONE_EURO + _PROTECCION_PCT)) + else: + guess = 0 + candidate = Decimal(max(guess, 0)) + while candidate + _ONE_EURO + proteccion_wallapop_fee(candidate + _ONE_EURO) <= budget: + candidate += _ONE_EURO + while candidate >= _ONE_EURO and candidate + proteccion_wallapop_fee(candidate) > budget: + candidate -= _ONE_EURO + if candidate < _ONE_EURO: + return None + if candidate >= listing.price_eur: + return None + if candidate < listing.price_eur * OFFER_PLATFORM_FLOOR_RATIO: + return None + return candidate + + def buyer_total_eur( listing: Listing, *, @@ -178,8 +230,10 @@ def buyer_total_eur( "DEFAULT_ASSUMED_IMPORT_CHARGES_EUR", "DEFAULT_ASSUMED_SHIPPING_EUR", "EU_COUNTRY_CODES", + "OFFER_PLATFORM_FLOOR_RATIO", "BuyerCost", "buyer_cost", "buyer_total_eur", + "offer_item_price_eur", "proteccion_wallapop_fee", ] diff --git a/src/salvager/domain/wishlist.py b/src/salvager/domain/wishlist.py index 8cec21f..3755e8b 100644 --- a/src/salvager/domain/wishlist.py +++ b/src/salvager/domain/wishlist.py @@ -56,6 +56,22 @@ class Phase2Settings(BaseModel): max_price_eur: Decimal | None = None +class OfferSettings(BaseModel): + """Per-entry Wallapop offer settings (wallapop-offer-flow). + + ``enabled`` gates the whole offer surface for the entry (Ofertar button, + negotiable-band alerts); toggled via ``salvager offer enable/disable`` + like its Phase 2 sibling. ``target_total_eur`` optionally aims offers at a + delivered total BELOW the entry ceiling ("I'd accept 80 € but I want to + pay 70 €"); ``None`` targets the effective ceiling (``max_price_solo``). + """ + + model_config = ConfigDict(extra="forbid") + + enabled: bool = False + target_total_eur: Decimal | None = None + + class WishlistEntry(BaseModel): """One declared wish β€” what the operator is hunting and at what ceiling. @@ -75,6 +91,7 @@ class WishlistEntry(BaseModel): keywords: list[str] = Field(default_factory=list) container_keywords: list[str] = Field(default_factory=list) phase2: Phase2Settings = Field(default_factory=Phase2Settings) + offer: OfferSettings = Field(default_factory=OfferSettings) confidence_threshold: ConfidenceThreshold @model_validator(mode="after") diff --git a/src/salvager/interfaces/offer_session.py b/src/salvager/interfaces/offer_session.py new file mode 100644 index 0000000..1d82175 --- /dev/null +++ b/src/salvager/interfaces/offer_session.py @@ -0,0 +1,90 @@ +"""``OfferSession`` port (wallapop-offer-flow, FR58-FR65). + +The port through which the offer orchestrator sends one bounded price +offer on a Wallapop listing. A sibling of :class:`BrowserSession` +rather than an extension of it: offers are Wallapop-only in v1, so the +eBay checkout flow never has to stub an ``execute_offer`` it cannot +implement. + +Adapter discipline (NFR-M1) holds the same way as the buy path: the +orchestration layer composes ``OfferSession`` only and never sees the +TinyFish SDK. + +The contract is fail-closed on the SEND: any uncertainty about whether +the offer went out MUST surface as :class:`OfferSendFailure` (never a +silent success) β€” the per-listing dedupe engages only on a verified +sent state. The screenshot is best-effort evidence, not the success +criterion: a verified send whose capture failed is still a success +(with ``screenshot_url = None``). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from decimal import Decimal +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from salvager.domain.errors import OfferFailureReason +from salvager.domain.listing import Listing + + +class OfferSuccess(BaseModel): + """The offer form confirmed the sent state. + + ``platform_remaining`` is Wallapop's "N ofertas restantes para hoy" + counter when the agent saw it on the form (after sending); ``None`` + when not visible. ``screenshot_url`` is the captured confirmation + when available β€” evidence, not the success criterion. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["success"] = "success" + offered_eur: Decimal = Field(gt=0) + screenshot_url: str | None = None + platform_remaining: int | None = None + total_seconds: int = Field(ge=0) + + +class OfferSendFailure(BaseModel): + """The offer was NOT verifiably sent. + + ``ctx`` carries the variant-specific detail the renderer needs β€” + each :class:`OfferFailureReason` documents its own ctx contract. + ``screenshot_missing`` is the one deliberately ambiguous variant: + the agent submitted but could not verify the sent state, so the + alert copy directs the operator to the Wallapop chat. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["failure"] = "failure" + reason: OfferFailureReason + ctx: dict[str, Any] = Field(default_factory=dict) + + +#: Discriminated union β€” the orchestrator pattern-matches on ``kind``. +OfferResult = Annotated[OfferSuccess | OfferSendFailure, Field(discriminator="kind")] + + +class OfferSession(ABC): + """Port for sending one bounded offer on a marketplace listing.""" + + @abstractmethod + async def execute_offer(self, listing: Listing, amount_eur: Decimal) -> OfferResult: + """Send an offer of EXACTLY ``amount_eur`` on ``listing``. + + The flow embeds the amount in the agent goal as a hard + constraint β€” the agent must never enter any other value, and + must abort (``amount_rejected``) if the form refuses it. + """ + + +__all__ = [ + "OfferResult", + "OfferSendFailure", + "OfferSession", + "OfferSuccess", +] diff --git a/src/salvager/migrations/0004_offer_schema.sql b/src/salvager/migrations/0004_offer_schema.sql new file mode 100644 index 0000000..4c86ee9 --- /dev/null +++ b/src/salvager/migrations/0004_offer_schema.sql @@ -0,0 +1,56 @@ +-- 0004_offer_schema.sql β€” Wallapop offer-flow schema (wallapop-offer-flow). +-- +-- Two pieces, mirroring the Phase 2 split (0002): +-- 1. `offers` β€” append-only: one row per EXECUTED offer attempt (success +-- or failure). Application-layer append-only via `OfferAuditWriter` +-- (INSERT-only methods; the append-only lint test covers it). Offer +-- *taps* are not duplicated here β€” they ride the existing `callbacks` +-- audit path like every other verb. +-- 2. `offer_state` β€” the single mutable lockout row (same class as +-- `phase2_state`, deliberately SEPARATE from it: offer failures must +-- never block real buys, and vice versa). +-- +-- `status` is `'sent'` for every v1 row; a future acceptance-detection +-- change extends the value set without a schema break. +-- +-- Idempotent: every statement is `IF NOT EXISTS` / `INSERT OR IGNORE`, +-- so re-running the migration after a partial application is safe. + +CREATE TABLE IF NOT EXISTS offers ( + audit_id INTEGER PRIMARY KEY AUTOINCREMENT, + alert_id TEXT NOT NULL, -- UUID, joins alert_snapshots.alert_id + listing_id TEXT NOT NULL, + marketplace TEXT NOT NULL, -- listing_ids are only unique per marketplace + entry_manufacturer TEXT NOT NULL, + entry_model TEXT NOT NULL, + entry_ref TEXT NOT NULL, + offered_eur TEXT NOT NULL, -- Decimal stringified (whole euros in v1) + asking_eur TEXT NOT NULL, -- Decimal stringified; asking item price at tap time + outcome TEXT NOT NULL, -- "success" | "failure" | "aborted" + failure_reason TEXT, -- OfferFailureReason value; NULL on success + screenshot_path TEXT, -- NULL when the agent produced none + platform_remaining INTEGER, -- "N ofertas restantes" counter when the agent saw it + status TEXT NOT NULL DEFAULT 'sent', -- v1: 'sent' (successful sends only meaning) + attempted_at TEXT NOT NULL -- ISO 8601 UTC +); + +CREATE INDEX IF NOT EXISTS idx_offers_listing + ON offers (marketplace, listing_id); + +CREATE INDEX IF NOT EXISTS idx_offers_attempted_at + ON offers (attempted_at); + +-- ───────────────────────────────────────────────────────────────────── +-- offer_state β€” the single-row offer lockout state. `CHECK (id = 1)` +-- plus the seed row guarantee exactly one row to UPDATE in place. +-- ───────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS offer_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + globally_disabled INTEGER NOT NULL DEFAULT 0, -- 0 = active, 1 = locked out + disabled_at TEXT, -- ISO 8601 UTC, NULL when active + disabled_reason TEXT, -- NULL when active + consecutive_failures INTEGER NOT NULL DEFAULT 0 +); + +INSERT OR IGNORE INTO offer_state (id, globally_disabled, consecutive_failures) + VALUES (1, 0, 0); diff --git a/src/salvager/orchestration/alert_updater.py b/src/salvager/orchestration/alert_updater.py index 2b288e6..ca4d451 100644 --- a/src/salvager/orchestration/alert_updater.py +++ b/src/salvager/orchestration/alert_updater.py @@ -23,6 +23,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timedelta from decimal import Decimal @@ -34,7 +35,11 @@ _phase1_button_row, _phase2_button_row, apply_update_banner, + negotiable_button_row, + offer_button_row, + offer_sent_badge_row, phase2_dead_reserved_row, + render_negotiable_listing_alert, render_phase1_listing_alert, render_phase2_listing_alert, render_price_drop_ping, @@ -43,7 +48,7 @@ from salvager.domain.alert_watch import AlertUpdate, AlertWatch, ChangeKind from salvager.domain.errors import TelegramMessageGone from salvager.domain.listing import Listing -from salvager.domain.pricing import buyer_cost +from salvager.domain.pricing import buyer_cost, buyer_total_eur, offer_item_price_eur from salvager.domain.wishlist import WishlistEntry from salvager.interfaces.store import Store from salvager.interfaces.telegram_surface import TelegramSurface @@ -110,23 +115,42 @@ def reconstruct_keyboard( last_verb: str | None, *, now_reserved: bool, + offer_sent: bool = False, + offer_eligible: bool = False, ) -> list[list[InlineButton]] | None: """The keyboard the edited message currently deserves. Telegram drops the keyboard on any body edit that omits ``reply_markup``, and the current keyboard may no longer be the one sent (the callback dispatcher repaints ack rows). The caller has - already excluded the in-flight ``buy`` verb (never repaint under a - running buy). + already excluded the in-flight ``buy``/``offer`` verbs (never + repaint under a running buy or offer). + + Offer state (wallapop-offer-flow): a recorded successful send keeps + the terminal ``πŸ’° Oferta enviada`` badge across every edit; + ``offer_eligible`` re-derives the Ofertar row from the listing's + CURRENT values (a reserved listing never shows it β€” like Comprar). """ alert_id = str(snapshot.alert_id) if last_verb in _ACKED_VERBS: return _acknowledgment_keyboard(last_verb, snapshot.alert_id) - if snapshot.phase == "phase2": + if snapshot.phase == "negotiable": + if offer_sent: + return [offer_sent_badge_row(alert_id)] if now_reserved: return [phase2_dead_reserved_row(alert_id)] - return [_phase2_button_row(alert_id)] - return [_phase1_button_row(alert_id)] + return [negotiable_button_row(alert_id)] + if snapshot.phase == "phase2": + rows = ( + [phase2_dead_reserved_row(alert_id)] if now_reserved else [_phase2_button_row(alert_id)] + ) + else: + rows = [_phase1_button_row(alert_id)] + if offer_sent: + rows.append(offer_sent_badge_row(alert_id)) + elif offer_eligible and not now_reserved: + rows.append(offer_button_row(alert_id)) + return rows async def process_entry_watches( @@ -141,6 +165,7 @@ async def process_entry_watches( assumed_import_charges_eur: Decimal, now: datetime, log: object, + has_offered: Callable[[str, str], Awaitable[bool]] | None = None, ) -> None: """Diff the entry's active watches against this cycle's fetch and edit changed alerts. Never raises β€” edits are strictly best-effort.""" @@ -163,6 +188,7 @@ async def process_entry_watches( await _process_one_watch( watch, listing, + entry=entry, store=store, telegram=telegram, policy=policy, @@ -170,6 +196,7 @@ async def process_entry_watches( assumed_import_charges_eur=assumed_import_charges_eur, now=now, log=log, + has_offered=has_offered, ) except Exception as exc: log.exception( # type: ignore[attr-defined] @@ -186,6 +213,7 @@ async def _process_one_watch( watch: AlertWatch, listing: Listing, *, + entry: WishlistEntry, store: Store, telegram: TelegramSurface, policy: AlertUpdatePolicy, @@ -193,6 +221,7 @@ async def _process_one_watch( assumed_import_charges_eur: Decimal, now: datetime, log: object, + has_offered: Callable[[str, str], Awaitable[bool]] | None = None, ) -> None: diff = detect_change(watch, listing, policy) if diff.change is None: @@ -206,16 +235,20 @@ async def _process_one_watch( last_callback = await store.get_last_callback_verb(watch.alert_id) last_verb = last_callback[0] if last_callback is not None else None - if last_verb == "buy": - # Never repaint under a RUNNING buy β€” but callbacks are append-only, - # so the marker must age out or a completed buy would suppress edits - # forever. Real buys resolve in minutes; after the window the diff - # proceeds (a bought listing shows as reserved β†’ dead badge, correct). + if last_verb in ("buy", "offer"): + # Never repaint under a RUNNING buy or offer β€” but callbacks are + # append-only, so the marker must age out or a completed action would + # suppress edits forever. Both resolve in minutes; after the window + # the diff proceeds. tapped_at = last_callback[1] if last_callback is not None else now if (now - tapped_at) <= timedelta(minutes=policy.buy_suppression_minutes): log.info( # type: ignore[attr-defined] - "alert_update_skipped_buy_in_flight", - extra={"alert_id": str(watch.alert_id), "change_kind": diff.change}, + "alert_update_skipped_action_in_flight", + extra={ + "alert_id": str(watch.alert_id), + "change_kind": diff.change, + "verb": last_verb, + }, ) return last_verb = None # aged out β€” reconstruct the phase keyboard @@ -230,11 +263,23 @@ async def _process_one_watch( await store.close_watch(watch.alert_id) return + offer_sent = False + if has_offered is not None and entry.offer.enabled: + try: + offer_sent = await has_offered(listing.marketplace, listing.listing_id) + except Exception as exc: + log.warning( # type: ignore[attr-defined] + "alert_update_offer_lookup_failed", + extra={"alert_id": str(watch.alert_id), "error_class": type(exc).__name__}, + ) + rendered = _render_update( snapshot, listing, diff.change, last_verb, + entry=entry, + offer_sent=offer_sent, previous_price_eur=watch.last_price_eur, assumed_shipping_eur=assumed_shipping_eur, assumed_import_charges_eur=assumed_import_charges_eur, @@ -321,6 +366,8 @@ def _render_update( change: ChangeKind, last_verb: str | None, *, + entry: WishlistEntry, + offer_sent: bool, previous_price_eur: Decimal, assumed_shipping_eur: Decimal, assumed_import_charges_eur: Decimal, @@ -330,6 +377,12 @@ def _render_update( The base renderers stay the single source of truth for alert anatomy (Decision 5); the comp row is omitted (it was an in-cycle signal at dispatch time, not current data). + + Offer surface (wallapop-offer-flow): the offer line + Ofertar + eligibility re-derive from the listing's CURRENT values. A price + drop can move a negotiable listing to at-or-under ceiling β€” then the + standard alert surface renders instead (Phase 2 when the entry + qualifies structurally; the tap-time preflight re-gates as always). """ updated = snapshot.model_copy(update={"listing": listing}) cost = buyer_cost( @@ -337,17 +390,98 @@ def _render_update( assumed_shipping_eur=assumed_shipping_eur, assumed_import_charges_eur=assumed_import_charges_eur, ) - if snapshot.phase == "phase2" and snapshot.phase2_max_price_eur is not None: - base = render_phase2_listing_alert(updated, snapshot.phase2_max_price_eur, buyer_cost=cost) + + offer_eur = None + offer_target = None + ceiling = entry.max_price_solo or entry.max_price_in_device + if ( + entry.offer.enabled + and listing.marketplace == "wallapop" + and not listing.is_refurbished + and ceiling is not None + ): + offer_target = entry.offer.target_total_eur or ceiling + offer_eur = offer_item_price_eur( + listing, + target_total_eur=offer_target, + assumed_shipping_eur=assumed_shipping_eur, + ) + if offer_eur is None: + offer_target = None + + dropped_into_budget = ( + snapshot.phase == "negotiable" + and ceiling is not None + and buyer_total_eur( + listing, + assumed_shipping_eur=assumed_shipping_eur, + assumed_import_charges_eur=assumed_import_charges_eur, + ) + <= ceiling + ) + + if snapshot.phase == "negotiable" and not dropped_into_budget and offer_eur is not None: + assert offer_target is not None + base = render_negotiable_listing_alert( + updated, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + buyer_cost=cost, + ) + keyboard = reconstruct_keyboard( + snapshot, + last_verb, + now_reserved=listing.is_reserved, + offer_sent=offer_sent, + ) else: - base = render_phase1_listing_alert(updated, buyer_cost=cost) + effective_phase = snapshot.phase + phase2_max = snapshot.phase2_max_price_eur + if snapshot.phase == "negotiable": + # Dropped into budget (or the offer surface vanished): render the + # standard anatomy. Phase 2 only when the entry structurally + # qualifies β€” the tap-time preflight re-gates everything else. + if ( + dropped_into_budget + and entry.phase2.enabled + and entry.phase2.max_price_eur is not None + ): + effective_phase, phase2_max = "phase2", entry.phase2.max_price_eur + else: + effective_phase, phase2_max = "phase1", None + if effective_phase == "phase2" and phase2_max is not None: + base = render_phase2_listing_alert( + updated, + phase2_max, + buyer_cost=cost, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + ) + else: + base = render_phase1_listing_alert( + updated, + buyer_cost=cost, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + ) + surface_snapshot = ( + snapshot + if snapshot.phase != "negotiable" + else snapshot.model_copy(update={"phase": effective_phase}) + ) + keyboard = reconstruct_keyboard( + surface_snapshot, + last_verb, + now_reserved=listing.is_reserved, + offer_sent=offer_sent, + offer_eligible=offer_eur is not None, + ) banner = update_banner_line( change, old_price_eur=previous_price_eur, new_price_eur=listing.price_eur, ) - keyboard = reconstruct_keyboard(snapshot, last_verb, now_reserved=listing.is_reserved) return apply_update_banner(base, banner, keyboard) diff --git a/src/salvager/orchestration/callback_handler.py b/src/salvager/orchestration/callback_handler.py index 8435a07..9a85d84 100644 --- a/src/salvager/orchestration/callback_handler.py +++ b/src/salvager/orchestration/callback_handler.py @@ -39,7 +39,7 @@ #: Verbs the dispatcher acts on. Anything else logs at warn level and #: is dropped silently β€” the surface layer (``TelegramBotSurface``) #: should have already filtered, this is defence in depth. -HANDLED_VERBS: Final[frozenset[str]] = frozenset({"view", "skip", "snooze", "buy"}) +HANDLED_VERBS: Final[frozenset[str]] = frozenset({"view", "skip", "snooze", "buy", "offer"}) #: Phase 1 acknowledgment-row labels (UX-DR12). Spanish past-participles #: match :data:`BUTTON_LABELS`' present-tense verbs (Ver β†’ visto, Saltar β†’ @@ -58,6 +58,10 @@ #: text, not the message body). BUY_IN_FLIGHT_LABEL: Final[str] = "🟑 Comprando…" +#: The non-tappable in-flight badge shown while the offer orchestrator +#: runs (wallapop-offer-flow) β€” same noop pattern as the buy badge. +OFFER_IN_FLIGHT_LABEL: Final[str] = "🟑 Ofertando…" + #: Default snooze window. The orchestrator can override via #: ``snooze_hours`` to wire ``config.yaml > snooze.default_hours``. DEFAULT_SNOOZE_HOURS: Final[int] = 24 @@ -78,6 +82,12 @@ class BuyExecutor(Protocol): async def execute_buy_from_callback(self, event: CallbackEvent) -> object: ... +class OfferExecutor(Protocol): + """Structural type for "something that can send a Wallapop offer".""" + + async def execute_offer_from_callback(self, event: CallbackEvent) -> object: ... + + class CallbackDispatcher: """Routes Phase 1 + Phase 2 callbacks to audit + state + keyboard edits. @@ -96,6 +106,7 @@ def __init__( store: Store, surface: TelegramSurface, buy_orchestrator: BuyExecutor | None = None, + offer_orchestrator: OfferExecutor | None = None, snooze_hours: int = DEFAULT_SNOOZE_HOURS, clock: Callable[[], datetime] = _utc_now, new_audit_id: Callable[[], UUID] = uuid_module.uuid4, @@ -103,6 +114,7 @@ def __init__( self._store = store self._surface = surface self._buy_orchestrator = buy_orchestrator + self._offer_orchestrator = offer_orchestrator self._snooze_hours = snooze_hours self._clock = clock self._new_audit_id = new_audit_id @@ -152,6 +164,10 @@ async def handle(self, event: CallbackEvent) -> None: await self._handle_buy(event, alert_id) return + if event.verb == "offer": + await self._handle_offer(event, alert_id) + return + if event.verb == "snooze": await self._apply_snooze(alert_id, now) @@ -198,6 +214,27 @@ async def _handle_buy(self, event: CallbackEvent, alert_id: UUID) -> None: self._buy_tasks.add(task) task.add_done_callback(self._buy_tasks.discard) + async def _handle_offer(self, event: CallbackEvent, alert_id: UUID) -> None: + """Offer verb: in-flight badge + fire-and-forget orchestrator β€” + the exact shape of :meth:`_handle_buy` (wallapop-offer-flow).""" + self._log.info( + "offer_callback_received", + extra={"alert_id": str(alert_id), "callback_data": event.callback_data}, + ) + await self._surface.edit_keyboard( + event.message_id, + _offer_in_flight_keyboard(alert_id), + ) + if self._offer_orchestrator is None: + self._log.error( + "offer_orchestrator_not_wired", + extra={"alert_id": str(alert_id)}, + ) + return + task = asyncio.create_task(self._offer_orchestrator.execute_offer_from_callback(event)) + self._buy_tasks.add(task) + task.add_done_callback(self._buy_tasks.discard) + async def _apply_snooze(self, alert_id: UUID, now: datetime) -> None: snapshot = await self._store.get_alert_snapshot_by_alert_id(alert_id) if snapshot is None: @@ -258,6 +295,19 @@ def _acknowledgment_keyboard(verb: str, alert_id: UUID) -> list[list[InlineButto ] +def _offer_in_flight_keyboard(alert_id: UUID) -> list[list[InlineButton]]: + """Single-row ``🟑 Ofertando…`` badge shown while the offer + orchestrator runs β€” same noop pattern as the buy badge.""" + return [ + [ + InlineButton( + text=OFFER_IN_FLIGHT_LABEL, + callback_data=f"listing:noop:{alert_id}", + ) + ] + ] + + def _in_flight_keyboard(alert_id: UUID) -> list[list[InlineButton]]: """Build the single-row in-flight keyboard shown while the buy orchestrator runs (Story 5.10, UX-DR11). @@ -283,6 +333,8 @@ def _in_flight_keyboard(alert_id: UUID) -> list[list[InlineButton]]: "BUY_IN_FLIGHT_LABEL", "DEFAULT_SNOOZE_HOURS", "HANDLED_VERBS", + "OFFER_IN_FLIGHT_LABEL", "BuyExecutor", "CallbackDispatcher", + "OfferExecutor", ] diff --git a/src/salvager/orchestration/composer.py b/src/salvager/orchestration/composer.py index 62ad820..de43873 100644 --- a/src/salvager/orchestration/composer.py +++ b/src/salvager/orchestration/composer.py @@ -30,6 +30,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta +from decimal import Decimal from pathlib import Path from typing import Final @@ -50,6 +51,7 @@ MigrationRunner, db_path_under, ) +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter from salvager.adapters.sqlite_store.phase2_state_reader import ( SqlitePhase2StateReader, ) @@ -60,6 +62,7 @@ MarketplaceDispatchingBrowser, MarketplaceDispatchingPageFetcher, ) +from salvager.adapters.tinyfish_browser.wallapop_offer import WallapopOfferFlow from salvager.adapters.tinyfish_browser.wallapop_pay import WallapopPayFlow from salvager.adapters.wallapop_api.fetcher import WallapopApiFetcher from salvager.adapters.wallapop_tinyfish.fetcher import ( @@ -86,6 +89,8 @@ from salvager.orchestration.daemon import Daemon from salvager.orchestration.degradation_reporter import DegradationReporter from salvager.orchestration.health_state import HealthState +from salvager.orchestration.offer_orchestrator import OfferOrchestrator +from salvager.orchestration.offer_preflight import OfferPreflight from salvager.orchestration.phase2_parsers import default_price_parser_registry from salvager.orchestration.phase2_preflight import Phase2Preflight from salvager.orchestration.poll_loop import run_poll_cycle @@ -144,6 +149,10 @@ class ComposedDaemon: _phase2_wallapop_pay: WallapopPayFlow _phase2_ebay_checkout: EbayCheckoutFlow _phase2_recon_fetcher: MarketplaceDispatchingPageFetcher + #: Offer-flow handles (wallapop-offer-flow); None when the daemon was + #: composed without Wallapop credentials (offers are Wallapop-only). + _offer_writer: OfferAuditWriter | None = None + _offer_flow: WallapopOfferFlow | None = None async def aclose(self) -> None: """Close every adapter that owns OS resources. Idempotent β€” @@ -155,14 +164,20 @@ async def aclose(self) -> None: handle has been given its chance to close, preserving the shutdown-surfaces-errors contract. """ - closers = ( - self._phase2_wallapop_pay.close, - self._phase2_ebay_checkout.close, - self._phase2_recon_fetcher.aclose, - self._phase2_audit_writer.close, - self._phase2_state_reader.close, - self.cache.close, - self.store.close, + closers = tuple( + closer + for closer in ( + self._phase2_wallapop_pay.close, + self._phase2_ebay_checkout.close, + self._phase2_recon_fetcher.aclose, + self._phase2_audit_writer.close, + self._phase2_state_reader.close, + self._offer_flow.close if self._offer_flow is not None else None, + self._offer_writer.close if self._offer_writer is not None else None, + self.cache.close, + self.store.close, + ) + if closer is not None ) log = get_logger("orchestration.composer") errors: list[Exception] = [] @@ -267,6 +282,25 @@ def compose_daemon( ebay_quota=ebay_quota, ) + # Offer flow (wallapop-offer-flow) β€” Wallapop-only, so composed only + # when the daemon has Wallapop credentials. Like the buy orchestrator + # it fires only on an operator Ofertar tap; entries opt in via + # `offer.enabled` (toggled by `salvager offer enable`). + offer = ( + _build_offer_orchestrator( + env=env, + config=config, + data_dir=data_dir, + wishlist_path=Path(wishlist_path), + store=store, + telegram=telegram, + reporter=reporter, + wallapop_cookies_path=wallapop_cookies_path, + ) + if wallapop_enabled + else None + ) + alerts_policy = AlertUpdatePolicy( watch_days=config.alerts.watch_days, min_price_drop_pct=config.alerts.min_price_drop_pct, @@ -284,6 +318,8 @@ def compose_daemon( reporter=reporter, phase2_preflight=phase2.preflight, alerts_policy=alerts_policy, + offer_band_pct=config.offer.band_pct if offer is not None else None, + has_offered=offer.offer_writer.has_successful_offer if offer is not None else None, log=log, ) ebay_job = _build_ebay_job( @@ -333,6 +369,7 @@ def compose_daemon( store=store, surface=telegram, buy_orchestrator=phase2.orchestrator, + offer_orchestrator=offer.orchestrator if offer is not None else None, snooze_hours=DEFAULT_SNOOZE_HOURS, ) @@ -347,6 +384,8 @@ def compose_daemon( _phase2_wallapop_pay=phase2.wallapop_pay, _phase2_ebay_checkout=phase2.ebay_checkout, _phase2_recon_fetcher=phase2.recon_fetcher, + _offer_writer=offer.offer_writer if offer is not None else None, + _offer_flow=offer.offer_flow if offer is not None else None, ) @@ -427,6 +466,8 @@ def _build_wallapop_job( reporter: DegradationReporter, phase2_preflight: Phase2Preflight, alerts_policy: AlertUpdatePolicy, + offer_band_pct: Decimal | None, + has_offered: Callable[[str, str], Awaitable[bool]] | None, log: object, ) -> Callable[[], Awaitable[None]] | None: """Build the Wallapop poll closure, or return None when cookies are missing.""" @@ -461,6 +502,8 @@ async def _wallapop_cycle() -> None: telegram=telegram, phase2_preflight=phase2_preflight, alerts_policy=alerts_policy, + offer_band_pct=offer_band_pct, + has_offered=has_offered, ) # Persist the API-path health to `_meta` so `health` can report # "wallapop_api degraded / wallapop_tinyfish healthy" without @@ -724,6 +767,75 @@ def _build_buy_orchestrator( ) +# ───────────────────────────────────────────────────────────────────────── +# Offer orchestrator wiring β€” wallapop-offer-flow +# ───────────────────────────────────────────────────────────────────────── + + +@dataclass +class _OfferBundle: + """Output of :func:`_build_offer_orchestrator` β€” the orchestrator plus + the OS-resource handles :class:`ComposedDaemon` closes on shutdown.""" + + orchestrator: OfferOrchestrator + offer_writer: OfferAuditWriter + offer_flow: WallapopOfferFlow + + +def _build_offer_orchestrator( + *, + env: EnvSettings, + config: ConfigModel, + data_dir: Path, + wishlist_path: Path, + store: SqliteStore, + telegram: TelegramBotSurface, + reporter: DegradationReporter, + wallapop_cookies_path: Path, +) -> _OfferBundle: + """Wire the offer orchestrator (wallapop-offer-flow). + + Fires only on an operator Ofertar tap β€” no autonomous trigger. The + reconciliation re-fetch uses its own stateless Wallapop fetcher + duplicate (same pattern as the buy reconciler's). + """ + db_path = db_path_under(data_dir) + offer_writer = OfferAuditWriter(db_path) + + preflight = OfferPreflight( + offer_writer=offer_writer, + kill_switch_global=config.offer.kill_switch_global, + lockout_threshold=config.offer.lockout_threshold, + daily_limit=config.offer.daily_limit, + ) + offer_flow = WallapopOfferFlow(api_key=env.TINYFISH_API_KEY) + refetch: PageFetcher = WallapopApiFetcher( + cookies_path=wallapop_cookies_path, + latitude=config.wallapop.latitude, + longitude=config.wallapop.longitude, + ) + + orchestrator = OfferOrchestrator( + preflight=preflight, + fetcher=refetch, + offer_session=offer_flow, + offer_writer=offer_writer, + telegram_surface=telegram, + store=store, + reporter=reporter, + wishlist_loader=_make_wishlist_loader(wishlist_path), + lockout_threshold=config.offer.lockout_threshold, + tolerance_eur=config.phase2.reconciliation_tolerance_eur, + tolerance_pct=config.phase2.reconciliation_tolerance_pct, + assumed_shipping_eur=config.pricing.assumed_shipping_eur, + ) + return _OfferBundle( + orchestrator=orchestrator, + offer_writer=offer_writer, + offer_flow=offer_flow, + ) + + __all__ = [ "EBAY_OAUTH_TOKENS_RELPATH", "WALLAPOP_COOKIES_RELPATH", diff --git a/src/salvager/orchestration/offer_orchestrator.py b/src/salvager/orchestration/offer_orchestrator.py new file mode 100644 index 0000000..64f3220 --- /dev/null +++ b/src/salvager/orchestration/offer_orchestrator.py @@ -0,0 +1,557 @@ +"""Offer orchestrator (wallapop-offer-flow, FR58-FR65). + +The single end-to-end flow the operator's Ofertar tap drives: + + snapshot lookup + β†’ re-run offer pre-flight (state may have changed since the alert) + β†’ reconciliation re-fetch by internal listing id (404 β†’ listing_gone) + β†’ recompute the offer amount from the fresh listing (drift β†’ abort) + β†’ execute_offer via the OfferSession adapter + β†’ append the offers audit row + β†’ lockout outcome (safety aborts never count; success resets) + β†’ Telegram dispatch (sent / failure / aborted) + β†’ keyboard restore on EVERY path (the v0.4.3 lesson) + +Sibling of :class:`BuyOrchestrator` with the same composure discipline: +ports and typed collaborators only, no SDK imports, and a typed +:class:`OfferOutcome` union so the callback handler branches on ``kind``. + +No money moves here β€” an offer is a negotiation message; the purchase +(if the seller accepts) stays behind the Comprar path, manually, in v1. +""" + +from __future__ import annotations + +import contextlib +import uuid as uuid_module +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.domain.alert import ( + CallbackEvent, + EventName, + InlineButton, + _phase1_button_row, + _phase2_button_row, + negotiable_button_row, + offer_button_row, + offer_sent_badge_row, + render_offer_failure, + render_offer_sent, +) +from salvager.domain.errors import OfferFailureReason +from salvager.domain.offer_audit import OfferAttemptRecord +from salvager.domain.pricing import offer_item_price_eur +from salvager.interfaces.offer_session import OfferSendFailure, OfferSession, OfferSuccess +from salvager.interfaces.page_fetcher import PageFetcher +from salvager.interfaces.store import Store +from salvager.interfaces.telegram_surface import TelegramSurface +from salvager.observability.logging import get_logger +from salvager.orchestration.degradation_reporter import Reporter +from salvager.orchestration.offer_preflight import OfferPreflight + +if TYPE_CHECKING: + from salvager.domain.alert import AlertSnapshot + from salvager.domain.wishlist import WishlistEntry + +EntryKey = tuple[str, str, str] +WishlistLoader = Callable[[EntryKey], "WishlistEntry | None"] + +#: The reason persisted in ``offer_state.disabled_reason`` when the +#: consecutive-failure threshold engages the lockout. +OFFER_LOCKOUT_REASON: Final[str] = "offer_lockout_threshold" + +#: Safety aborts that never increment the lockout counter (spec: the +#: offer path is healthy β€” the abort itself proves the guardrails work). +_NO_LOCKOUT_REASONS: Final[frozenset[OfferFailureReason]] = frozenset( + { + OfferFailureReason.listing_gone, + OfferFailureReason.reconciliation_tripped, + OfferFailureReason.duplicate_offer, + OfferFailureReason.lockout_engaged, + OfferFailureReason.daily_limit_reached, + } +) + +#: Preflight reason id β†’ the closed render variant. The raw reason is +#: preserved in the outcome ctx for audits. +_PREFLIGHT_REASON_TO_FAILURE: Final[dict[str, OfferFailureReason]] = { + "offer_disabled_for_entry": OfferFailureReason.ui_check_failed, + "not_wallapop": OfferFailureReason.ui_check_failed, + "listing_refurbished": OfferFailureReason.offer_unavailable, + "listing_reserved": OfferFailureReason.reconciliation_tripped, + "offer_kill_switch": OfferFailureReason.lockout_engaged, + "offer_lockout_engaged": OfferFailureReason.lockout_engaged, + "offer_daily_limit_reached": OfferFailureReason.daily_limit_reached, + "duplicate_offer": OfferFailureReason.duplicate_offer, +} + + +# ───────────────────────────────────────────────────────────────────────── +# OfferOutcome β€” discriminated union returned to the callback handler +# ───────────────────────────────────────────────────────────────────────── + + +class OfferOutcomeSuccess(BaseModel): + """The offer was verifiably sent and audited.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["success"] = "success" + offered_eur: Decimal + audit_id: int + + +class OfferOutcomeFailure(BaseModel): + """The send was attempted and failed.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["failure"] = "failure" + reason: OfferFailureReason + ctx: dict[str, Any] = Field(default_factory=dict) + + +class OfferOutcomeAborted(BaseModel): + """A guardrail refused before the marketplace was touched. + + ``reason`` is the raw preflight/reconciliation identifier; + ``rendered_as`` is the closed variant the operator-facing alert used. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: Literal["aborted"] = "aborted" + reason: str + rendered_as: OfferFailureReason | None = None + ctx: dict[str, Any] = Field(default_factory=dict) + + +OfferOutcome = Annotated[ + OfferOutcomeSuccess | OfferOutcomeFailure | OfferOutcomeAborted, + Field(discriminator="kind"), +] + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +# ───────────────────────────────────────────────────────────────────────── +# OfferOrchestrator +# ───────────────────────────────────────────────────────────────────────── + + +@dataclass +class OfferOrchestrator: + """Composes the offer collaborators into ``execute_offer_from_callback``. + + ``fetcher`` is the Wallapop :class:`PageFetcher` β€” the reconciliation + re-fetch goes through ``fetch_listing`` (internal listing id; slugs + 404, the v0.4.2 lesson). ``tolerance_eur``/``tolerance_pct`` reuse + the Phase 2 reconciliation tolerances for displayed-vs-recomputed + amount drift. + """ + + preflight: OfferPreflight + fetcher: PageFetcher + offer_session: OfferSession + offer_writer: OfferAuditWriter + telegram_surface: TelegramSurface + store: Store + reporter: Reporter + wishlist_loader: WishlistLoader + lockout_threshold: int + tolerance_eur: Decimal = Decimal("1.00") + tolerance_pct: Decimal = Decimal("5") + assumed_shipping_eur: Decimal = Decimal("3.50") + clock: Callable[[], datetime] = _utc_now + + def __post_init__(self) -> None: + self._log = get_logger("orchestration.offer_orchestrator") + + async def execute_offer_from_callback(self, callback_event: CallbackEvent) -> OfferOutcome: + """Drive the full offer pipeline for one Ofertar tap. + + A single ``try/except`` wraps everything downstream of the + snapshot lookup; an unexpected exception emits + ``EventName.offer_orchestrator_error``, counts a lockout failure + and returns ``OfferOutcomeFailure(marketplace_error)``. The + keyboard is restored on EVERY path. + """ + alert_id = _parse_alert_id(callback_event.callback_data) + if alert_id is None: + self._log.warning( + "offer_orchestrator_callback_data_unparseable", + extra={"callback_data": callback_event.callback_data}, + ) + return OfferOutcomeAborted(reason="callback_data_unparseable") + + snapshot = await self.store.get_alert_snapshot_by_alert_id(alert_id) + if snapshot is None: + self._log.warning( + "offer_orchestrator_snapshot_missing", extra={"alert_id": str(alert_id)} + ) + aborted = OfferOutcomeAborted(reason="snapshot_not_found") + await self._restore_keyboard(callback_event, None, alert_id, aborted) + return aborted + + try: + outcome: OfferOutcome = await self._run(snapshot, callback_event) + except Exception as exc: + outcome = await self._handle_unexpected(snapshot, exc) + await self._restore_keyboard(callback_event, snapshot, alert_id, outcome) + return outcome + + # ───────────────────────────────────────────────────────────────── + # Steps + # ───────────────────────────────────────────────────────────────── + + async def _run(self, snapshot: AlertSnapshot, callback_event: CallbackEvent) -> OfferOutcome: + _ = callback_event # the tap itself is audited by the callbacks table + entry = self.wishlist_loader(snapshot.entry_key) + if entry is None: + ctx: dict[str, Any] = {"detail": "entry_not_in_wishlist"} + await self._dispatch_failure(snapshot, OfferFailureReason.ui_check_failed, ctx) + return OfferOutcomeAborted( + reason="entry_not_in_wishlist", + rendered_as=OfferFailureReason.ui_check_failed, + ctx=ctx, + ) + + check = await self.preflight.check(entry, snapshot.listing) + if not check.eligible: + reason = check.reason or "unknown" + mapped = _PREFLIGHT_REASON_TO_FAILURE.get(reason, OfferFailureReason.ui_check_failed) + ctx = await self._preflight_ctx(reason) + await self._dispatch_failure(snapshot, mapped, ctx) + return OfferOutcomeAborted(reason=reason, rendered_as=mapped, ctx=ctx) + + # ── Reconciliation re-fetch by internal id ─────────────────── + try: + fresh = await self.fetcher.fetch_listing(snapshot.listing) + except Exception as exc: + if getattr(exc, "status_code", None) == 404: + self._log.info( + "offer_orchestrator_listing_gone", + extra={"listing_id": snapshot.listing.listing_id}, + ) + gone_ctx: dict[str, Any] = {"detail": "listing returned 404 on re-fetch"} + await self._dispatch_failure(snapshot, OfferFailureReason.listing_gone, gone_ctx) + return OfferOutcomeAborted( + reason="listing_gone", + rendered_as=OfferFailureReason.listing_gone, + ctx=gone_ctx, + ) + self._log.error( + "offer_orchestrator_refetch_failed", + extra={"error_class": exc.__class__.__name__, "error": str(exc)[:200]}, + ) + fail_ctx: dict[str, Any] = { + "error_class": exc.__class__.__name__, + "detail": str(exc), + } + return await self._fail(snapshot, OfferFailureReason.marketplace_error, fail_ctx) + + target = entry.offer.target_total_eur or _entry_ceiling(entry) + displayed = offer_item_price_eur( + snapshot.listing, + target_total_eur=target, + assumed_shipping_eur=self.assumed_shipping_eur, + ) + recon_ctx: dict[str, Any] = {"displayed_offer": displayed} + + if fresh.is_reserved: + recon_ctx["detail"] = "listing is now reserved" + await self._dispatch_failure( + snapshot, OfferFailureReason.reconciliation_tripped, recon_ctx + ) + return OfferOutcomeAborted( + reason="listing_reserved_on_refetch", + rendered_as=OfferFailureReason.reconciliation_tripped, + ctx=recon_ctx, + ) + + recomputed = offer_item_price_eur( + fresh, + target_total_eur=target, + assumed_shipping_eur=self.assumed_shipping_eur, + ) + if recomputed is None or ( + displayed is not None and not self._within_tolerance(displayed, recomputed) + ): + recon_ctx["recomputed_offer"] = recomputed + recon_ctx["fresh_price_eur"] = str(fresh.price_eur) + await self._dispatch_failure( + snapshot, OfferFailureReason.reconciliation_tripped, recon_ctx + ) + return OfferOutcomeAborted( + reason="offer_amount_drifted", + rendered_as=OfferFailureReason.reconciliation_tripped, + ctx=recon_ctx, + ) + + # ── Execute the send ───────────────────────────────────────── + result = await self.offer_session.execute_offer(fresh, recomputed) + + if isinstance(result, OfferSendFailure): + await self._record_attempt( + snapshot, + fresh, + recomputed, + outcome="failure", + failure_reason=result.reason, + platform_remaining=result.ctx.get("platform_remaining"), + ) + return await self._fail(snapshot, result.reason, dict(result.ctx)) + + assert isinstance(result, OfferSuccess) # type-narrowing + audit_id = await self._record_attempt( + snapshot, + fresh, + recomputed, + outcome="success", + screenshot_path=result.screenshot_url, + platform_remaining=result.platform_remaining, + ) + if audit_id == 0: + # The offer IS sent but the dedupe row is missing β€” the operator + # must know the Ofertar button could reappear for this listing. + await self._report( + EventName.offer_orchestrator_error, + { + "error_class": "offer_audit_write_failed", + "alert_id": str(snapshot.alert_id), + "detail": "oferta ENVIADA pero sin fila de auditorΓ­a β€” dedupe no registrado", + }, + ) + with contextlib.suppress(Exception): + await self.offer_writer.reset_failure_counter() + await self._dispatch_sent(snapshot, recomputed, audit_id, result) + return OfferOutcomeSuccess(offered_eur=recomputed, audit_id=audit_id) + + def _within_tolerance(self, displayed: Decimal, recomputed: Decimal) -> bool: + tolerance = max(self.tolerance_eur, displayed * self.tolerance_pct / 100) + return abs(displayed - recomputed) <= tolerance + + async def _preflight_ctx(self, reason: str) -> dict[str, Any]: + ctx: dict[str, Any] = {"detail": reason} + if reason == "offer_lockout_engaged": + state = await self.offer_writer.read_state() + ctx["consecutive_failures"] = state.consecutive_failures + ctx["threshold"] = self.lockout_threshold + if reason == "offer_daily_limit_reached": + ctx["limit_source"] = "propio" + return ctx + + async def _fail( + self, + snapshot: AlertSnapshot, + reason: OfferFailureReason, + ctx: dict[str, Any], + ) -> OfferOutcomeFailure: + """Common failure path: lockout accounting + operator alert.""" + if reason not in _NO_LOCKOUT_REASONS: + failures = await self.offer_writer.increment_failure_counter() + if failures >= self.lockout_threshold: + await self.offer_writer.set_global_disable(OFFER_LOCKOUT_REASON) + await self._report( + EventName.offer_lockout_engaged, + { + "consecutive_failures": failures, + "threshold": self.lockout_threshold, + "last_affected_entry": snapshot.entry_display_name, + }, + ) + await self._dispatch_failure(snapshot, reason, ctx) + return OfferOutcomeFailure(reason=reason, ctx=ctx) + + async def _record_attempt( + self, + snapshot: AlertSnapshot, + fresh: Any, + offered: Decimal, + *, + outcome: Literal["success", "failure", "aborted"], + failure_reason: OfferFailureReason | None = None, + screenshot_path: str | None = None, + platform_remaining: int | None = None, + ) -> int: + """Append the offers audit row. Best-effort on failure outcomes; + a success MUST land (the dedupe depends on it) so it propagates.""" + record = OfferAttemptRecord( + alert_id=snapshot.alert_id, + listing_id=snapshot.listing.listing_id, + marketplace=snapshot.listing.marketplace, + entry_key=snapshot.entry_key, + offered_eur=offered, + asking_eur=fresh.price_eur, + outcome=outcome, + failure_reason=failure_reason, + screenshot_path=screenshot_path, + platform_remaining=platform_remaining, + attempted_at=self.clock(), + ) + try: + return await self.offer_writer.record_offer_attempt(record) + except Exception as exc: + # NEVER let an audit-write failure change the outcome: after a + # verified send, raising here would report "No se ha enviado + # ninguna oferta" (false), count a lockout failure, and leave the + # dedupe unrecorded β€” re-arming a duplicate send (CodeRabbit, + # PR #55). Log loudly; the success path escalates via Telegram. + self._log.error( + "offer_orchestrator_audit_failed", + extra={ + "outcome": outcome, + "error_class": exc.__class__.__name__, + "detail": str(exc), + }, + ) + return 0 + + async def _handle_unexpected(self, snapshot: AlertSnapshot, exc: Exception) -> OfferOutcome: + self._log.error( + "offer_orchestrator_unexpected_error", + extra={"error_class": exc.__class__.__name__, "detail": str(exc)}, + ) + await self._report( + EventName.offer_orchestrator_error, + {"error_class": exc.__class__.__name__, "alert_id": str(snapshot.alert_id)}, + ) + ctx: dict[str, Any] = {"error_class": exc.__class__.__name__, "detail": str(exc)} + try: + return await self._fail(snapshot, OfferFailureReason.marketplace_error, ctx) + except Exception: + self._log.error("offer_orchestrator_failure_path_failed_too") + return OfferOutcomeFailure(reason=OfferFailureReason.marketplace_error, ctx=ctx) + + async def _report(self, event: EventName, ctx: dict[str, Any]) -> None: + try: + await self.reporter.report("warn", event, ctx=ctx) + except Exception: + self._log.error("offer_orchestrator_reporter_failed", extra={"event": event.value}) + + # ───────────────────────────────────────────────────────────────── + # Telegram dispatch + keyboard restore + # ───────────────────────────────────────────────────────────────── + + async def _dispatch_sent( + self, + snapshot: AlertSnapshot, + offered: Decimal, + audit_id: int, + result: OfferSuccess, + ) -> None: + rendered = render_offer_sent( + entry_display_name=snapshot.entry_display_name, + offered_eur=offered, + audit_id=audit_id, + screenshot_path=result.screenshot_url, + platform_remaining=result.platform_remaining, + ) + await self._send("sent", rendered, snapshot) + + async def _dispatch_failure( + self, + snapshot: AlertSnapshot, + reason: OfferFailureReason, + ctx: dict[str, Any], + ) -> None: + rendered = render_offer_failure( + reason, entry_display_name=snapshot.entry_display_name, ctx=ctx + ) + await self._send("failure", rendered, snapshot) + + async def _send(self, kind: str, rendered: Any, snapshot: AlertSnapshot) -> None: + try: + await self.telegram_surface.send(rendered) + except Exception as exc: + self._log.error( + "offer_orchestrator_telegram_dispatch_failed", + extra={ + "kind": kind, + "alert_id": str(snapshot.alert_id), + "error_class": exc.__class__.__name__, + }, + ) + + async def _restore_keyboard( + self, + callback_event: CallbackEvent, + snapshot: AlertSnapshot | None, + alert_id: UUID, + outcome: OfferOutcome, + ) -> None: + """Repaint the tapped message's keyboard to match the outcome. + + Success β†’ the terminal ``πŸ’° Oferta enviada`` badge (plus the + phase's own row on non-negotiable alerts, so Comprar survives an + offer). Failure/abort β†’ the original rows so the operator can + retry (the preflight re-gates every tap). Best-effort. + """ + try: + keyboard = _outcome_keyboard(snapshot, alert_id, outcome) + await self.telegram_surface.edit_keyboard(callback_event.message_id, keyboard) + except Exception as exc: + self._log.warning( + "offer_keyboard_restore_failed", + extra={ + "alert_id": str(alert_id), + "telegram_message_id": callback_event.message_id, + "error_class": exc.__class__.__name__, + }, + ) + + +def _entry_ceiling(entry: WishlistEntry) -> Decimal: + ceiling = entry.max_price_solo or entry.max_price_in_device + assert ceiling is not None, "wishlist validation guarantees a ceiling" + return ceiling + + +def _outcome_keyboard( + snapshot: AlertSnapshot | None, + alert_id: UUID, + outcome: OfferOutcome, +) -> list[list[InlineButton]]: + """The keyboard the message deserves after an offer outcome.""" + aid = str(alert_id) + phase = snapshot.phase if snapshot is not None else "phase1" + if isinstance(outcome, OfferOutcomeSuccess): + if phase == "negotiable": + return [offer_sent_badge_row(aid)] + base = _phase2_button_row(aid) if phase == "phase2" else _phase1_button_row(aid) + return [base, offer_sent_badge_row(aid)] + if phase == "negotiable": + return [negotiable_button_row(aid)] + base = _phase2_button_row(aid) if phase == "phase2" else _phase1_button_row(aid) + return [base, offer_button_row(aid)] + + +def _parse_alert_id(callback_data: str) -> UUID | None: + parts = callback_data.split(":") + if len(parts) != 3: + return None + try: + return uuid_module.UUID(parts[2]) + except ValueError: + return None + + +__all__ = [ + "OFFER_LOCKOUT_REASON", + "OfferOrchestrator", + "OfferOutcome", + "OfferOutcomeAborted", + "OfferOutcomeFailure", + "OfferOutcomeSuccess", + "WishlistLoader", +] diff --git a/src/salvager/orchestration/offer_preflight.py b/src/salvager/orchestration/offer_preflight.py new file mode 100644 index 0000000..03cdf4b --- /dev/null +++ b/src/salvager/orchestration/offer_preflight.py @@ -0,0 +1,79 @@ +"""Offer pre-flight gate (wallapop-offer-flow, FR58-FR65). + +The offer sibling of :class:`Phase2Preflight`: consulted when an +operator's Ofertar tap arrives, re-checking everything that may have +changed since the alert was dispatched. Checks are ordered cheap β†’ +expensive: per-entry/listing conditions short-circuit before the gate +touches the database. + +The gate deliberately does NOT check the offer amount β€” the orchestrator +recomputes it from the reconciled listing right after this gate passes +(the amount check needs the fresh listing anyway). +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.domain.listing import Listing +from salvager.domain.wishlist import WishlistEntry + + +@dataclass(frozen=True) +class OfferEligibilityResult: + """Outcome of one offer pre-flight evaluation. + + ``reason`` is ``None`` exactly when ``eligible`` is True; failing + checks yield stable string IDs for logs and the outcome ctx. + """ + + eligible: bool + reason: str | None = None + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +@dataclass +class OfferPreflight: + """The offer pre-flight gate. Constructed once per daemon.""" + + offer_writer: OfferAuditWriter + kill_switch_global: bool + lockout_threshold: int + daily_limit: int + clock: Callable[[], datetime] = field(default=_utc_now) + + async def check(self, entry: WishlistEntry, listing: Listing) -> OfferEligibilityResult: + # ── Per-entry / per-listing checks (no DB round-trip) ──────── + if not entry.offer.enabled: + return OfferEligibilityResult(False, "offer_disabled_for_entry") + if listing.marketplace != "wallapop": + return OfferEligibilityResult(False, "not_wallapop") + if listing.is_refurbished: + return OfferEligibilityResult(False, "listing_refurbished") + if listing.is_reserved: + return OfferEligibilityResult(False, "listing_reserved") + if self.kill_switch_global: + return OfferEligibilityResult(False, "offer_kill_switch") + + # ── Global offer state checks (DB reads) ───────────────────── + state = await self.offer_writer.read_state() + if state.globally_disabled: + return OfferEligibilityResult(False, "offer_lockout_engaged") + if state.consecutive_failures >= self.lockout_threshold: + return OfferEligibilityResult(False, "offer_lockout_engaged") + recent = await self.offer_writer.count_recent_successes(now=self.clock()) + if recent >= self.daily_limit: + return OfferEligibilityResult(False, "offer_daily_limit_reached") + if await self.offer_writer.has_successful_offer(listing.marketplace, listing.listing_id): + return OfferEligibilityResult(False, "duplicate_offer") + + return OfferEligibilityResult(eligible=True) + + +__all__ = ["OfferEligibilityResult", "OfferPreflight"] diff --git a/src/salvager/orchestration/poll_loop.py b/src/salvager/orchestration/poll_loop.py index a77d9b0..dd1adb2 100644 --- a/src/salvager/orchestration/poll_loop.py +++ b/src/salvager/orchestration/poll_loop.py @@ -42,7 +42,7 @@ import asyncio import time import uuid as uuid_module -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from decimal import Decimal @@ -52,6 +52,7 @@ from salvager.domain.alert import ( AlertSnapshot, Phase, + render_negotiable_listing_alert, render_phase1_listing_alert, render_phase2_listing_alert, ) @@ -64,6 +65,7 @@ DEFAULT_ASSUMED_SHIPPING_EUR, buyer_cost, buyer_total_eur, + offer_item_price_eur, ) from salvager.domain.wishlist import Wishlist, WishlistEntry from salvager.interfaces.listing_evaluator import ListingEvaluator @@ -127,6 +129,8 @@ async def run_poll_cycle( max_concurrent_evaluations: int = DEFAULT_MAX_CONCURRENT_EVALUATIONS, clock: Callable[[], datetime] = _utc_now, new_alert_id: Callable[[], UUID] = uuid_module.uuid4, + offer_band_pct: Decimal | None = None, + has_offered: Callable[[str, str], Awaitable[bool]] | None = None, ) -> PollCycleSummary: """Run one poll cycle for ``marketplace`` against every wishlist entry. @@ -231,6 +235,7 @@ async def run_poll_cycle( assumed_import_charges_eur=import_buffer, now=now, log=log, + has_offered=has_offered, ) candidates = await _filter_unseen(listings, entry, store) @@ -270,7 +275,7 @@ async def run_poll_cycle( # before the LLM eval so an over-ceiling listing never costs an # evaluation or reaches the alert path (shipping-aware-pricing, # ebay-import-charges-pricing). - buyable = await _filter_over_ceiling( + buyable, negotiable = await _filter_over_ceiling( buyable, entry, store, @@ -279,12 +284,19 @@ async def run_poll_cycle( assumed_import_charges_eur=import_buffer, marketplace=marketplace, log=log, + offer_band_pct=offer_band_pct, ) - if not buyable: + if not buyable and not negotiable: continue - evaluations = await _evaluate_concurrently(buyable, entry, evaluator, semaphore, log) + # Negotiable-band listings ride the same evaluation + confidence + # gate as ordinary candidates (junk in the band must not alert just + # because it is cheap-ish); the tag only changes the renderer. + negotiable_ids = {listing.listing_id for listing in negotiable} + evaluations = await _evaluate_concurrently( + buyable + negotiable, entry, evaluator, semaphore, log + ) for listing, evaluation in evaluations: if evaluation is None: @@ -305,6 +317,8 @@ async def run_poll_cycle( new_alert_id=new_alert_id, clock=clock, log=log, + negotiable=listing.listing_id in negotiable_ids, + has_offered=has_offered, ) if handled: summary.alerts_sent += 1 @@ -402,8 +416,9 @@ async def _filter_over_ceiling( assumed_import_charges_eur: Decimal, marketplace: Marketplace, log: object, -) -> list[Listing]: - """Drop listings whose delivered buyer total exceeds the entry ceiling. + offer_band_pct: Decimal | None = None, +) -> tuple[list[Listing], list[Listing]]: + """Split listings into ``(within_ceiling, negotiable)``; drop the rest. The search pre-filter caps the *item* price; this is the authoritative gate on the total the buyer actually pays (item + shipping + Wallapop @@ -411,11 +426,19 @@ async def _filter_over_ceiling( ceiling but over it once shipping/fees are added is dropped here, before the LLM eval, and recorded as seen + counted as dropped β€” mirroring the below-threshold drop path. + + Single carve-out (wallapop-offer-flow): a Wallapop listing on an + offer-enabled entry whose buyer total is over the ceiling but within + ``ceiling x (1 + offer_band_pct)`` β€” AND for which a valid offer amount + exists (the platform's -30 % floor can rule one out) β€” is kept in the + ``negotiable`` bucket instead of dropped. eBay and offer-disabled + entries never populate it. """ ceiling = _entry_ceiling(entry) if ceiling is None: - return buyable + return buyable, [] within: list[Listing] = [] + negotiable: list[Listing] = [] for listing in buyable: total = buyer_total_eur( listing, @@ -425,6 +448,31 @@ async def _filter_over_ceiling( if total <= ceiling: within.append(listing) continue + if ( + offer_band_pct is not None + and entry.offer.enabled + and listing.marketplace == "wallapop" + and not listing.is_refurbished + and total <= ceiling * (1 + offer_band_pct) + and offer_item_price_eur( + listing, + target_total_eur=entry.offer.target_total_eur or ceiling, + assumed_shipping_eur=assumed_shipping_eur, + ) + is not None + ): + negotiable.append(listing) + log.info( # type: ignore[attr-defined] + "listing_kept_negotiable_band", + extra={ + "marketplace": marketplace, + "entry_display_name": entry.display_name, + "listing_id": listing.listing_id, + "buyer_total_eur": str(total), + "ceiling_eur": str(ceiling), + }, + ) + continue summary.dropped_count += 1 log.info( # type: ignore[attr-defined] "listing_dropped_over_ceiling", @@ -448,7 +496,7 @@ async def _filter_over_ceiling( "error_class": exc.__class__.__name__, }, ) - return within + return within, negotiable async def _filter_unseen( @@ -597,6 +645,8 @@ async def _dispatch_alert( new_alert_id: Callable[[], UUID], clock: Callable[[], datetime], log: object, + negotiable: bool = False, + has_offered: Callable[[str, str], Awaitable[bool]] | None = None, ) -> bool: """Build snapshot β†’ render β†’ send β†’ persist. Return True on full success. @@ -611,13 +661,20 @@ async def _dispatch_alert( duplicate-eval on the next pass, but the cache (Story 3.10) absorbs that. """ - phase, phase2_max_price_eur = await _select_phase( - entry=entry, - listing=listing, - evaluation=evaluation, - phase2_preflight=phase2_preflight, - log=log, - ) + phase: Phase + phase2_max_price_eur: Decimal | None + if negotiable: + # Over ceiling by definition β€” the Phase 2 preflight would reject + # it; the negotiable surface never carries a Comprar row. + phase, phase2_max_price_eur = "negotiable", None + else: + phase, phase2_max_price_eur = await _select_phase( + entry=entry, + listing=listing, + evaluation=evaluation, + phase2_preflight=phase2_preflight, + log=log, + ) snapshot = AlertSnapshot( alert_id=new_alert_id(), @@ -650,14 +707,65 @@ async def _dispatch_alert( assumed_import_charges_eur=import_buffer, ) - try: - rendered = ( - render_phase2_listing_alert( - snapshot, phase2_max_price_eur, comp_summary=comp_summary, buyer_cost=cost + # Offer surface (wallapop-offer-flow): the computed amount + target, + # rendered when the entry opted in, an amount exists (only possible when + # the target sits below the delivered total β€” routine on the negotiable + # band, and on under-ceiling alerts only with offer.target_total_eur), + # and no successful offer was already sent for the listing. + offer_eur: Decimal | None = None + offer_target: Decimal | None = None + if entry.offer.enabled and listing.marketplace == "wallapop" and not listing.is_refurbished: + ceiling = _entry_ceiling(entry) + if ceiling is not None: + target = entry.offer.target_total_eur or ceiling + amount = offer_item_price_eur( + listing, target_total_eur=target, assumed_shipping_eur=buffer ) - if phase == "phase2" and phase2_max_price_eur is not None - else render_phase1_listing_alert(snapshot, comp_summary=comp_summary, buyer_cost=cost) + if amount is not None: + already = ( + await has_offered(listing.marketplace, listing.listing_id) + if has_offered is not None + else False + ) + if not already: + offer_eur, offer_target = amount, target + + if phase == "negotiable" and (offer_eur is None or offer_target is None): + # The band filter only tags listings with a computable offer, but the + # dedupe can still void it (an offer already went out). An + # over-ceiling alert without an offer surface is noise β€” skip it. + log.info( # type: ignore[attr-defined] + "negotiable_alert_skipped_no_offer", + extra={"listing_id": listing.listing_id, "entry_display_name": entry.display_name}, ) + return False + + try: + if phase == "negotiable" and offer_eur is not None and offer_target is not None: + rendered = render_negotiable_listing_alert( + snapshot, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + comp_summary=comp_summary, + buyer_cost=cost, + ) + elif phase == "phase2" and phase2_max_price_eur is not None: + rendered = render_phase2_listing_alert( + snapshot, + phase2_max_price_eur, + comp_summary=comp_summary, + buyer_cost=cost, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + ) + else: + rendered = render_phase1_listing_alert( + snapshot, + comp_summary=comp_summary, + buyer_cost=cost, + offer_eur=offer_eur, + offer_target_total_eur=offer_target, + ) message_id = await telegram.send(rendered) except Exception as exc: log.error( # type: ignore[attr-defined] diff --git a/src/salvager/templates/config.example.yaml b/src/salvager/templates/config.example.yaml index 36e523b..f9563a8 100644 --- a/src/salvager/templates/config.example.yaml +++ b/src/salvager/templates/config.example.yaml @@ -102,6 +102,26 @@ alerts: # message (Telegram edits are silent; a big drop deserves a ping). price_drop_ping_pct: 10 +# ───────────────────────────────────────────────────────────────────────── +# offer β€” Wallapop "hacer oferta" flow (wallapop-offer-flow) +# ───────────────────────────────────────────────────────────────────────── +offer: + # Wallapop listings on offer-enabled entries with a buyer total over the + # ceiling but within ceiling Γ— (1 + band_pct) produce a negotiable alert + # (πŸ’° Ofertar, no Comprar) instead of being filtered. Wallapop's own + # -30 % offer floor makes bands much past 0.40 useless. + band_pct: 0.20 + # Self-imposed budget of successful offer sends per rolling 24 h. + # Wallapop allows 10 offers per calendar day per account; keeping this + # under it leaves headroom for your own manual offers. + daily_limit: 5 + # Consecutive execution failures that disable the offer path until + # `salvager offer enable` clears it. Independent from the Phase 2 + # circuit breaker β€” offer failures never block real buys. + lockout_threshold: 3 + # Master off-switch for offer sending, regardless of per-entry settings. + kill_switch_global: false + # ───────────────────────────────────────────────────────────────────────── # telegram β€” delivery semantics (NFR-I6) # ───────────────────────────────────────────────────────────────────────── diff --git a/src/salvager/templates/wishlist.example.yaml b/src/salvager/templates/wishlist.example.yaml index ef0f4b1..3eabb00 100644 --- a/src/salvager/templates/wishlist.example.yaml +++ b/src/salvager/templates/wishlist.example.yaml @@ -38,6 +38,12 @@ # container_keywords: list[str] Wrapper-listing search terms (NAS, mini-PC) # phase2.enabled: bool Default false; toggle via CLI never YAML # phase2.max_price_eur: decimal Per-entry Phase 2 hard ceiling (FR26) +# offer.enabled: bool Default false; toggle via CLI never YAML. +# Enables the Wallapop πŸ’° Ofertar surface +# (offer button + negotiable-band alerts). +# offer.target_total_eur: decimal Aim offers at a delivered total BELOW the +# ceiling ("I'd accept 80 € but want to pay +# 70 €"). Unset = target the ceiling. entries: diff --git a/tests/e2e/test_poll_loop_offer_band.py b/tests/e2e/test_poll_loop_offer_band.py new file mode 100644 index 0000000..b2eaa67 --- /dev/null +++ b/tests/e2e/test_poll_loop_offer_band.py @@ -0,0 +1,237 @@ +"""Negotiable-band poll-cycle tests (wallapop-offer-flow). + +The over-ceiling alert gate's single carve-out: Wallapop listings on +offer-enabled entries with a buyer total inside ``ceiling x (1 + +band_pct)`` become negotiable alerts (πŸ’° Ofertar, no Comprar); everything +else β€” over-band, offer-disabled, eBay β€” filters exactly as before, and +the confidence gate still applies inside the band. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from pathlib import Path + +import pytest + +from salvager.adapters.sqlite_store import MigrationRunner, SqliteStore, open_connection +from salvager.adapters.sqlite_store.migrations import db_path_under +from salvager.domain.alert import BUTTON_LABELS, InlineButton, RenderedAlert +from salvager.domain.evaluation import ListingEvaluation +from salvager.domain.listing import Listing, SearchQuery +from salvager.domain.wishlist import Wishlist, WishlistEntry +from salvager.interfaces.listing_evaluator import ListingEvaluator +from salvager.interfaces.page_fetcher import PageFetcher +from salvager.interfaces.telegram_surface import CallbackHandler, TelegramSurface +from salvager.orchestration.poll_loop import run_poll_cycle + +_T0 = datetime(2026, 7, 22, 12, 0, 0, tzinfo=UTC) +_BAND = Decimal("0.20") + + +def _entry(*, offer_enabled: bool = True) -> WishlistEntry: + return WishlistEntry.model_validate( + { + "manufacturer": "Corsair", + "model": "Vengeance LPX 16GB", + "ref": "CMK16GX4M2D3000C16", + "type": "ram", + "keywords": ["corsair vengeance lpx 16gb"], + "max_price_solo": Decimal("70.00"), + "confidence_threshold": "medium", + "offer": {"enabled": offer_enabled}, + } + ) + + +def _listing( + listing_id: str, + *, + price_eur: Decimal, + marketplace: str = "wallapop", +) -> Listing: + return Listing( + listing_id=listing_id, + marketplace=marketplace, # type: ignore[arg-type] + url=f"https://es.wallapop.com/item/{listing_id}", + title="Corsair Vengeance LPX 16GB", + description="ok", + price_eur=price_eur, + location="Madrid", + photo_urls=["https://cdn/photo.jpg"], + fetched_at=_T0, + ) + + +def _evaluation(listing_id: str, *, confidence: str = "high") -> ListingEvaluation: + return ListingEvaluation( + listing_id=listing_id, + entry_key=("Corsair", "Vengeance LPX 16GB", "CMK16GX4M2D3000C16"), + confidence=confidence, # type: ignore[arg-type] + one_line_take="Match.", + is_container=False, + evaluated_at=_T0, + ) + + +class _FixtureFetcher(PageFetcher): + def __init__(self, listings: list[Listing]) -> None: + self._listings = listings + + async def search(self, query: SearchQuery) -> list[Listing]: + return list(self._listings) + + async def fetch(self, listing_url: str) -> Listing: # pragma: no cover + raise AssertionError("not exercised") + + +class _ScriptedEvaluator(ListingEvaluator): + def __init__(self, by_listing: dict[str, ListingEvaluation]) -> None: + self._by = by_listing + self.calls: list[str] = [] + + async def evaluate(self, listing: Listing, entry: WishlistEntry) -> ListingEvaluation: + self.calls.append(listing.listing_id) + return self._by[listing.listing_id] + + +class _RecordingTelegram(TelegramSurface): + def __init__(self) -> None: + self.sends: list[RenderedAlert] = [] + self._next_message_id = 5000 + + async def send(self, rendered: RenderedAlert, *, reply_to_message_id: int | None = None) -> int: + self.sends.append(rendered) + self._next_message_id += 1 + return self._next_message_id + + async def edit_alert( + self, message_id: int, rendered: RenderedAlert, *, has_photo: bool + ) -> None: + return None + + async def edit_keyboard( + self, message_id: int, keyboard: list[list[InlineButton]] | None + ) -> None: # pragma: no cover + return None + + async def listen_callbacks(self, handler: CallbackHandler) -> None: # pragma: no cover + _ = handler + + +@pytest.fixture +def store(tmp_path: Path) -> SqliteStore: + db_path = db_path_under(tmp_path) + connection = open_connection(db_path) + MigrationRunner().run(connection) + connection.close() + return SqliteStore(db_path) + + +async def _cycle( + store: SqliteStore, + listings: list[Listing], + evals: dict[str, ListingEvaluation], + *, + entry: WishlistEntry | None = None, + band: Decimal | None = _BAND, +) -> tuple[_RecordingTelegram, _ScriptedEvaluator]: + telegram = _RecordingTelegram() + evaluator = _ScriptedEvaluator(evals) + await run_poll_cycle( + "wallapop", + wishlist=Wishlist(entries=[entry if entry is not None else _entry()]), + fetcher=_FixtureFetcher(listings), + evaluator=evaluator, + store=store, + telegram=telegram, + offer_band_pct=band, + ) + return telegram, evaluator + + +def _keyboard_labels(rendered: RenderedAlert) -> list[str]: + assert rendered.inline_keyboard is not None + return [button.text for row in rendered.inline_keyboard for button in row] + + +async def test_in_band_listing_dispatches_negotiable_alert(store: SqliteStore) -> None: + # 70 € asking against a 70 € ceiling: buyer total ~79.44 € (est. shipping + # + ProtecciΓ³n) is over ceiling but under 84 € (ceiling x 1.2). + listing = _listing("band1", price_eur=Decimal("70.00")) + telegram, _ = await _cycle(store, [listing], {"band1": _evaluation("band1")}) + + assert len(telegram.sends) == 1 + rendered = telegram.sends[0] + assert rendered.text.startswith("πŸ’°") + assert "Oferta:" in rendered.text + labels = _keyboard_labels(rendered) + assert BUTTON_LABELS["offer"] in labels + assert BUTTON_LABELS["buy"] not in labels + + snapshot = await store.get_alert_snapshot(1) + assert snapshot is not None + assert snapshot.phase == "negotiable" + + +async def test_over_band_listing_stays_filtered(store: SqliteStore) -> None: + listing = _listing("far1", price_eur=Decimal("85.00")) # total ~95 € > 84 € + telegram, evaluator = await _cycle(store, [listing], {}) + + assert telegram.sends == [] + assert evaluator.calls == [] # dropped before the LLM eval, as before + + +async def test_offer_disabled_entry_filters_the_band(store: SqliteStore) -> None: + listing = _listing("band2", price_eur=Decimal("70.00")) + telegram, evaluator = await _cycle(store, [listing], {}, entry=_entry(offer_enabled=False)) + + assert telegram.sends == [] + assert evaluator.calls == [] + + +async def test_no_band_config_means_no_carve_out(store: SqliteStore) -> None: + listing = _listing("band3", price_eur=Decimal("70.00")) + telegram, evaluator = await _cycle(store, [listing], {}, band=None) + + assert telegram.sends == [] + assert evaluator.calls == [] + + +async def test_confidence_gate_applies_inside_the_band(store: SqliteStore) -> None: + listing = _listing("band4", price_eur=Decimal("70.00")) + telegram, evaluator = await _cycle( + store, [listing], {"band4": _evaluation("band4", confidence="low")} + ) + + assert evaluator.calls == ["band4"] # evaluated like any candidate + assert telegram.sends == [] # below the medium threshold + + +async def test_under_ceiling_alert_unchanged_without_target(store: SqliteStore) -> None: + # Buyer total fits the ceiling and no offer.target_total_eur is set β†’ + # the ceiling-fit price lands at/above asking β†’ no offer surface at all. + listing = _listing("under1", price_eur=Decimal("55.00")) # total ~63.31 € + telegram, _ = await _cycle(store, [listing], {"under1": _evaluation("under1")}) + + assert len(telegram.sends) == 1 + rendered = telegram.sends[0] + assert rendered.text.startswith("πŸ“¦") + assert "Oferta:" not in rendered.text + assert BUTTON_LABELS["offer"] not in _keyboard_labels(rendered) + + +async def test_lower_target_adds_offer_row_to_standard_alert(store: SqliteStore) -> None: + entry = _entry() + entry = entry.model_copy( + update={"offer": entry.offer.model_copy(update={"target_total_eur": Decimal("60")})} + ) + listing = _listing("under2", price_eur=Decimal("55.00")) # total ~63.31 € ≀ 70 € + telegram, _ = await _cycle(store, [listing], {"under2": _evaluation("under2")}, entry=entry) + + assert len(telegram.sends) == 1 + rendered = telegram.sends[0] + assert rendered.text.startswith("πŸ“¦") # still a standard Phase 1 alert + assert "Oferta:" in rendered.text + assert BUTTON_LABELS["offer"] in _keyboard_labels(rendered) diff --git a/tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr b/tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr new file mode 100644 index 0000000..5755d72 --- /dev/null +++ b/tests/unit/__snapshots__/test_offer_renderer_snapshots.ambr @@ -0,0 +1,322 @@ +# serializer version: 1 +# name: test_offer_variant_keyboard_matches_snapshot[negotiable_listing_direct][negotiable_listing_direct.keyboard] + list([ + list([ + dict({ + 'callback_data': 'listing:offer:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ’° Ofertar', + }), + dict({ + 'callback_data': 'listing:skip:12345678-1234-1234-1234-123456789abc', + 'text': '❌ Saltar', + }), + dict({ + 'callback_data': 'listing:view:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ‘ Ver', + }), + ]), + ]) +# --- +# name: test_offer_variant_keyboard_matches_snapshot[negotiable_listing_missing_photo][negotiable_listing_missing_photo.keyboard] + list([ + list([ + dict({ + 'callback_data': 'listing:offer:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ’° Ofertar', + }), + dict({ + 'callback_data': 'listing:skip:12345678-1234-1234-1234-123456789abc', + 'text': '❌ Saltar', + }), + dict({ + 'callback_data': 'listing:view:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ‘ Ver', + }), + ]), + ]) +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_amount_rejected][offer_failure_amount_rejected.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_daily_limit_reached][offer_failure_daily_limit_reached.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_duplicate_offer][offer_failure_duplicate_offer.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_listing_gone][offer_failure_listing_gone.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_lockout_engaged][offer_failure_lockout_engaged.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_marketplace_error][offer_failure_marketplace_error.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_missing_element][offer_failure_missing_element.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_offer_unavailable][offer_failure_offer_unavailable.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_reconciliation_tripped][offer_failure_reconciliation_tripped.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_screenshot_missing][offer_failure_screenshot_missing.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_timeout][offer_failure_timeout.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_failure_ui_check_failed][offer_failure_ui_check_failed.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[offer_sent][offer_sent.keyboard] + None +# --- +# name: test_offer_variant_keyboard_matches_snapshot[phase1_listing_with_offer][phase1_listing_with_offer.keyboard] + list([ + list([ + dict({ + 'callback_data': 'listing:view:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ‘ Ver', + }), + dict({ + 'callback_data': 'listing:skip:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ™… Saltar', + }), + dict({ + 'callback_data': 'listing:snooze:12345678-1234-1234-1234-123456789abc', + 'text': '😴 Posponer 24h', + }), + ]), + list([ + dict({ + 'callback_data': 'listing:offer:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ’° Ofertar', + }), + ]), + ]) +# --- +# name: test_offer_variant_keyboard_matches_snapshot[phase2_listing_with_offer][phase2_listing_with_offer.keyboard] + list([ + list([ + dict({ + 'callback_data': 'listing:buy:12345678-1234-1234-1234-123456789abc', + 'text': 'βœ… Comprar', + }), + dict({ + 'callback_data': 'listing:skip:12345678-1234-1234-1234-123456789abc', + 'text': '❌ Saltar', + }), + dict({ + 'callback_data': 'listing:view:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ‘ Ver', + }), + ]), + list([ + dict({ + 'callback_data': 'listing:offer:12345678-1234-1234-1234-123456789abc', + 'text': 'πŸ’° Ofertar', + }), + ]), + ]) +# --- +# name: test_offer_variant_matches_snapshot[negotiable_listing_direct][negotiable_listing_direct.text] + ''' + πŸ’° *WD Red Plus 4TB \(WD40EFPX\)* β€” *70,00 €* + πŸ“ Madrid Β· Wallapop + πŸ’Ά 70,00 \+ 3,50 envΓ­o \(est\.\) \+ 5,94 ProtecciΓ³n \= 79,44 € + πŸ’° Oferta: 51,00 € \(total ≀ 60,00 €\) + πŸ”— [Ver anuncio en Wallapop](https://es.wallapop.com/item/abc123) + _WD Red Plus 4TB at 55€ β€” strong match\._ + πŸ” Confidence: high + ''' +# --- +# name: test_offer_variant_matches_snapshot[negotiable_listing_missing_photo][negotiable_listing_missing_photo.text] + ''' + πŸ’° *WD Red Plus 4TB \(WD40EFPX\)* β€” *70,00 €* + πŸ“ Madrid Β· Wallapop + πŸ’Ά 70,00 \+ 3,50 envΓ­o \(est\.\) \+ 5,94 ProtecciΓ³n \= 79,44 € + πŸ’° Oferta: 51,00 € \(total ≀ 60,00 €\) + πŸ”— [Ver anuncio en Wallapop](https://es.wallapop.com/item/abc123) + _WD Red Plus 4TB at 55€ β€” strong match\._ + πŸ” Confidence: high + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_amount_rejected][offer_failure_amount_rejected.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Wallapop rechazΓ³ el importe ofertado + \- Importe intentado: 51,00 € + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_daily_limit_reached][offer_failure_daily_limit_reached.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: LΓ­mite diario de ofertas alcanzado + \- LΓ­mite: presupuesto propio \(offer\.daily\_limit\) + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. Reintenta cuando la ventana de 24 h libere presupuesto + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_duplicate_offer][offer_failure_duplicate_offer.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Ya existe una oferta enviada para este anuncio + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_listing_gone][offer_failure_listing_gone.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: El anuncio ya no estΓ‘ disponible \(vendido o retirado\) + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_lockout_engaged][offer_failure_lockout_engaged.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: EnvΓ­o de ofertas bloqueado por fallos consecutivos + \- 3 fallos consecutivos Β· umbral: 3 + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + 2\. `salvager offer enable ` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_marketplace_error][offer_failure_marketplace_error.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Error en el marketplace + \- Detalle: TinyFishUnavailable + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_missing_element][offer_failure_missing_element.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Elemento esperado no encontrado + \- Elementos faltantes: \['offer\_button'\] + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_offer_unavailable][offer_failure_offer_unavailable.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: El anuncio no admite ofertas + \- Vendedor PRO, producto reacondicionado o categorΓ­a excluida + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_reconciliation_tripped][offer_failure_reconciliation_tripped.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: El anuncio cambiΓ³ desde la alerta \(precio/estado\) + \- Oferta mostrada: 51,00 € + \- Oferta recalculada: 49,00 € + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_screenshot_missing][offer_failure_screenshot_missing.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Captura de confirmaciΓ³n no disponible + + La oferta puede haberse enviado, pero no se capturΓ³ la confirmaciΓ³n\. + + PrΓ³ximo paso: + 1\. Comprueba el chat del anuncio en la app de Wallapop + 2\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_timeout][offer_failure_timeout.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: Timeout durante el envΓ­o de la oferta + \- Detalle: TinyFishUnavailable + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_failure_ui_check_failed][offer_failure_ui_check_failed.text] + ''' + 🚫 *Oferta no enviada* Β· WD Red Plus 4TB \(WD40EFPX\) + Causa: VerificaciΓ³n de UI fallΓ³ + \- Elementos faltantes: \['offer\_button'\] + + No se ha enviado ninguna oferta\. + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_offer_variant_matches_snapshot[offer_sent][offer_sent.text] + ''' + πŸ’° *Oferta enviada* Β· 51,00 € + Listing: WD Red Plus 4TB \(WD40EFPX\) + El vendedor puede aceptar, rechazar o contraofertar β€” vigila el chat de Wallapop\. + Si acepta: tienes 24 h para comprar al precio aceptado en la app \(el artΓ­culo NO queda reservado\)\. + Ofertas restantes hoy: 9 + `salvager audit show --id 7` para el registro completo de eventos\. + ''' +# --- +# name: test_offer_variant_matches_snapshot[phase1_listing_with_offer][phase1_listing_with_offer.text] + ''' + πŸ“¦ *WD Red Plus 4TB \(WD40EFPX\)* β€” *55,00 €* + πŸ“ Madrid Β· Wallapop + πŸ’Ά 55,00 \+ 3,50 envΓ­o \(est\.\) \+ 4,82 ProtecciΓ³n \= 63,32 € + πŸ’° Oferta: 42,00 € \(total ≀ 50,00 €\) + πŸ”— [Ver anuncio en Wallapop](https://es.wallapop.com/item/abc123) + _WD Red Plus 4TB at 55€ β€” strong match\._ + πŸ” Confidence: high + ''' +# --- +# name: test_offer_variant_matches_snapshot[phase2_listing_with_offer][phase2_listing_with_offer.text] + ''' + 🟒 *WD Red Plus 4TB \(WD40EFPX\)* β€” *55,00 €* + πŸ“ Madrid Β· Wallapop + πŸ’Ά 55,00 \+ 3,50 envΓ­o \(est\.\) \+ 4,82 ProtecciΓ³n \= 63,32 € + πŸ’° Oferta: 42,00 € \(total ≀ 50,00 €\) + πŸ”— [Ver anuncio en Wallapop](https://es.wallapop.com/item/abc123) + _WD Red Plus 4TB at 55€ β€” strong match\._ + πŸ” Confidence: high Β· Phase 2 max: 60,00 € + ''' +# --- diff --git a/tests/unit/__snapshots__/test_operational_alert_renderer.ambr b/tests/unit/__snapshots__/test_operational_alert_renderer.ambr index f084663..38650cc 100644 --- a/tests/unit/__snapshots__/test_operational_alert_renderer.ambr +++ b/tests/unit/__snapshots__/test_operational_alert_renderer.ambr @@ -77,6 +77,50 @@ Estado: la cachΓ© y el reintento absorben el lΓ­mite ''' # --- +# name: test_operational_alert_matches_snapshot[offer_disabled] + ''' + ⚠️ *EnvΓ­o de ofertas desactivado* + + Causa: kill\_switch\_global + Estado actual: envΓ­o de ofertas desactivado globalmente + + PrΓ³ximo paso: + 1\. `salvager offer enable ` + ''' +# --- +# name: test_operational_alert_matches_snapshot[offer_lockout_engaged] + ''' + ⚠️ *EnvΓ­o de ofertas desactivado* + + Causa: 3 fallos consecutivos \(umbral: 3\) + Última entrada afectada: Corsair Vengeance LPX 16GB / CMK16GX4M2D3000C16 + Estado actual: envΓ­o de ofertas desactivado globalmente + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + 2\. revisa la causa y parchea si es un bug + 3\. `salvager offer enable ` + ''' +# --- +# name: test_operational_alert_matches_snapshot[offer_orchestrator_error] + ''' + ⚠️ *Error en el orquestador de ofertas* + + Causa: TinyFishSessionLost + Alert: 12345678\-1234\-1234\-1234\-123456789abc + Estado: ninguna oferta enviada; el teclado se ha restaurado + + PrΓ³ximo paso: + 1\. `salvager audit show --last 5` + ''' +# --- +# name: test_operational_alert_matches_snapshot[offer_re_enabled] + ''' + ℹ️ EnvΓ­o de ofertas reactivado + + Entrada: Corsair Vengeance LPX 16GB / CMK16GX4M2D3000C16 + ''' +# --- # name: test_operational_alert_matches_snapshot[phase2_buy_callback_received] ''' ℹ️ Buy callback recibido diff --git a/tests/unit/test_alert_renderer.py b/tests/unit/test_alert_renderer.py index 6f0c8b4..43f71b5 100644 --- a/tests/unit/test_alert_renderer.py +++ b/tests/unit/test_alert_renderer.py @@ -32,7 +32,9 @@ # ───────────────────────────────────────────────────────────────────────── -def test_severity_tokens_have_locked_six_entries() -> None: +def test_severity_tokens_have_locked_nine_entries() -> None: + # Grown by the wallapop-offer-flow PRD amendment (FR58-FR65): the three + # offer surfaces join the original six. assert set(SEVERITY_TOKENS.keys()) == { "operational_warn", "operational_info", @@ -40,22 +42,28 @@ def test_severity_tokens_have_locked_six_entries() -> None: "phase2_listing", "phase2_buy_success", "phase2_buy_failure", + "negotiable_listing", + "offer_sent", + "offer_failure", } assert SEVERITY_TOKENS["phase1_listing"] == "πŸ“¦" assert SEVERITY_TOKENS["operational_warn"] == "⚠️ " + assert SEVERITY_TOKENS["negotiable_listing"] == "πŸ’°" -def test_button_labels_have_locked_five_entries() -> None: +def test_button_labels_have_locked_six_entries() -> None: assert set(BUTTON_LABELS.keys()) == { "view", "skip_phase1", "snooze", "buy", "skip_phase2", + "offer", } assert BUTTON_LABELS["view"] == "πŸ‘ Ver" assert BUTTON_LABELS["skip_phase1"] == "πŸ™… Saltar" assert BUTTON_LABELS["snooze"] == "😴 Posponer 24h" + assert BUTTON_LABELS["offer"] == "πŸ’° Ofertar" def test_callback_data_format_is_literal_template() -> None: diff --git a/tests/unit/test_audit_writer_append_only.py b/tests/unit/test_audit_writer_append_only.py index 80ec199..2f37cb9 100644 --- a/tests/unit/test_audit_writer_append_only.py +++ b/tests/unit/test_audit_writer_append_only.py @@ -17,8 +17,9 @@ import inspect import re -from salvager.adapters.sqlite_store import audit_writer +from salvager.adapters.sqlite_store import audit_writer, offer_writer from salvager.adapters.sqlite_store.audit_writer import Phase2AuditWriter +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter _APPEND_ONLY_TABLES = frozenset({"tap_events", "transactions", "phase2_smoke_tests"}) @@ -70,3 +71,60 @@ def test_no_sql_mutates_an_append_only_audit_table() -> None: ) offenders = sorted(table for table in mutated if table in _APPEND_ONLY_TABLES) assert offenders == [], f"append-only audit tables must never be UPDATE/DELETE'd: {offenders}" + + +# ───────────────────────────────────────────────────────────────────────── +# OfferAuditWriter β€” same mechanical guards for the `offers` audit table +# (wallapop-offer-flow; the mutable `offer_state` row is exempt by design). +# ───────────────────────────────────────────────────────────────────────── + +_OFFER_APPEND_ONLY_TABLES = frozenset({"offers"}) + +_EXPECTED_OFFER_PUBLIC_METHODS = frozenset( + { + "record_offer_attempt", + "has_successful_offer", + "count_recent_successes", + "read_state", + "set_global_disable", + "clear_global_disable", + "increment_failure_counter", + "reset_failure_counter", + # Lifecycle β€” not an audit-mutation surface. + "close", + } +) + + +def _offer_public_methods() -> set[str]: + return { + name + for name, _ in inspect.getmembers(OfferAuditWriter, predicate=inspect.isfunction) + if not name.startswith("_") + } + + +def test_offer_writer_has_no_update_or_delete_methods() -> None: + offenders = sorted( + name + for name in _offer_public_methods() + if name.startswith("update_") or name.startswith("delete_") + ) + assert offenders == [], ( + f"OfferAuditWriter must stay append-only β€” forbidden methods found: {offenders}" + ) + + +def test_offer_writer_exposes_only_the_documented_surface() -> None: + assert _offer_public_methods() == set(_EXPECTED_OFFER_PUBLIC_METHODS) + + +def test_no_sql_mutates_the_offers_table() -> None: + source = inspect.getsource(offer_writer) + mutated = re.findall( + r"\b(?:UPDATE|DELETE\s+FROM)\s+([a-z_]+)", + source, + flags=re.IGNORECASE, + ) + offenders = sorted(table for table in mutated if table in _OFFER_APPEND_ONLY_TABLES) + assert offenders == [], f"the offers audit table must never be UPDATE/DELETE'd: {offenders}" diff --git a/tests/unit/test_callback_handler.py b/tests/unit/test_callback_handler.py index dee4f6c..bf72139 100644 --- a/tests/unit/test_callback_handler.py +++ b/tests/unit/test_callback_handler.py @@ -448,7 +448,7 @@ async def _edit(message_id: int, keyboard: list[list[InlineButton]] | None) -> N async def test_buy_verb_is_in_handled_verbs() -> None: assert "buy" in HANDLED_VERBS - assert frozenset({"view", "skip", "snooze", "buy"}) == HANDLED_VERBS + assert frozenset({"view", "skip", "snooze", "buy", "offer"}) == HANDLED_VERBS async def test_buy_verb_edits_keyboard_to_comprando_and_fires_orchestrator() -> None: diff --git a/tests/unit/test_cli_offer.py b/tests/unit/test_cli_offer.py new file mode 100644 index 0000000..d36c0af --- /dev/null +++ b/tests/unit/test_cli_offer.py @@ -0,0 +1,266 @@ +"""Tests for ``salvager offer enable/disable/status`` (wallapop-offer-flow). + +Mirrors ``test_cli_phase2.py``: the ``run_*`` functions are exercised +directly with a real wishlist file (genuine ruamel round-trip) and a +migrated SQLite DB; TTY/``input()`` semantics use injected fakes. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import Iterator +from decimal import Decimal +from pathlib import Path + +import pytest + +from salvager.adapters.sqlite_store import MigrationRunner, open_connection +from salvager.adapters.sqlite_store.migrations import db_path_under +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.cli.commands import offer_cmd +from salvager.config.wishlist_yaml import load_wishlist +from salvager.domain.offer_audit import OfferStateSnapshot + +_WISHLIST_YAML = """\ +entries: + - manufacturer: Corsair + model: Vengeance LPX 16GB + ref: CMK16GX4M2D3000C16 + type: ram + keywords: + - corsair vengeance lpx + max_price_solo: 80.00 + confidence_threshold: medium + offer: + enabled: false + target_total_eur: null + + - manufacturer: Western Digital + model: WD Red Plus 4TB + ref: WD40EFPX + type: hdd + keywords: + - wd red plus 4tb + max_price_solo: 70.00 + confidence_threshold: medium + offer: + enabled: true + target_total_eur: 60.00 +""" + + +@pytest.fixture +def workspace(tmp_path: Path) -> Iterator[tuple[Path, Path]]: + wishlist_path = tmp_path / "wishlist.yaml" + wishlist_path.write_text(_WISHLIST_YAML, encoding="utf-8") + data_dir = tmp_path / "data" + data_dir.mkdir() + connection = open_connection(db_path_under(data_dir)) + try: + MigrationRunner().run(connection) + finally: + connection.close() + yield wishlist_path, data_dir + + +def _offer_enabled(wishlist_path: Path, ref: str) -> bool: + wishlist = load_wishlist(wishlist_path) + return next(e.offer.enabled for e in wishlist.entries if e.ref == ref) + + +def _engage_lockout(data_dir: Path) -> None: + async def _do() -> None: + writer = OfferAuditWriter(db_path_under(data_dir)) + try: + await writer.increment_failure_counter() + await writer.set_global_disable("offer_lockout_threshold") + finally: + await writer.close() + + asyncio.run(_do()) + + +def _read_state(data_dir: Path) -> OfferStateSnapshot: + async def _do() -> OfferStateSnapshot: + writer = OfferAuditWriter(db_path_under(data_dir)) + try: + return await writer.read_state() + finally: + await writer.close() + + return asyncio.run(_do()) + + +# ───────────────────────────────────────────────────────────────────────── +# offer enable +# ───────────────────────────────────────────────────────────────────────── + + +def test_enable_flips_the_flag_and_clears_lockout( + workspace: tuple[Path, Path], +) -> None: + wishlist_path, data_dir = workspace + _engage_lockout(data_dir) + + exit_code = offer_cmd.run_enable( + query="CMK16GX4M2D3000C16", wishlist_path=wishlist_path, data_dir=data_dir + ) + + assert exit_code == 0 + assert _offer_enabled(wishlist_path, "CMK16GX4M2D3000C16") is True + state = _read_state(data_dir) + assert state.globally_disabled is False + assert state.consecutive_failures == 0 + + +def test_enable_with_target_persists_it(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + + exit_code = offer_cmd.run_enable( + query="CMK16GX4M2D3000C16", + wishlist_path=wishlist_path, + data_dir=data_dir, + target_total_eur="70.00", + ) + + assert exit_code == 0 + wishlist = load_wishlist(wishlist_path) + entry = next(e for e in wishlist.entries if e.ref == "CMK16GX4M2D3000C16") + assert entry.offer.target_total_eur == Decimal("70.00") + + +def test_enable_with_garbage_target_is_usage_error(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_enable( + query="CMK16GX4M2D3000C16", + wishlist_path=wishlist_path, + data_dir=data_dir, + target_total_eur="mucho", + ) + assert exit_code == 2 + assert _offer_enabled(wishlist_path, "CMK16GX4M2D3000C16") is False + + +def test_unknown_entry_exits_usage_error(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_enable( + query="no-such-ref", wishlist_path=wishlist_path, data_dir=data_dir + ) + assert exit_code == 2 + + +# ───────────────────────────────────────────────────────────────────────── +# offer disable +# ───────────────────────────────────────────────────────────────────────── + + +def test_per_entry_disable_keeps_lockout_untouched( + workspace: tuple[Path, Path], +) -> None: + wishlist_path, data_dir = workspace + _engage_lockout(data_dir) + + exit_code = offer_cmd.run_disable( + query="WD40EFPX", all_entries=False, wishlist_path=wishlist_path, data_dir=data_dir + ) + + assert exit_code == 0 + assert _offer_enabled(wishlist_path, "WD40EFPX") is False + # Per-entry disable never lifts (nor engages) the global lockout. + assert _read_state(data_dir).globally_disabled is True + + +def test_disable_requires_entry_or_all(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_disable( + query=None, all_entries=False, wishlist_path=wishlist_path, data_dir=data_dir + ) + assert exit_code == 2 + + +def test_disable_all_requires_tty(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_disable( + query=None, + all_entries=True, + wishlist_path=wishlist_path, + data_dir=data_dir, + is_tty=lambda: False, + ) + assert exit_code == 1 + assert _offer_enabled(wishlist_path, "WD40EFPX") is True + + +def test_disable_all_typing_count_disables_and_locks( + workspace: tuple[Path, Path], +) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_disable( + query=None, + all_entries=True, + wishlist_path=wishlist_path, + data_dir=data_dir, + is_tty=lambda: True, + input_fn=lambda prompt: "1", # one entry currently enabled + ) + assert exit_code == 0 + assert _offer_enabled(wishlist_path, "WD40EFPX") is False + state = _read_state(data_dir) + assert state.globally_disabled is True + assert state.disabled_reason == "operator_disable_all" + + +def test_disable_all_wrong_number_aborts(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_disable( + query=None, + all_entries=True, + wishlist_path=wishlist_path, + data_dir=data_dir, + is_tty=lambda: True, + input_fn=lambda prompt: "7", + ) + assert exit_code == 1 + assert _offer_enabled(wishlist_path, "WD40EFPX") is True + + +# ───────────────────────────────────────────────────────────────────────── +# offer status +# ───────────────────────────────────────────────────────────────────────── + + +def test_status_json_emits_a_parseable_object( + workspace: tuple[Path, Path], capsys: pytest.CaptureFixture[str] +) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_status( + wishlist_path=wishlist_path, data_dir=data_dir, output_format="json" + ) + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["globally_disabled"] is False + assert payload["sent_last_24h"] == 0 + by_ref = {e["entry_key"][2]: e for e in payload["entries"]} + assert by_ref["WD40EFPX"]["offer_enabled"] is True + assert Decimal(by_ref["WD40EFPX"]["target_total_eur"]) == Decimal("60") + assert by_ref["CMK16GX4M2D3000C16"]["offer_enabled"] is False + + +def test_status_human_shows_rows_and_footer( + workspace: tuple[Path, Path], capsys: pytest.CaptureFixture[str] +) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_status(wishlist_path=wishlist_path, data_dir=data_dir) + assert exit_code == 0 + out = capsys.readouterr().out + assert "WD Red Plus 4TB" in out + assert "Sent last 24h: 0/5" in out + + +def test_status_unknown_format_is_usage_error(workspace: tuple[Path, Path]) -> None: + wishlist_path, data_dir = workspace + exit_code = offer_cmd.run_status( + wishlist_path=wishlist_path, data_dir=data_dir, output_format="xml" + ) + assert exit_code == 2 diff --git a/tests/unit/test_dev_emit_alert.py b/tests/unit/test_dev_emit_alert.py index 4132f76..1ffc80e 100644 --- a/tests/unit/test_dev_emit_alert.py +++ b/tests/unit/test_dev_emit_alert.py @@ -2,11 +2,13 @@ Three layers: - - **catalog completeness** β€” the registry contains exactly 45 + - **catalog completeness** β€” the registry contains exactly 66 variants (3+3 listing, 3 cost, 3 edited, 1 price-drop ping, - 1 receipt, 9 buy failures, 22 operational) and the set drifts only - when a PRD amendment adds an EventName / BuyFailureReason variant - or a release-audit delta adds a rendering shape; + 2 negotiable, 2 with-offer, 1 receipt, 1 offer-sent, 9 buy + failures, 12 offer failures, 26 operational) and the set drifts + only when a PRD amendment adds an EventName / BuyFailureReason / + OfferFailureReason variant or a release-audit delta adds a + rendering shape; - **renderability** β€” every registered builder produces a non-empty MarkdownV2 ``RenderedAlert.text`` without raising; - **CLI seams** β€” ``--dry-run`` prints the rendered text to stdout @@ -16,6 +18,8 @@ from __future__ import annotations +import re + import pytest from typer.testing import CliRunner @@ -25,7 +29,7 @@ build_rendered_variant, ) from salvager.domain.alert import EventName -from salvager.domain.errors import BuyFailureReason +from salvager.domain.errors import BuyFailureReason, OfferFailureReason _RUNNER = CliRunner() @@ -35,7 +39,7 @@ # ───────────────────────────────────────────────────────────────────────── -def test_registry_is_a_closed_set_of_45_variants() -> None: +def test_registry_is_a_closed_set_of_66_variants() -> None: expected_listing = { "phase1_listing_direct", "phase1_listing_container", @@ -50,12 +54,18 @@ def test_registry_is_a_closed_set_of_45_variants() -> None: "phase1_listing_edited_price_drop", "phase2_listing_edited_reserved", "price_drop_ping", + "negotiable_listing_direct", + "negotiable_listing_missing_photo", + "phase1_listing_with_offer", + "phase2_listing_with_offer", } expected_buy = {"buy_success"} | {f"buy_failure_{r.value}" for r in BuyFailureReason} + expected_offer = {"offer_sent"} | {f"offer_failure_{r.value}" for r in OfferFailureReason} expected_operational = {e.value for e in EventName} - expected = expected_listing | expected_buy | expected_operational + expected = expected_listing | expected_buy | expected_offer | expected_operational assert set(VARIANT_REGISTRY) == expected - assert len(VARIANT_REGISTRY) == 45 + # 17 listing shapes + 10 buy + 13 offer + 26 operational (= len(EventName)). + assert len(VARIANT_REGISTRY) == 66 def test_registry_covers_every_buy_failure_reason() -> None: @@ -63,6 +73,11 @@ def test_registry_covers_every_buy_failure_reason() -> None: assert f"buy_failure_{reason.value}" in VARIANT_REGISTRY +def test_registry_covers_every_offer_failure_reason() -> None: + for reason in OfferFailureReason: + assert f"offer_failure_{reason.value}" in VARIANT_REGISTRY + + def test_registry_covers_every_event_name() -> None: for event in EventName: assert event.value in VARIANT_REGISTRY @@ -102,7 +117,8 @@ def test_list_variants_prints_every_variant_name() -> None: assert result.exit_code == 0 for name in VARIANT_REGISTRY: assert name in result.output - assert "45 variants total" in result.output + plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output) + assert "66 variants total" in plain def test_emit_alert_dry_run_prints_rendered_text_without_sending() -> None: diff --git a/tests/unit/test_offer_orchestrator.py b/tests/unit/test_offer_orchestrator.py new file mode 100644 index 0000000..d592b92 --- /dev/null +++ b/tests/unit/test_offer_orchestrator.py @@ -0,0 +1,554 @@ +"""Offer orchestrator + preflight tests (wallapop-offer-flow). + +Real ``OfferAuditWriter`` over a migrated temp DB (lockout/dedupe/budget +are DB-backed contracts); fakes for the store, fetcher, offer session, +Telegram surface, and reporter. Covers the happy path, every abort path, +lockout engagement + independence rules, the daily budget, and the +keyboard-restore guarantee on every outcome. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest + +from salvager.adapters.sqlite_store.connection import open_connection +from salvager.adapters.sqlite_store.migrations import MigrationRunner, db_path_under +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.domain.alert import AlertSnapshot, CallbackEvent, EventName, RenderedAlert +from salvager.domain.errors import OfferFailureReason, WallapopApiError +from salvager.domain.evaluation import ListingEvaluation +from salvager.domain.listing import Listing +from salvager.domain.offer_audit import OfferAttemptRecord +from salvager.domain.wishlist import WishlistEntry +from salvager.interfaces.offer_session import ( + OfferResult, + OfferSendFailure, + OfferSession, + OfferSuccess, +) +from salvager.orchestration.offer_orchestrator import ( + OfferOrchestrator, + OfferOutcomeAborted, + OfferOutcomeFailure, + OfferOutcomeSuccess, +) +from salvager.orchestration.offer_preflight import OfferPreflight + +_T0 = datetime(2026, 7, 22, 12, 0, 0, tzinfo=UTC) +_ENTRY_KEY = ("Corsair", "Vengeance LPX 16GB", "CMK16GX4M2D3000C16") + + +def _entry(**offer_overrides: Any) -> WishlistEntry: + return WishlistEntry.model_validate( + { + "manufacturer": _ENTRY_KEY[0], + "model": _ENTRY_KEY[1], + "ref": _ENTRY_KEY[2], + "type": "ram", + "keywords": ["corsair"], + "max_price_solo": Decimal("70.00"), + "confidence_threshold": "medium", + "offer": {"enabled": True, **offer_overrides}, + } + ) + + +def _listing(**overrides: Any) -> Listing: + base: dict[str, Any] = { + "listing_id": "internal-123", + "marketplace": "wallapop", + "url": "https://es.wallapop.com/item/corsair-abc", + "title": "Corsair Vengeance LPX 16GB", + "description": "d", + "price_eur": Decimal("70.00"), + "shipping_eur": Decimal("3.50"), + "fetched_at": _T0, + } + base.update(overrides) + return Listing(**base) + + +def _snapshot(listing: Listing | None = None) -> AlertSnapshot: + lst = listing if listing is not None else _listing() + return AlertSnapshot( + alert_id=uuid4(), + entry_key=_ENTRY_KEY, + entry_display_name="Corsair Vengeance LPX 16GB (CMK16GX4M2D3000C16)", + listing=lst, + evaluation=ListingEvaluation( + listing_id=lst.listing_id, + entry_key=_ENTRY_KEY, + confidence="high", + one_line_take="Match.", + is_container=False, + evaluated_at=_T0, + ), + phase="negotiable", + rendered_at=_T0, + ) + + +def _event(snapshot: AlertSnapshot) -> CallbackEvent: + return CallbackEvent( + callback_query_id="cq1", + chat_id=1, + message_id=99, + callback_data=f"listing:offer:{snapshot.alert_id}", + verb="offer", + ) + + +# ───────────────────────────────────────────────────────────────────────── +# Fakes +# ───────────────────────────────────────────────────────────────────────── + + +class _FakeStore: + def __init__(self, snapshot: AlertSnapshot | None) -> None: + self._snapshot = snapshot + + async def get_alert_snapshot_by_alert_id(self, alert_id: Any) -> AlertSnapshot | None: + if self._snapshot is not None and self._snapshot.alert_id == alert_id: + return self._snapshot + return None + + +class _FakeFetcher: + def __init__(self, fresh: Listing | None = None, error: Exception | None = None) -> None: + self._fresh = fresh + self._error = error + self.calls = 0 + + async def fetch_listing(self, listing: Listing) -> Listing: + self.calls += 1 + if self._error is not None: + raise self._error + assert self._fresh is not None + return self._fresh + + +class _FakeOfferSession(OfferSession): + def __init__(self, result: OfferResult | None = None) -> None: + self._result = result + self.calls: list[tuple[str, Decimal]] = [] + + async def execute_offer(self, listing: Listing, amount_eur: Decimal) -> OfferResult: + self.calls.append((listing.listing_id, amount_eur)) + assert self._result is not None, "test drove execution unexpectedly" + return self._result + + +class _FakeTelegram: + def __init__(self) -> None: + self.sends: list[RenderedAlert] = [] + self.keyboard_edits: list[tuple[int, Any]] = [] + + async def send(self, rendered: RenderedAlert, **_: Any) -> int: + self.sends.append(rendered) + return 1 + + async def edit_keyboard(self, message_id: int, keyboard: Any) -> None: + self.keyboard_edits.append((message_id, keyboard)) + + +class _FakeReporter: + def __init__(self) -> None: + self.events: list[EventName] = [] + + async def report(self, severity: str, event: EventName, *, ctx: Any = None) -> None: + self.events.append(event) + + +@pytest.fixture +async def offer_writer(tmp_path: Path) -> AsyncIterator[OfferAuditWriter]: + db_path = db_path_under(tmp_path) + connection = open_connection(db_path) + MigrationRunner().run(connection) + connection.close() + writer = OfferAuditWriter(db_path) + yield writer + await writer.close() + + +def _orchestrator( + *, + offer_writer: OfferAuditWriter, + snapshot: AlertSnapshot | None, + fetcher: _FakeFetcher, + session: _FakeOfferSession, + entry: WishlistEntry | None = None, + kill_switch: bool = False, + daily_limit: int = 5, +) -> tuple[OfferOrchestrator, _FakeTelegram, _FakeReporter]: + telegram = _FakeTelegram() + reporter = _FakeReporter() + resolved_entry = entry if entry is not None else _entry() + preflight = OfferPreflight( + offer_writer=offer_writer, + kill_switch_global=kill_switch, + lockout_threshold=3, + daily_limit=daily_limit, + ) + orchestrator = OfferOrchestrator( + preflight=preflight, + fetcher=fetcher, # type: ignore[arg-type] + offer_session=session, + offer_writer=offer_writer, + telegram_surface=telegram, # type: ignore[arg-type] + store=_FakeStore(snapshot), # type: ignore[arg-type] + reporter=reporter, # type: ignore[arg-type] + wishlist_loader=lambda key: resolved_entry if key == _ENTRY_KEY else None, + lockout_threshold=3, + ) + return orchestrator, telegram, reporter + + +def _success_result(**overrides: Any) -> OfferSuccess: + base: dict[str, Any] = { + "offered_eur": Decimal("61"), + "screenshot_url": "https://shots/offer.png", + "platform_remaining": 9, + "total_seconds": 12, + } + base.update(overrides) + return OfferSuccess(**base) + + +# ───────────────────────────────────────────────────────────────────────── +# Happy path +# ───────────────────────────────────────────────────────────────────────── + + +async def test_happy_path_sends_recomputed_amount_and_audits( + offer_writer: OfferAuditWriter, +) -> None: + # Asking 70 € against a 70 € ceiling: fit price is 61 €. + snapshot = _snapshot() + fetcher = _FakeFetcher(fresh=_listing()) + session = _FakeOfferSession(_success_result()) + orchestrator, telegram, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeSuccess) + assert outcome.offered_eur == Decimal("61") + assert session.calls == [("internal-123", Decimal("61"))] + assert await offer_writer.has_successful_offer("wallapop", "internal-123") is True + assert (await offer_writer.read_state()).consecutive_failures == 0 + # Sent alert + terminal badge keyboard. + assert any("Oferta enviada" in r.text for r in telegram.sends) + assert telegram.keyboard_edits, "keyboard must be repainted" + last_keyboard = telegram.keyboard_edits[-1][1] + assert any("Oferta enviada" in b.text for row in last_keyboard for b in row) + + +async def test_success_on_phase2_alert_keeps_comprar_row(offer_writer: OfferAuditWriter) -> None: + listing = _listing(price_eur=Decimal("55.00")) + snapshot = _snapshot(listing).model_copy( + update={"phase": "phase2", "phase2_max_price_eur": Decimal("70.00")} + ) + entry = _entry(target_total_eur=Decimal("60")) + fetcher = _FakeFetcher(fresh=listing) + session = _FakeOfferSession(_success_result(offered_eur=Decimal("51"))) + orchestrator, telegram, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session, entry=entry + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeSuccess) + last_keyboard = telegram.keyboard_edits[-1][1] + labels = [b.text for row in last_keyboard for b in row] + assert "βœ… Comprar" in labels + assert any("Oferta enviada" in label for label in labels) + + +# ───────────────────────────────────────────────────────────────────────── +# Abort paths (no lockout increment, keyboard restored) +# ───────────────────────────────────────────────────────────────────────── + + +async def test_listing_gone_aborts_fail_closed(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + fetcher = _FakeFetcher(error=WallapopApiError(404, "not found")) + session = _FakeOfferSession() + orchestrator, telegram, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.listing_gone + assert session.calls == [] + assert (await offer_writer.read_state()).consecutive_failures == 0 + # Failure alert carries the reassurance; keyboard restored to Ofertar. + assert any("No se ha enviado ninguna oferta" in r.text for r in telegram.sends) + last_keyboard = telegram.keyboard_edits[-1][1] + assert any("Ofertar" in b.text for row in last_keyboard for b in row) + + +async def test_price_rise_beyond_tolerance_aborts(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() # displayed fit: 61 € + fetcher = _FakeFetcher(fresh=_listing(price_eur=Decimal("95.00"))) # fit now impossible + session = _FakeOfferSession() + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.reconciliation_tripped + assert session.calls == [] + assert (await offer_writer.read_state()).consecutive_failures == 0 + + +async def test_reserved_on_refetch_aborts(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + fetcher = _FakeFetcher(fresh=_listing(is_reserved=True)) + session = _FakeOfferSession() + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.reconciliation_tripped + assert session.calls == [] + + +async def test_duplicate_offer_blocked_before_execution(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + await offer_writer.record_offer_attempt( + OfferAttemptRecord( + alert_id=uuid4(), + listing_id="internal-123", + marketplace="wallapop", + entry_key=_ENTRY_KEY, + offered_eur=Decimal("61"), + asking_eur=Decimal("70.00"), + outcome="success", + attempted_at=_T0, + ) + ) + fetcher = _FakeFetcher(fresh=_listing()) + session = _FakeOfferSession() + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, snapshot=snapshot, fetcher=fetcher, session=session + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.duplicate_offer + assert fetcher.calls == 0 + assert session.calls == [] + + +async def test_daily_budget_blocks_before_execution(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + now = datetime.now(UTC) + for n in range(2): + await offer_writer.record_offer_attempt( + OfferAttemptRecord( + alert_id=uuid4(), + listing_id=f"other-{n}", + marketplace="wallapop", + entry_key=_ENTRY_KEY, + offered_eur=Decimal("10"), + asking_eur=Decimal("20"), + outcome="success", + attempted_at=now - timedelta(hours=1), + ) + ) + fetcher = _FakeFetcher(fresh=_listing()) + session = _FakeOfferSession() + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, + snapshot=snapshot, + fetcher=fetcher, + session=session, + daily_limit=2, + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.daily_limit_reached + assert session.calls == [] + assert (await offer_writer.read_state()).consecutive_failures == 0 + + +async def test_kill_switch_blocks(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, + snapshot=snapshot, + fetcher=_FakeFetcher(fresh=_listing()), + session=_FakeOfferSession(), + kill_switch=True, + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.lockout_engaged + + +async def test_snapshot_missing_restores_keyboard(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + orchestrator, telegram, _ = _orchestrator( + offer_writer=offer_writer, + snapshot=None, # store knows nothing about this alert + fetcher=_FakeFetcher(fresh=_listing()), + session=_FakeOfferSession(), + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.reason == "snapshot_not_found" + assert telegram.keyboard_edits, "the in-flight badge must not stay painted" + + +# ───────────────────────────────────────────────────────────────────────── +# Failures + lockout +# ───────────────────────────────────────────────────────────────────────── + + +async def test_execution_failure_counts_and_third_engages_lockout( + offer_writer: OfferAuditWriter, +) -> None: + snapshot = _snapshot() + session = _FakeOfferSession( + OfferSendFailure(reason=OfferFailureReason.timeout, ctx={"detail": "x"}) + ) + orchestrator, telegram, reporter = _orchestrator( + offer_writer=offer_writer, + snapshot=snapshot, + fetcher=_FakeFetcher(fresh=_listing()), + session=session, + ) + + for _attempt in (1, 2, 3): + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + assert isinstance(outcome, OfferOutcomeFailure) + + state = await offer_writer.read_state() + assert state.globally_disabled is True + assert state.consecutive_failures == 3 + assert EventName.offer_lockout_engaged in reporter.events + + # The next tap aborts at preflight β€” no execution. + session.calls.clear() + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + assert isinstance(outcome, OfferOutcomeAborted) + assert outcome.rendered_as is OfferFailureReason.lockout_engaged + assert session.calls == [] + # Keyboard restored on every failure outcome along the way. + assert len(telegram.keyboard_edits) == 4 + + +async def test_platform_daily_limit_failure_never_counts(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + session = _FakeOfferSession( + OfferSendFailure( + reason=OfferFailureReason.daily_limit_reached, ctx={"platform_remaining": 0} + ) + ) + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, + snapshot=snapshot, + fetcher=_FakeFetcher(fresh=_listing()), + session=session, + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeFailure) + assert outcome.reason is OfferFailureReason.daily_limit_reached + assert (await offer_writer.read_state()).consecutive_failures == 0 + + +async def test_failure_rows_are_audited(offer_writer: OfferAuditWriter) -> None: + snapshot = _snapshot() + session = _FakeOfferSession(OfferSendFailure(reason=OfferFailureReason.amount_rejected, ctx={})) + orchestrator, _, _ = _orchestrator( + offer_writer=offer_writer, + snapshot=snapshot, + fetcher=_FakeFetcher(fresh=_listing()), + session=session, + ) + + await orchestrator.execute_offer_from_callback(_event(snapshot)) + + connection = open_connection(offer_writer._db_path) + try: + row = connection.execute( + "SELECT outcome, failure_reason FROM offers ORDER BY audit_id DESC LIMIT 1" + ).fetchone() + finally: + connection.close() + assert tuple(row) == ("failure", "amount_rejected") + + +class _AuditFailingWriter(OfferAuditWriter): + """Writer whose offers INSERT always fails β€” the post-send audit gap.""" + + async def record_offer_attempt(self, attempt: OfferAttemptRecord) -> int: + raise RuntimeError("disk full") + + +async def test_audit_failure_after_send_still_reports_success(tmp_path: Path) -> None: + # CodeRabbit (PR #55), critical: a failed audit write AFTER a verified + # send must never render "No se ha enviado ninguna oferta" (false), must + # not count a lockout failure, and must escalate the missing dedupe row. + db_path = db_path_under(tmp_path) + connection = open_connection(db_path) + MigrationRunner().run(connection) + connection.close() + writer = _AuditFailingWriter(db_path) + try: + snapshot = _snapshot() + session = _FakeOfferSession(_success_result()) + telegram = _FakeTelegram() + reporter = _FakeReporter() + entry = _entry() + orchestrator = OfferOrchestrator( + preflight=OfferPreflight( + offer_writer=writer, + kill_switch_global=False, + lockout_threshold=3, + daily_limit=5, + ), + fetcher=_FakeFetcher(fresh=_listing()), # type: ignore[arg-type] + offer_session=session, + offer_writer=writer, + telegram_surface=telegram, # type: ignore[arg-type] + store=_FakeStore(snapshot), # type: ignore[arg-type] + reporter=reporter, # type: ignore[arg-type] + wishlist_loader=lambda key: entry if key == _ENTRY_KEY else None, + lockout_threshold=3, + ) + + outcome = await orchestrator.execute_offer_from_callback(_event(snapshot)) + + assert isinstance(outcome, OfferOutcomeSuccess) + assert outcome.audit_id == 0 + assert any("Oferta enviada" in r.text for r in telegram.sends) + assert not any("No se ha enviado" in r.text for r in telegram.sends) + assert EventName.offer_orchestrator_error in reporter.events + assert (await writer.read_state()).consecutive_failures == 0 + finally: + await writer.close() diff --git a/tests/unit/test_offer_pricing.py b/tests/unit/test_offer_pricing.py new file mode 100644 index 0000000..cfe75d8 --- /dev/null +++ b/tests/unit/test_offer_pricing.py @@ -0,0 +1,128 @@ +"""Offer-amount derivation tests (wallapop-offer-flow). + +The offer is the largest whole-euro item price whose Wallapop buyer total +(item + shipping + ProtecciΓ³n) fits the entry's offer target, bounded by +the platform rules: strictly below the asking price and at or above 70 % +of it (Wallapop rejects discounts deeper than 30 %). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from salvager.domain.listing import Listing +from salvager.domain.pricing import ( + OFFER_PLATFORM_FLOOR_RATIO, + buyer_total_eur, + offer_item_price_eur, +) + +_TS = datetime(2026, 7, 22, tzinfo=UTC) +_BUFFER = Decimal("3.50") + + +def _listing(**overrides: object) -> Listing: + base: dict[str, object] = { + "listing_id": "x", + "marketplace": "wallapop", + "url": "https://es.wallapop.com/item/x", + "title": "Corsair Vengeance LPX 16GB", + "description": "d", + "price_eur": Decimal("88.00"), + "shipping_eur": Decimal("3.50"), + "fetched_at": _TS, + } + base.update(overrides) + return Listing(**base) # type: ignore[arg-type] + + +def test_band_listing_gets_ceiling_fit_offer() -> None: + # 88 € asking against an 80 € target: largest whole-euro O with + # O + 3.50 + (0.69 + 7.5 % O) ≀ 80 is 70 €. + listing = _listing() + offer = offer_item_price_eur( + listing, target_total_eur=Decimal("80"), assumed_shipping_eur=_BUFFER + ) + assert offer == Decimal("70") + # The fit is genuine and maximal: 70 fits the target, 71 would not. + assert buyer_total_eur( + _listing(price_eur=Decimal("70")), assumed_shipping_eur=_BUFFER + ) <= Decimal("80") + assert buyer_total_eur( + _listing(price_eur=Decimal("71")), assumed_shipping_eur=_BUFFER + ) > Decimal("80") + + +def test_under_ceiling_listing_with_default_target_yields_no_offer() -> None: + # Buyer total already fits the target β†’ the fit price lands at or above + # asking β†’ nothing to negotiate. + listing = _listing(price_eur=Decimal("55.00"), shipping_eur=Decimal("3.49")) + assert ( + offer_item_price_eur(listing, target_total_eur=Decimal("80"), assumed_shipping_eur=_BUFFER) + is None + ) + + +def test_lower_per_entry_target_activates_offers_under_the_ceiling() -> None: + # Asking 70 € (buyer total ~79.44 €, under an 80 € ceiling) with a 70 € + # target: the offer aims the delivered total at the operator's target. + listing = _listing(price_eur=Decimal("70.00")) + offer = offer_item_price_eur( + listing, target_total_eur=Decimal("70"), assumed_shipping_eur=_BUFFER + ) + assert offer == Decimal("61") + assert offer < listing.price_eur + + +def test_platform_floor_blocks_too_deep_offers() -> None: + # Fit price 33 € against a 60 € asking price is under the 70 % floor. + listing = _listing(price_eur=Decimal("60.00")) + assert ( + offer_item_price_eur(listing, target_total_eur=Decimal("40"), assumed_shipping_eur=_BUFFER) + is None + ) + # Exactly at the floor is accepted (the UI says "al menos"). + at_floor = _listing(price_eur=Decimal("100.00")) + offer = offer_item_price_eur( + at_floor, target_total_eur=Decimal("79.50"), assumed_shipping_eur=_BUFFER + ) + assert offer == Decimal("70") + assert offer == at_floor.price_eur * OFFER_PLATFORM_FLOOR_RATIO + + +def test_proteccion_flat_threshold_branch() -> None: + # Budget 16 € after shipping: 14 € pays the variable fee (0.69 + 7.5 %), + # 13 € would pay the flat 1.69 € β€” the fit must settle with the real + # schedule across the boundary, not the linear closed form alone. + listing = _listing(price_eur=Decimal("18.00")) + offer = offer_item_price_eur( + listing, target_total_eur=Decimal("19.50"), assumed_shipping_eur=_BUFFER + ) + assert offer == Decimal("14") + + +def test_unknown_shipping_uses_the_buffer() -> None: + known = offer_item_price_eur( + _listing(), target_total_eur=Decimal("80"), assumed_shipping_eur=_BUFFER + ) + unknown = offer_item_price_eur( + _listing(shipping_eur=None), target_total_eur=Decimal("80"), assumed_shipping_eur=_BUFFER + ) + assert known == unknown == Decimal("70") + + +def test_non_wallapop_listing_never_offers() -> None: + listing = _listing(marketplace="ebay", url="https://www.ebay.es/itm/x") + assert ( + offer_item_price_eur(listing, target_total_eur=Decimal("80"), assumed_shipping_eur=_BUFFER) + is None + ) + + +def test_target_below_shipping_yields_no_offer() -> None: + listing = _listing() + assert ( + offer_item_price_eur(listing, target_total_eur=Decimal("3"), assumed_shipping_eur=_BUFFER) + is None + ) diff --git a/tests/unit/test_offer_renderer_snapshots.py b/tests/unit/test_offer_renderer_snapshots.py new file mode 100644 index 0000000..addc878 --- /dev/null +++ b/tests/unit/test_offer_renderer_snapshots.py @@ -0,0 +1,45 @@ +"""Golden snapshots for every offer-flow rendering surface. + +Built FROM the variant registry (the PR #50 pattern): the fixture each +test renders is exactly what ``salvager dev emit-alert `` +dispatches, so the golden text here ≑ the on-device capture reference. +Covers the 4 offer-eligible listing shapes, ``offer_sent``, and the 12 +``offer_failure_*`` variants β€” 17 snapshots, locked formats (FR22 + +FR58-FR65). +""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion + +from salvager.cli.dev_alert_fixtures import VARIANT_REGISTRY, build_rendered_variant + +_OFFER_VARIANTS = sorted( + name + for name in VARIANT_REGISTRY + if name.startswith(("negotiable_listing", "offer_failure_")) + or name in {"offer_sent", "phase1_listing_with_offer", "phase2_listing_with_offer"} +) + + +def test_offer_variant_selection_is_complete() -> None: + # 2 negotiable shapes + 2 with-offer shapes + offer_sent + 12 failures. + assert len(_OFFER_VARIANTS) == 17 + + +@pytest.mark.parametrize("variant", _OFFER_VARIANTS) +def test_offer_variant_matches_snapshot(variant: str, snapshot: SnapshotAssertion) -> None: + rendered = build_rendered_variant(variant) + assert rendered.text == snapshot(name=f"{variant}.text") + + +@pytest.mark.parametrize("variant", _OFFER_VARIANTS) +def test_offer_variant_keyboard_matches_snapshot(variant: str, snapshot: SnapshotAssertion) -> None: + rendered = build_rendered_variant(variant) + keyboard = ( + [[button.model_dump() for button in row] for row in rendered.inline_keyboard] + if rendered.inline_keyboard is not None + else None + ) + assert keyboard == snapshot(name=f"{variant}.keyboard") diff --git a/tests/unit/test_offer_writer.py b/tests/unit/test_offer_writer.py new file mode 100644 index 0000000..984ea68 --- /dev/null +++ b/tests/unit/test_offer_writer.py @@ -0,0 +1,226 @@ +"""Offer schema migration (0004) + :class:`OfferAuditWriter` tests. + +Mirrors ``test_phase2_schema.py`` / ``test_audit_writer.py``: the 0004 +migration is additive over 0003 (existing rows untouched), `offers` rows +append with Decimals stored as text, the dedupe/daily-budget reads gate +correctly, and the independent `offer_state` lockout round-trips. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from pathlib import Path +from uuid import uuid4 + +import pytest + +from salvager.adapters.sqlite_store.connection import open_connection +from salvager.adapters.sqlite_store.migrations import MigrationRunner, db_path_under +from salvager.adapters.sqlite_store.offer_writer import OfferAuditWriter +from salvager.domain.errors import OfferFailureReason +from salvager.domain.offer_audit import OfferAttemptRecord + +_ENTRY_KEY = ("Corsair", "Vengeance LPX 16GB", "CMK16GX4M2D3000C16") + + +@pytest.fixture +def migrated_db(tmp_path: Path) -> Path: + db_path = db_path_under(tmp_path) + connection = open_connection(db_path) + MigrationRunner().run(connection) + connection.close() + return db_path + + +def _attempt(**overrides: object) -> OfferAttemptRecord: + base: dict[str, object] = { + "alert_id": uuid4(), + "listing_id": "internal-123", + "marketplace": "wallapop", + "entry_key": _ENTRY_KEY, + "offered_eur": Decimal("70"), + "asking_eur": Decimal("88.00"), + "outcome": "success", + "platform_remaining": 9, + "attempted_at": datetime.now(UTC), + } + base.update(overrides) + return OfferAttemptRecord(**base) # type: ignore[arg-type] + + +# ───────────────────────────────────────────────────────────────────────── +# Migration 0004 +# ───────────────────────────────────────────────────────────────────────── + + +def test_migration_creates_offer_tables_and_seeds_state(migrated_db: Path) -> None: + connection = open_connection(migrated_db) + try: + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + assert "offers" in tables + assert "offer_state" in tables + row = connection.execute( + "SELECT globally_disabled, consecutive_failures FROM offer_state WHERE id = 1" + ).fetchone() + assert (row[0], row[1]) == (0, 0) + finally: + connection.close() + + +def test_migration_0004_is_additive_over_existing_rows(tmp_path: Path) -> None: + # Apply only 0001-0003, insert a Phase 1 row, then apply 0004 on top. + db_path = db_path_under(tmp_path) + connection = open_connection(db_path) + try: + runner = MigrationRunner() + for version, name in runner.available_migrations(): + if version <= 3: + connection.executescript(runner._read_migration(name)) + connection.execute( + "INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', '3')" + ) + seen_at = datetime.now(UTC).isoformat() + connection.execute( + "INSERT INTO seen_listings (listing_id, entry_manufacturer, entry_model, " + "entry_ref, url, first_seen_at, last_seen_at) VALUES ('x', ?, ?, ?, 'u', ?, ?)", + (*_ENTRY_KEY, seen_at, seen_at), + ) + connection.commit() + + version = runner.run(connection) + assert version == 4 + count = connection.execute("SELECT COUNT(*) FROM seen_listings").fetchone()[0] + assert count == 1 + finally: + connection.close() + + +# ───────────────────────────────────────────────────────────────────────── +# OfferAuditWriter +# ───────────────────────────────────────────────────────────────────────── + + +async def test_record_offer_attempt_inserts_row_with_text_decimals(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + audit_id = await writer.record_offer_attempt(_attempt()) + assert audit_id > 0 + connection = open_connection(migrated_db) + try: + row = connection.execute( + "SELECT offered_eur, asking_eur, outcome, failure_reason, status, " + "platform_remaining FROM offers WHERE audit_id = ?", + (audit_id,), + ).fetchone() + finally: + connection.close() + assert tuple(row) == ("70", "88.00", "success", None, "sent", 9) + finally: + await writer.close() + + +async def test_failure_attempt_records_reason(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + audit_id = await writer.record_offer_attempt( + _attempt(outcome="failure", failure_reason=OfferFailureReason.timeout) + ) + connection = open_connection(migrated_db) + try: + row = connection.execute( + "SELECT failure_reason FROM offers WHERE audit_id = ?", (audit_id,) + ).fetchone() + finally: + connection.close() + assert row[0] == "timeout" + finally: + await writer.close() + + +async def test_has_successful_offer_dedupes_per_listing(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + assert await writer.has_successful_offer("wallapop", "internal-123") is False + # A failed attempt does not count as a sent offer. + await writer.record_offer_attempt( + _attempt(outcome="failure", failure_reason=OfferFailureReason.timeout) + ) + assert await writer.has_successful_offer("wallapop", "internal-123") is False + await writer.record_offer_attempt(_attempt()) + assert await writer.has_successful_offer("wallapop", "internal-123") is True + # Listing ids are only unique per marketplace. + assert await writer.has_successful_offer("ebay", "internal-123") is False + finally: + await writer.close() + + +async def test_count_recent_successes_rolls_off_after_24h(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + now = datetime.now(UTC) + await writer.record_offer_attempt(_attempt(attempted_at=now - timedelta(hours=25))) + await writer.record_offer_attempt( + _attempt(listing_id="other", attempted_at=now - timedelta(hours=1)) + ) + # Aborts and failures never consume budget. + await writer.record_offer_attempt( + _attempt( + listing_id="failed", + outcome="failure", + failure_reason=OfferFailureReason.timeout, + attempted_at=now, + ) + ) + assert await writer.count_recent_successes(now=now) == 1 + finally: + await writer.close() + + +async def test_lockout_round_trip_and_enable_resets_counter(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + assert (await writer.read_state()).globally_disabled is False + assert await writer.increment_failure_counter() == 1 + assert await writer.increment_failure_counter() == 2 + await writer.set_global_disable("offer_lockout_threshold") + state = await writer.read_state() + assert state.globally_disabled is True + assert state.disabled_reason == "offer_lockout_threshold" + assert state.consecutive_failures == 2 + + await writer.clear_global_disable(_ENTRY_KEY) + state = await writer.read_state() + assert state.globally_disabled is False + assert state.disabled_reason is None + # Re-enabling is a fresh start, not threshold-minus-one. + assert state.consecutive_failures == 0 + finally: + await writer.close() + + +async def test_success_resets_failure_counter(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + try: + await writer.increment_failure_counter() + await writer.reset_failure_counter() + assert (await writer.read_state()).consecutive_failures == 0 + finally: + await writer.close() + + +async def test_state_survives_a_fresh_writer_instance(migrated_db: Path) -> None: + writer = OfferAuditWriter(migrated_db) + await writer.increment_failure_counter() + await writer.close() + + reopened = OfferAuditWriter(migrated_db) + try: + assert (await reopened.read_state()).consecutive_failures == 1 + finally: + await reopened.close() diff --git a/tests/unit/test_operational_alert_renderer.py b/tests/unit/test_operational_alert_renderer.py index 47eca79..f6ce06e 100644 --- a/tests/unit/test_operational_alert_renderer.py +++ b/tests/unit/test_operational_alert_renderer.py @@ -110,6 +110,29 @@ "alert_id": "12345678-1234-1234-1234-123456789abc", }, ), + EventName.offer_lockout_engaged: ( + "warn", + { + "consecutive_failures": 3, + "threshold": 3, + "last_affected_entry": "Corsair Vengeance LPX 16GB / CMK16GX4M2D3000C16", + }, + ), + EventName.offer_disabled: ( + "warn", + {"reason": "kill_switch_global"}, + ), + EventName.offer_re_enabled: ( + "info", + {"entry": "Corsair Vengeance LPX 16GB / CMK16GX4M2D3000C16"}, + ), + EventName.offer_orchestrator_error: ( + "warn", + { + "error_class": "TinyFishSessionLost", + "alert_id": "12345678-1234-1234-1234-123456789abc", + }, + ), } diff --git a/tests/unit/test_phase2_schema.py b/tests/unit/test_phase2_schema.py index 4b1bc36..eadaa3a 100644 --- a/tests/unit/test_phase2_schema.py +++ b/tests/unit/test_phase2_schema.py @@ -32,7 +32,7 @@ def test_migration_creates_phase2_tables_and_advances_version(tmp_path: Path) -> finally: connection.close() - assert version == 3 + assert version == 4 tables = _table_names(db_path) for table in _PHASE2_TABLES: assert table in tables, f"migration 0002 must create {table!r}" @@ -74,7 +74,7 @@ def test_migration_is_idempotent(tmp_path: Path) -> None: version = MigrationRunner().run(connection) finally: connection.close() - assert version == 3 + assert version == 4 # Re-running never duplicates the seeded single-row state. connection = open_connection(db_path) diff --git a/tests/unit/test_sqlite_store.py b/tests/unit/test_sqlite_store.py index 627e048..7bed0b4 100644 --- a/tests/unit/test_sqlite_store.py +++ b/tests/unit/test_sqlite_store.py @@ -137,8 +137,8 @@ def test_migration_runner_applies_pending_migrations(tmp_path: Path) -> None: connection = open_connection(tmp_path / "salvager.db") try: version = MigrationRunner().run(connection) - assert version == 3 - assert MigrationRunner().current_version(connection) == 3 + assert version == 4 + assert MigrationRunner().current_version(connection) == 4 finally: connection.close() @@ -211,7 +211,7 @@ def test_migration_runner_persists_schema_version(migrated_db: Path) -> None: connection = open_connection(migrated_db) try: row = connection.execute("SELECT value FROM _meta WHERE key='schema_version'").fetchone() - assert int(row[0]) == 3 + assert int(row[0]) == 4 finally: connection.close() diff --git a/tests/unit/test_tinyfish_offer_flow.py b/tests/unit/test_tinyfish_offer_flow.py new file mode 100644 index 0000000..a9af25f --- /dev/null +++ b/tests/unit/test_tinyfish_offer_flow.py @@ -0,0 +1,221 @@ +"""Tests for the Wallapop offer flow (wallapop-offer-flow). + +Same seam as the buy-flow tests: a fake async TinyFish client records +every ``agent.run`` call and returns a preloaded response (or raises a +preloaded exception). Coverage: happy path (with and without a +screenshot), every agent-reported failure outcome, SDK errors, echoed- +amount mismatch, malformed payload, wrong-marketplace guard, and the +goal contract's structural clauses. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +import httpx +import pytest +from pydantic import SecretStr +from tinyfish import AgentRunResponse, RateLimitError, RunStatus, SDKError + +from salvager.adapters.tinyfish_browser import WallapopOfferFlow +from salvager.adapters.tinyfish_browser.wallapop_offer import ( + OFFER_OUTPUT_CONTRACT, + render_offer_goal, +) +from salvager.domain.errors import OfferFailureReason +from salvager.domain.listing import Listing +from salvager.interfaces.offer_session import OfferSendFailure, OfferSuccess + +_FAKE_KEY = SecretStr("sk-tinyfish-fake-deadbeefcafebabe0123456789abcdef") +_FIXED_TS = datetime(2026, 7, 22, 12, 0, 0, tzinfo=UTC) +_AMOUNT = Decimal("70") + + +class _FakeAgent: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + self.next_response: AgentRunResponse | None = None + self.next_exception: BaseException | None = None + + async def run(self, *, goal: str, url: str, **kwargs: Any) -> AgentRunResponse: + self.calls.append({"goal": goal, "url": url, **kwargs}) + if self.next_exception is not None: + raise self.next_exception + assert self.next_response is not None, "test forgot to preload a response" + return self.next_response + + +class _FakeClient: + def __init__(self) -> None: + self.agent = _FakeAgent() + self.closed = False + + async def close(self) -> None: + self.closed = True + + +def _make_response( + *, + status: RunStatus = RunStatus.COMPLETED, + result: dict[str, Any] | None = None, + run_id: str = "run-offer-abc", +) -> AgentRunResponse: + return AgentRunResponse( + status=status, + run_id=run_id, + result=result, + error=None, + num_of_steps=9, + started_at=_FIXED_TS, + finished_at=_FIXED_TS, + ) + + +def _listing(marketplace: str = "wallapop") -> Listing: + return Listing( + listing_id="internal-123", + marketplace=marketplace, # type: ignore[arg-type] + url="https://es.wallapop.com/item/corsair-abc", + title="Corsair Vengeance LPX 16GB", + description="d", + price_eur=Decimal("88.00"), + fetched_at=_FIXED_TS, + ) + + +def _flow() -> tuple[WallapopOfferFlow, _FakeClient]: + client = _FakeClient() + flow = WallapopOfferFlow(_FAKE_KEY, client=client) # type: ignore[arg-type] + return flow, client + + +async def test_happy_path_returns_success_with_counter() -> None: + flow, client = _flow() + client.agent.next_response = _make_response( + result={ + "outcome": "success", + "offered_eur": "70", + "screenshot_url": "https://shots/offer.png", + "platform_remaining": 9, + } + ) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSuccess) + assert result.offered_eur == _AMOUNT + assert result.screenshot_url == "https://shots/offer.png" + assert result.platform_remaining == 9 + + +async def test_verified_send_without_screenshot_is_still_success() -> None: + flow, client = _flow() + client.agent.next_response = _make_response( + result={"outcome": "success", "offered_eur": "70", "screenshot_url": None} + ) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSuccess) + assert result.screenshot_url is None + + +@pytest.mark.parametrize( + ("outcome", "reason"), + [ + ("offer_unavailable", OfferFailureReason.offer_unavailable), + ("amount_rejected", OfferFailureReason.amount_rejected), + ("daily_limit_reached", OfferFailureReason.daily_limit_reached), + ("missing_element", OfferFailureReason.missing_element), + ("screenshot_missing", OfferFailureReason.screenshot_missing), + ("marketplace_error", OfferFailureReason.marketplace_error), + ("timeout", OfferFailureReason.timeout), + ("ui_check_failed", OfferFailureReason.ui_check_failed), + ], +) +async def test_agent_failure_outcomes_map_to_reasons( + outcome: str, reason: OfferFailureReason +) -> None: + flow, client = _flow() + client.agent.next_response = _make_response( + result={"outcome": outcome, "detail": "x", "missing": ["offer_button"]} + ) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is reason + assert result.ctx.get("detail") == "x" + + +async def test_exhausted_platform_counter_carries_remaining_zero() -> None: + flow, client = _flow() + client.agent.next_response = _make_response( + result={"outcome": "daily_limit_reached", "platform_remaining": 0} + ) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.daily_limit_reached + assert result.ctx["platform_remaining"] == 0 + + +async def test_echoed_amount_mismatch_is_ui_check_failed() -> None: + flow, client = _flow() + client.agent.next_response = _make_response(result={"outcome": "success", "offered_eur": "65"}) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.ui_check_failed + assert result.ctx["expected"] == "70" + + +async def test_malformed_payload_is_marketplace_error() -> None: + flow, client = _flow() + client.agent.next_response = _make_response(result={"outcome": "nonsense", "extra": 1}) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.marketplace_error + + +async def test_sdk_error_is_marketplace_error() -> None: + flow, client = _flow() + client.agent.next_exception = SDKError("boom") + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.marketplace_error + + +async def test_rate_limit_is_marketplace_error_with_detail() -> None: + flow, client = _flow() + client.agent.next_exception = RateLimitError( + "slow down", + response=httpx.Response( + status_code=429, + content=b"{}", + request=httpx.Request("POST", "https://agent.tinyfish.ai/v1/automation/run"), + ), + ) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.marketplace_error + assert result.ctx["detail"] == "tinyfish_rate_limited" + + +async def test_incomplete_run_is_marketplace_error() -> None: + flow, client = _flow() + client.agent.next_response = _make_response(status=RunStatus.FAILED, result=None) + result = await flow.execute_offer(_listing(), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.marketplace_error + + +async def test_wrong_marketplace_is_refused_without_agent_call() -> None: + flow, client = _flow() + result = await flow.execute_offer(_listing(marketplace="ebay"), _AMOUNT) + assert isinstance(result, OfferSendFailure) + assert result.reason is OfferFailureReason.marketplace_error + assert client.agent.calls == [] + + +def test_goal_embeds_amount_contract_and_platform_rules() -> None: + goal = render_offer_goal(_AMOUNT) + assert "EXACTLY 70 EUR" in goal + assert OFFER_OUTPUT_CONTRACT in goal + assert "Hacer oferta" in goal + assert "ofertas restantes" in goal + assert "do NOT buy" in goal diff --git a/tests/unit/test_wallapop_api_fetcher.py b/tests/unit/test_wallapop_api_fetcher.py index ba4be00..1e4b1f8 100644 --- a/tests/unit/test_wallapop_api_fetcher.py +++ b/tests/unit/test_wallapop_api_fetcher.py @@ -248,6 +248,34 @@ def handler(_: _RecordedRequest) -> WallapopResponse: assert listings[1].is_reserved is False +@pytest.mark.asyncio +async def test_search_maps_refurbished_flag_to_is_refurbished(tmp_path: Path) -> None: + """``is_refurbished: {flag: true}`` must surface as + ``Listing.is_refurbished=True``; ``flag: false`` and a missing + envelope both surface as False. The offer surface pre-filters on + this β€” refurbished listings don't accept offers (wallapop-offer-flow, + live-probed 2026-07-22). + """ + + def handler(_: _RecordedRequest) -> WallapopResponse: + payload = _valid_search_payload() + items = payload["data"]["section"]["items"] + items[0]["is_refurbished"] = {"flag": True} + items[1]["is_refurbished"] = {"flag": False} + return WallapopResponse(status_code=200, text=json.dumps(payload), json_data=payload) + + fetcher = _build_fetcher(tmp_path, handler) + try: + listings = await fetcher.search( + SearchQuery(keyword="WD Red Plus 4TB", marketplace="wallapop") + ) + finally: + await fetcher.aclose() + + assert listings[0].is_refurbished is True + assert listings[1].is_refurbished is False + + # ───────────────────────────────────────────────────────────────────────── # Error mapping (NFR-I4) # ───────────────────────────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index 63e5a23..a65df82 100644 --- a/uv.lock +++ b/uv.lock @@ -1178,7 +1178,7 @@ wheels = [ [[package]] name = "salvager" -version = "0.4.3" +version = "0.4.4" source = { editable = "." } dependencies = [ { name = "anthropic" }, diff --git a/wishlist.example.yaml b/wishlist.example.yaml index ef0f4b1..3eabb00 100644 --- a/wishlist.example.yaml +++ b/wishlist.example.yaml @@ -38,6 +38,12 @@ # container_keywords: list[str] Wrapper-listing search terms (NAS, mini-PC) # phase2.enabled: bool Default false; toggle via CLI never YAML # phase2.max_price_eur: decimal Per-entry Phase 2 hard ceiling (FR26) +# offer.enabled: bool Default false; toggle via CLI never YAML. +# Enables the Wallapop πŸ’° Ofertar surface +# (offer button + negotiable-band alerts). +# offer.target_total_eur: decimal Aim offers at a delivered total BELOW the +# ceiling ("I'd accept 80 € but want to pay +# 70 €"). Unset = target the ceiling. entries: