walla-cli 0.1.6__tar.gz → 0.1.7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {walla_cli-0.1.6 → walla_cli-0.1.7}/AGENTS.md +2 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/PKG-INFO +1 -1
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/WIRE.md +9 -5
- walla_cli-0.1.7/fixtures/offer_buyer_request.json +19 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/pyproject.toml +1 -1
- {walla_cli-0.1.6 → walla_cli-0.1.7}/scripts/check_code_quality.py +55 -2
- {walla_cli-0.1.6 → walla_cli-0.1.7}/skills/walla/SKILL.md +4 -2
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_account.py +16 -19
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_account_more.py +28 -9
- walla_cli-0.1.7/tests/test_chat_open.py +67 -0
- walla_cli-0.1.7/tests/test_regressions.py +132 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/__init__.py +1 -1
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/actions.py +10 -11
- walla_cli-0.1.7/walla/account/chat.py +136 -0
- walla_cli-0.1.7/walla/account/inbox.py +151 -0
- walla_cli-0.1.7/walla/account/offer_wire.py +49 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/cmd_account.py +24 -2
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/instruct.py +10 -3
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/http/headers.py +5 -2
- walla_cli-0.1.6/walla/account/inbox.py +0 -147
- {walla_cli-0.1.6 → walla_cli-0.1.7}/.cursor/rules/quality.mdc +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/.github/workflows/ci.yml +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/.gitignore +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/LICENSE +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/README.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/assets/walla-github-banner.jpg +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/code_quality.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/data_engineering_standards.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/mcp.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/objective_function.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/docs/rate_limit_analysis.md +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/fixtures/categories.json +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/fixtures/item_detail.json +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/fixtures/search_bicicleta.json +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/scripts/dogfood_run.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/scripts/probe_rate.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_catch.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_cli_json.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_core.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_edges.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_export.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_http_more.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_live.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_negotiate.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_parsers.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_polite.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_search_api.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_session_store.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_shortlist.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/tests/test_verdict.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/cookies.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/login.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/prompt.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/account/session_store.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/banner.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/catch.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/cmd_look.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/cmd_ops.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/console.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/cli/main.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/envelope.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/errors.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/exceptions.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/human_fix.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/core/path.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/http/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/http/client.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/http/polite.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/export.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/export_pdf.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/negotiate.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/profile_store.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/shortlist.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/verdict.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/hunter/watches.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/mcp/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/mcp/_entry.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/mcp/server.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/models/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/models/account.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/models/listing.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/models/profile.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/search/__init__.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/search/api.py +0 -0
- {walla_cli-0.1.6 → walla_cli-0.1.7}/walla/search/parse.py +0 -0
|
@@ -60,4 +60,6 @@ Ranks: models 10 → exceptions 15 → http 20 → core 30 → search/account/hu
|
|
|
60
60
|
- **Do** require `--yes` for say/offer.
|
|
61
61
|
- **Don't** commit session.json, cookies, or tokens.
|
|
62
62
|
- **Don't** invent wire shapes without a fixture.
|
|
63
|
+
- **Don't** post buyer offers as `{item_id, amount, currency}` (HTTP 400).
|
|
64
|
+
Use `walla.account.offer_wire.build_offer_body` / `fixtures/offer_buyer_request.json`.
|
|
63
65
|
- **Don't** require `.env` credentials; login is cookie paste.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: walla-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Wallapop.es CLI - look · offer · talk for humans and agents
|
|
5
5
|
Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/berendgort/wallapop-cli
|
|
@@ -44,13 +44,17 @@ login ever succeeds.
|
|
|
44
44
|
|
|
45
45
|
## Messaging / offers (auth)
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- Inbox: `GET /api/v3/conversations
|
|
50
|
-
-
|
|
47
|
+
- Open chat (listing **Chat** button): `POST /api/v3/conversations`
|
|
48
|
+
body `{ "item_id": "<hash>" }` → `{ conversation_id, item_id, other_user_id, channel }`.
|
|
49
|
+
- Inbox: `GET /bff/messaging/inbox?page_size=&max_messages=` (not `/api/v3/conversations`, 405).
|
|
50
|
+
- Send text: PubNub publish after `GET /api/v3/instant-messaging/token`
|
|
51
|
+
(REST `…/messages` is 404). See `walla.account.chat.publish_text`.
|
|
51
52
|
- Offers: `POST /api/v3/delivery/buyer/offers` with
|
|
52
53
|
`{offer_id, offer_price_amount, offer_price_currency, item_ids}`.
|
|
53
|
-
|
|
54
|
+
Requires header `X-AppVersion` (without it Wallapop returns a generic HTTP 400).
|
|
55
|
+
Fixture: `fixtures/offer_buyer_request.json`. The old
|
|
56
|
+
`{item_id, amount, currency}` body returns **HTTP 400** — forbidden.
|
|
57
|
+
Some sellers disable offers → **409** `offer creation not allowed`; fall back to chat text.
|
|
54
58
|
- Favorites: `/api/v3/users/me/favorites`, `/api/v3/items/{id}/favorite`
|
|
55
59
|
|
|
56
60
|
Writes that fail closed as `error_type: unsupported` until fixtures exist.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"path": "/api/v3/delivery/buyer/offers",
|
|
4
|
+
"method": "POST",
|
|
5
|
+
"required_keys": [
|
|
6
|
+
"offer_id",
|
|
7
|
+
"offer_price_amount",
|
|
8
|
+
"offer_price_currency",
|
|
9
|
+
"item_ids"
|
|
10
|
+
],
|
|
11
|
+
"forbidden_keys": ["item_id", "amount", "currency"],
|
|
12
|
+
"example": {
|
|
13
|
+
"offer_id": "00000000-0000-4000-8000-000000000001",
|
|
14
|
+
"offer_price_amount": 650,
|
|
15
|
+
"offer_price_currency": "EUR",
|
|
16
|
+
"item_ids": ["wzv48vgrmdzl"]
|
|
17
|
+
},
|
|
18
|
+
"notes": "Old {item_id, amount, currency} returns HTTP 400. Never invent without this fixture."
|
|
19
|
+
}
|
|
@@ -109,14 +109,67 @@ def check_layers() -> list[str]:
|
|
|
109
109
|
return bad
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
def check_offer_fixture() -> list[str]:
|
|
113
|
+
"""Keep offer wire constants in sync with fixtures/offer_buyer_request.json."""
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
wire = ROOT / "walla" / "account" / "offer_wire.py"
|
|
117
|
+
if not wire.is_file():
|
|
118
|
+
return ["walla/account/offer_wire.py missing"]
|
|
119
|
+
tree = ast.parse(wire.read_text(encoding="utf-8"))
|
|
120
|
+
consts: dict[str, tuple[str, ...]] = {}
|
|
121
|
+
for node in tree.body:
|
|
122
|
+
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
|
123
|
+
continue
|
|
124
|
+
target = node.targets[0]
|
|
125
|
+
if not isinstance(target, ast.Name):
|
|
126
|
+
continue
|
|
127
|
+
if target.id not in ("REQUIRED_OFFER_KEYS", "FORBIDDEN_OFFER_KEYS"):
|
|
128
|
+
continue
|
|
129
|
+
if not isinstance(node.value, ast.Tuple):
|
|
130
|
+
continue
|
|
131
|
+
vals: list[str] = []
|
|
132
|
+
for elt in node.value.elts:
|
|
133
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
134
|
+
vals.append(elt.value)
|
|
135
|
+
consts[target.id] = tuple(vals)
|
|
136
|
+
required = consts.get("REQUIRED_OFFER_KEYS")
|
|
137
|
+
forbidden = consts.get("FORBIDDEN_OFFER_KEYS")
|
|
138
|
+
if not required or not forbidden:
|
|
139
|
+
return ["offer_wire.py missing REQUIRED/FORBIDDEN tuples"]
|
|
140
|
+
path = ROOT / "fixtures" / "offer_buyer_request.json"
|
|
141
|
+
if not path.is_file():
|
|
142
|
+
return ["fixtures/offer_buyer_request.json missing"]
|
|
143
|
+
meta = json.loads(path.read_text(encoding="utf-8"))
|
|
144
|
+
bad: list[str] = []
|
|
145
|
+
if tuple(meta.get("required_keys") or ()) != required:
|
|
146
|
+
bad.append("offer fixture required_keys != offer_wire.REQUIRED_OFFER_KEYS")
|
|
147
|
+
if tuple(meta.get("forbidden_keys") or ()) != forbidden:
|
|
148
|
+
bad.append("offer fixture forbidden_keys != offer_wire.FORBIDDEN_OFFER_KEYS")
|
|
149
|
+
example = meta.get("example") or {}
|
|
150
|
+
for k in forbidden:
|
|
151
|
+
if k in example:
|
|
152
|
+
bad.append(f"offer fixture example contains forbidden key {k}")
|
|
153
|
+
for k in required:
|
|
154
|
+
if k not in example:
|
|
155
|
+
bad.append(f"offer fixture example missing {k}")
|
|
156
|
+
return bad
|
|
157
|
+
|
|
158
|
+
|
|
112
159
|
def main() -> int:
|
|
113
|
-
errors =
|
|
160
|
+
errors = (
|
|
161
|
+
check_loc()
|
|
162
|
+
+ check_emdash()
|
|
163
|
+
+ check_print()
|
|
164
|
+
+ check_layers()
|
|
165
|
+
+ check_offer_fixture()
|
|
166
|
+
)
|
|
114
167
|
if errors:
|
|
115
168
|
print("FAILED code quality:")
|
|
116
169
|
for e in errors:
|
|
117
170
|
print(f" {e}")
|
|
118
171
|
return 1
|
|
119
|
-
print("OK: LOC<=250, no em-dash, no bare print, layer DAG")
|
|
172
|
+
print("OK: LOC<=250, no em-dash, no bare print, layer DAG, offer wire")
|
|
120
173
|
return 0
|
|
121
174
|
|
|
122
175
|
|
|
@@ -36,7 +36,9 @@ walla setup --lat 41.39 --lon 2.17 --km 30 --budget 400 \
|
|
|
36
36
|
every `https://es.wallapop.com/item/…` URL. Never summarize without links.
|
|
37
37
|
3. One GRAB → `walla negotiate <id> --json`. Several GRABs → ask human to pick an id.
|
|
38
38
|
4. Show opening + offer_eur + walk_away. Human approves text and EUR.
|
|
39
|
-
5.
|
|
40
|
-
6.
|
|
39
|
+
5. Open chat if needed: `walla chat <item_id> --json` (listing Chat button).
|
|
40
|
+
6. Only then: `walla say <item_id> "…" --yes` and/or `walla offer … --eur N --yes`
|
|
41
|
+
If offer returns 409 (seller disabled offers), put the EUR in the chat text.
|
|
42
|
+
7. **Stop.** Human pays or meets in the Wallapop app. `--yes` never means buy.
|
|
41
43
|
|
|
42
44
|
Reads and negotiate drafts are safe. Sends need confirm.
|
|
@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, patch
|
|
|
9
9
|
import pytest
|
|
10
10
|
|
|
11
11
|
from walla.account.actions import quote_offer
|
|
12
|
-
from walla.account.inbox import
|
|
12
|
+
from walla.account.inbox import list_conversations, list_messages
|
|
13
13
|
from walla.account.login import login_password
|
|
14
14
|
from walla.core.exceptions import WallaAuthError, WallaUnsupportedError
|
|
15
15
|
from walla.http.client import HttpClient
|
|
@@ -35,36 +35,33 @@ def test_quote_offer_totals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
|
|
35
35
|
assert q.within_pickup is True
|
|
36
36
|
|
|
37
37
|
|
|
38
|
-
def
|
|
39
|
-
rows = _extract_list({"data": {"conversations": [{"id": "c1", "unread": 2}]}})
|
|
40
|
-
assert rows[0]["id"] == "c1"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_extract_messages() -> None:
|
|
44
|
-
msgs = _extract_messages({"messages": [{"id": "m1", "text": "hola", "from_self": True}]})
|
|
45
|
-
assert msgs is not None
|
|
46
|
-
assert msgs[0].text == "hola"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_list_conversations_mock() -> None:
|
|
38
|
+
def test_list_conversations_bff_mock() -> None:
|
|
50
39
|
class C(HttpClient):
|
|
51
40
|
def request(self, method: str, path: str, **kwargs: Any) -> Any: # type: ignore[override]
|
|
41
|
+
assert path == "/bff/messaging/inbox"
|
|
52
42
|
return {
|
|
43
|
+
"user_hash": "me",
|
|
53
44
|
"conversations": [
|
|
54
45
|
{
|
|
55
|
-
"
|
|
56
|
-
"item": {"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
46
|
+
"hash": "c1",
|
|
47
|
+
"item": {"hash": "i1", "title": "Bike"},
|
|
48
|
+
"with_user": {"hash": "u2", "name": "Ana"},
|
|
49
|
+
"unread_messages": 1,
|
|
50
|
+
"channel": "chat.u2.c1.me",
|
|
51
|
+
"messages": {
|
|
52
|
+
"messages": [{"id": "m1", "text": "hola", "from_self": False}]
|
|
53
|
+
},
|
|
60
54
|
}
|
|
61
|
-
]
|
|
55
|
+
],
|
|
62
56
|
}
|
|
63
57
|
|
|
64
58
|
with patch("walla.account.inbox._auth_client", return_value=C()):
|
|
65
59
|
rows = list_conversations()
|
|
60
|
+
msgs = list_messages("c1")
|
|
66
61
|
assert rows[0].id == "c1"
|
|
67
62
|
assert rows[0].item_title == "Bike"
|
|
63
|
+
assert rows[0].other_user == "Ana"
|
|
64
|
+
assert msgs[0].text == "hola"
|
|
68
65
|
|
|
69
66
|
|
|
70
67
|
def test_login_password_missing_args() -> None:
|
|
@@ -138,15 +138,34 @@ def test_list_messages_and_send() -> None:
|
|
|
138
138
|
|
|
139
139
|
class C(HttpClient):
|
|
140
140
|
def request(self, method: str, path: str, **kwargs: Any) -> Any: # type: ignore[override]
|
|
141
|
-
if
|
|
142
|
-
return {
|
|
143
|
-
|
|
141
|
+
if path == "/bff/messaging/inbox":
|
|
142
|
+
return {
|
|
143
|
+
"user_hash": "me",
|
|
144
|
+
"conversations": [
|
|
145
|
+
{
|
|
146
|
+
"hash": "c1",
|
|
147
|
+
"item": {"hash": "i1", "title": "Bike"},
|
|
148
|
+
"with_user": {"hash": "u2", "name": "Ana"},
|
|
149
|
+
"channel": "chat.u2.c1.me",
|
|
150
|
+
"messages": {
|
|
151
|
+
"messages": [{"id": "1", "text": "hi", "from_self": False}]
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
if path == "/api/v3/instant-messaging/token":
|
|
157
|
+
return {"token": "pn-token"}
|
|
158
|
+
return {}
|
|
144
159
|
|
|
145
160
|
with patch("walla.account.inbox._auth_client", return_value=C()):
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
161
|
+
with patch("walla.account.chat._auth_client", return_value=C()):
|
|
162
|
+
with patch("walla.account.chat.requests.get") as pn:
|
|
163
|
+
pn.return_value = MagicMock(status_code=200, text="[1,\"Sent\",\"0\"]")
|
|
164
|
+
msgs = list_messages("c1")
|
|
165
|
+
assert msgs[0].text == "hi"
|
|
166
|
+
out = send_message("c1", "hola", confirm=True)
|
|
167
|
+
assert out["sent"] is True
|
|
168
|
+
assert pn.called
|
|
150
169
|
|
|
151
170
|
|
|
152
171
|
def test_list_messages_unsupported() -> None:
|
|
@@ -154,11 +173,11 @@ def test_list_messages_unsupported() -> None:
|
|
|
154
173
|
|
|
155
174
|
class C(HttpClient):
|
|
156
175
|
def request(self, method: str, path: str, **kwargs: Any) -> Any: # type: ignore[override]
|
|
157
|
-
|
|
176
|
+
return {"user_hash": "me", "conversations": []}
|
|
158
177
|
|
|
159
178
|
with patch("walla.account.inbox._auth_client", return_value=C()):
|
|
160
179
|
with pytest.raises(WallaUnsupportedError):
|
|
161
|
-
list_messages("
|
|
180
|
+
list_messages("missing")
|
|
162
181
|
|
|
163
182
|
|
|
164
183
|
def test_watches_roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Chat open + offer-disabled regressions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from walla.account.actions import make_offer
|
|
11
|
+
from walla.account.chat import open_conversation
|
|
12
|
+
from walla.http.headers import default_headers
|
|
13
|
+
from walla.hunter.profile_store import save_profile
|
|
14
|
+
from walla.models.listing import Listing, ListingLocation, Money
|
|
15
|
+
from walla.models.profile import Profile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_headers_include_app_version() -> None:
|
|
19
|
+
h = default_headers(device_id="d")
|
|
20
|
+
assert h["X-AppVersion"] == "825980"
|
|
21
|
+
assert "es-ES" in h["Accept-Language"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_open_conversation_posts_item_id() -> None:
|
|
25
|
+
client = MagicMock()
|
|
26
|
+
client.post.return_value = {
|
|
27
|
+
"conversation_id": "conv1",
|
|
28
|
+
"item_id": "item1",
|
|
29
|
+
"other_user_id": "user2",
|
|
30
|
+
"channel": "chat.user2.conv1.me",
|
|
31
|
+
}
|
|
32
|
+
out = open_conversation("item1", client=client)
|
|
33
|
+
assert out["conversation_id"] == "conv1"
|
|
34
|
+
assert out["channel"].startswith("chat.")
|
|
35
|
+
body = client.post.call_args.kwargs["json_body"]
|
|
36
|
+
assert body == {"item_id": "item1"}
|
|
37
|
+
assert "chat?itemId=item1" in out["chat_url"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_make_offer_409_points_to_say(
|
|
41
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
42
|
+
) -> None:
|
|
43
|
+
monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
|
|
44
|
+
save_profile(
|
|
45
|
+
Profile(lat=41.39, lon=2.17, pickup_km=50, budget=850),
|
|
46
|
+
path=tmp_path / "profile.json",
|
|
47
|
+
)
|
|
48
|
+
item = Listing(
|
|
49
|
+
id="wzv48vgrmdzl",
|
|
50
|
+
title="North Reach",
|
|
51
|
+
price=Money(amount=800, currency="EUR"),
|
|
52
|
+
web_slug="north-reach",
|
|
53
|
+
url="https://es.wallapop.com/item/north-reach",
|
|
54
|
+
shippable=True,
|
|
55
|
+
user_allows_shipping=True,
|
|
56
|
+
location=ListingLocation(latitude=39.5, longitude=2.7),
|
|
57
|
+
)
|
|
58
|
+
with patch("walla.account.actions.get_item", return_value=item):
|
|
59
|
+
with patch("walla.account.actions._auth_client") as auth:
|
|
60
|
+
client = MagicMock()
|
|
61
|
+
client.post.side_effect = RuntimeError(
|
|
62
|
+
'HTTP 409: [{"error_code":"offer creation not allowed"}]'
|
|
63
|
+
)
|
|
64
|
+
auth.return_value = client
|
|
65
|
+
with pytest.raises(Exception, match="walla say") as exc:
|
|
66
|
+
make_offer("wzv48vgrmdzl", 656, confirm=True)
|
|
67
|
+
assert "disabled" in str(exc.value).lower() or "409" in str(exc.value)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Regression locks for past agent/wire failures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from walla.account.actions import make_offer
|
|
12
|
+
from walla.account.offer_wire import (
|
|
13
|
+
FORBIDDEN_OFFER_KEYS,
|
|
14
|
+
REQUIRED_OFFER_KEYS,
|
|
15
|
+
assert_offer_body,
|
|
16
|
+
build_offer_body,
|
|
17
|
+
)
|
|
18
|
+
from walla.core.instruct import instruct_recipe
|
|
19
|
+
from walla.hunter.negotiate import draft_negotiation
|
|
20
|
+
from walla.hunter.profile_store import save_profile
|
|
21
|
+
from walla.hunter.shortlist import PRESENT_RULE, rank_shortlist
|
|
22
|
+
from walla.hunter.verdict import score_listing
|
|
23
|
+
from walla.models.listing import Listing, ListingLocation, Money
|
|
24
|
+
from walla.models.profile import Profile
|
|
25
|
+
|
|
26
|
+
FIX = Path(__file__).resolve().parents[1] / "fixtures"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _listing(**kwargs: object) -> Listing:
|
|
30
|
+
base: dict[str, object] = dict(
|
|
31
|
+
id="wzv48vgrmdzl",
|
|
32
|
+
title="North Reach 13m 2023",
|
|
33
|
+
price=Money(amount=800, currency="EUR"),
|
|
34
|
+
web_slug="north-reach-13m-2023",
|
|
35
|
+
url="https://es.wallapop.com/item/north-reach-13m-2023",
|
|
36
|
+
shippable=True,
|
|
37
|
+
user_allows_shipping=True,
|
|
38
|
+
location=ListingLocation(latitude=39.53, longitude=2.72, city="Mallorca"),
|
|
39
|
+
)
|
|
40
|
+
base.update(kwargs)
|
|
41
|
+
return Listing(**base) # type: ignore[arg-type]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_offer_fixture_matches_builder() -> None:
|
|
45
|
+
meta = json.loads((FIX / "offer_buyer_request.json").read_text(encoding="utf-8"))
|
|
46
|
+
assert tuple(meta["required_keys"]) == REQUIRED_OFFER_KEYS
|
|
47
|
+
assert tuple(meta["forbidden_keys"]) == FORBIDDEN_OFFER_KEYS
|
|
48
|
+
assert_offer_body(meta["example"])
|
|
49
|
+
body = build_offer_body("wzv48vgrmdzl", 650, offer_id=meta["example"]["offer_id"])
|
|
50
|
+
assert set(body) == set(REQUIRED_OFFER_KEYS)
|
|
51
|
+
for k in FORBIDDEN_OFFER_KEYS:
|
|
52
|
+
assert k not in body
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_old_offer_shape_is_rejected() -> None:
|
|
56
|
+
with pytest.raises(ValueError, match="forbidden"):
|
|
57
|
+
assert_offer_body({"item_id": "x", "amount": 650, "currency": "EUR"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_make_offer_posts_fixture_shape(
|
|
61
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
62
|
+
) -> None:
|
|
63
|
+
monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
|
|
64
|
+
save_profile(
|
|
65
|
+
Profile(lat=41.39, lon=2.17, pickup_km=50, budget=850),
|
|
66
|
+
path=tmp_path / "profile.json",
|
|
67
|
+
)
|
|
68
|
+
with patch("walla.account.actions.get_item", return_value=_listing()):
|
|
69
|
+
with patch("walla.account.actions._auth_client") as auth:
|
|
70
|
+
client = MagicMock()
|
|
71
|
+
client.post.return_value = {"ok": True}
|
|
72
|
+
auth.return_value = client
|
|
73
|
+
out = make_offer("wzv48vgrmdzl", 650, confirm=True)
|
|
74
|
+
body = client.post.call_args.kwargs["json_body"]
|
|
75
|
+
assert_offer_body(body)
|
|
76
|
+
assert body["offer_price_amount"] == 650
|
|
77
|
+
assert out["offer_id"] == body["offer_id"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_fair_open_not_soft_90pct() -> None:
|
|
81
|
+
profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=850)
|
|
82
|
+
brief = draft_negotiation(_listing(), profile)
|
|
83
|
+
assert brief.offer_eur == 656
|
|
84
|
+
assert brief.offer_eur < 800 * 0.90
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_shipping_draft_never_says_paso_hoy() -> None:
|
|
88
|
+
profile = Profile(lat=41.39, lon=2.17, km=30, pickup_km=30, budget=850)
|
|
89
|
+
brief = draft_negotiation(_listing(), profile)
|
|
90
|
+
blob = f"{brief.opening}\n{brief.offer_line}".lower()
|
|
91
|
+
assert "envío" in blob
|
|
92
|
+
assert "pasar hoy" not in blob
|
|
93
|
+
assert "recojo" not in blob
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_spain_default_keeps_far_shipping() -> None:
|
|
97
|
+
profile = Profile(lat=41.39, lon=2.17, km=30, budget=850)
|
|
98
|
+
far = _listing(price=Money(amount=700, currency="EUR"))
|
|
99
|
+
assert score_listing(far, profile) == "GRAB"
|
|
100
|
+
assert score_listing(far, profile, local=True) == "PASS"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_shortlist_ranked_with_wallapop_links() -> None:
|
|
104
|
+
rows = [
|
|
105
|
+
{
|
|
106
|
+
"id": "a",
|
|
107
|
+
"title": "A",
|
|
108
|
+
"verdict": "LOOK",
|
|
109
|
+
"price": {"amount": 700},
|
|
110
|
+
"url": "https://es.wallapop.com/item/a",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"id": "b",
|
|
114
|
+
"title": "B",
|
|
115
|
+
"verdict": "GRAB",
|
|
116
|
+
"price": {"amount": 650},
|
|
117
|
+
"url": "https://es.wallapop.com/item/b",
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
ranked = rank_shortlist(rows)
|
|
121
|
+
assert [r["id"] for r in ranked] == ["b", "a"]
|
|
122
|
+
assert all(r["url"].startswith("https://es.wallapop.com/item/") for r in ranked)
|
|
123
|
+
assert "markdown" in PRESENT_RULE.lower()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_instruct_locks_present_and_spain(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
127
|
+
monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
|
|
128
|
+
data = instruct_recipe()
|
|
129
|
+
assert data["present"]["channel"] == "wallapop.es"
|
|
130
|
+
assert "es.wallapop.com/item" in data["present"]["format"]
|
|
131
|
+
assert any("Spain" in r for r in data["rules"])
|
|
132
|
+
assert any("--local" in r for r in data["rules"])
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import uuid
|
|
6
5
|
from typing import Any
|
|
7
6
|
|
|
8
7
|
from walla.account.login import ensure_access_token
|
|
8
|
+
from walla.account.offer_wire import build_offer_body
|
|
9
9
|
from walla.core.exceptions import WallaUnsupportedError
|
|
10
10
|
from walla.http.client import HttpClient
|
|
11
11
|
from walla.hunter.profile_store import load_profile
|
|
@@ -117,29 +117,28 @@ def make_offer(
|
|
|
117
117
|
f"In-person listing outside pickup radius ({item.title}). Refuse offer."
|
|
118
118
|
)
|
|
119
119
|
http = client or _auth_client()
|
|
120
|
-
|
|
120
|
+
body = build_offer_body(item_id, offer_eur)
|
|
121
121
|
try:
|
|
122
122
|
raw = http.post(
|
|
123
123
|
"/api/v3/delivery/buyer/offers",
|
|
124
|
-
json_body=
|
|
125
|
-
"offer_id": offer_id,
|
|
126
|
-
"offer_price_amount": offer_eur,
|
|
127
|
-
"offer_price_currency": "EUR",
|
|
128
|
-
"item_ids": [item_id],
|
|
129
|
-
},
|
|
124
|
+
json_body=body,
|
|
130
125
|
auth=True,
|
|
131
126
|
)
|
|
132
127
|
return {
|
|
133
128
|
"sent": True,
|
|
134
|
-
"offer_id": offer_id,
|
|
129
|
+
"offer_id": body["offer_id"],
|
|
135
130
|
"quote": quote.model_dump(mode="json"),
|
|
136
131
|
"title": item.title,
|
|
137
132
|
"response": raw,
|
|
138
133
|
}
|
|
139
134
|
except Exception as exc:
|
|
140
135
|
msg = str(exc)
|
|
141
|
-
if "409" in msg:
|
|
142
|
-
raise WallaUnsupportedError(
|
|
136
|
+
if "409" in msg or "offer creation not allowed" in msg.lower():
|
|
137
|
+
raise WallaUnsupportedError(
|
|
138
|
+
"Seller disabled formal offers (409). "
|
|
139
|
+
"Open chat and send the price in text: "
|
|
140
|
+
f'walla say {item_id} "… te propongo {offer_eur} € …" --yes'
|
|
141
|
+
) from exc
|
|
143
142
|
raise WallaUnsupportedError(
|
|
144
143
|
f"Offer wire failed ({exc}). Capture POST buyer/offers and update WIRE.md."
|
|
145
144
|
) from exc
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Open chat (Chat button) and send via PubNub."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from curl_cffi import requests
|
|
11
|
+
|
|
12
|
+
from walla.account.login import ensure_access_token
|
|
13
|
+
from walla.core.exceptions import WallaUnsupportedError
|
|
14
|
+
from walla.http.client import HttpClient
|
|
15
|
+
from walla.http.headers import WEB_BASE, default_headers
|
|
16
|
+
|
|
17
|
+
__all__ = (
|
|
18
|
+
"open_conversation",
|
|
19
|
+
"publish_text",
|
|
20
|
+
"resolve_conversation",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_PN_PUB = "pub-c-255dc549-86f5-4abd-8b9e-921d5a02fde7"
|
|
24
|
+
_PN_SUB = "sub-c-89405e27-d4df-4d87-aca1-d6e9118f0a0d"
|
|
25
|
+
_PN_ORIGIN = "https://ps2.pndsn.com"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _auth_client() -> HttpClient:
|
|
29
|
+
sess = ensure_access_token()
|
|
30
|
+
return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_conversation(
|
|
34
|
+
item_id: str, *, client: HttpClient | None = None
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Same as the listing Chat button: create or reuse a conversation."""
|
|
37
|
+
http = client or _auth_client()
|
|
38
|
+
raw = http.post(
|
|
39
|
+
"/api/v3/conversations",
|
|
40
|
+
json_body={"item_id": item_id},
|
|
41
|
+
auth=True,
|
|
42
|
+
)
|
|
43
|
+
if not isinstance(raw, dict) or not raw.get("conversation_id"):
|
|
44
|
+
raise WallaUnsupportedError(
|
|
45
|
+
f"Chat open failed for {item_id}. Capture POST /api/v3/conversations."
|
|
46
|
+
)
|
|
47
|
+
return {
|
|
48
|
+
"conversation_id": str(raw["conversation_id"]),
|
|
49
|
+
"item_id": str(raw.get("item_id") or item_id),
|
|
50
|
+
"other_user_id": str(raw["other_user_id"]) if raw.get("other_user_id") else None,
|
|
51
|
+
"channel": str(raw["channel"]) if raw.get("channel") else None,
|
|
52
|
+
"chat_url": f"{WEB_BASE}/app/chat?itemId={item_id}",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_conversation(
|
|
57
|
+
ref: str, *, client: HttpClient | None = None
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Resolve item id or conversation hash to an open conversation + channel."""
|
|
60
|
+
http = client or _auth_client()
|
|
61
|
+
from walla.account.inbox import list_conversations_raw
|
|
62
|
+
|
|
63
|
+
inbox = list_conversations_raw(client=http)
|
|
64
|
+
for row in inbox.get("conversations") or []:
|
|
65
|
+
if not isinstance(row, dict):
|
|
66
|
+
continue
|
|
67
|
+
cid = str(row.get("hash") or "")
|
|
68
|
+
item = row.get("item") or {}
|
|
69
|
+
item_hash = str(item.get("hash") or "") if isinstance(item, dict) else ""
|
|
70
|
+
if ref in (cid, item_hash):
|
|
71
|
+
with_user = row.get("with_user") or {}
|
|
72
|
+
return {
|
|
73
|
+
"conversation_id": cid,
|
|
74
|
+
"item_id": item_hash or None,
|
|
75
|
+
"other_user_id": (
|
|
76
|
+
str(with_user.get("hash"))
|
|
77
|
+
if isinstance(with_user, dict) and with_user.get("hash")
|
|
78
|
+
else None
|
|
79
|
+
),
|
|
80
|
+
"channel": str(row["channel"]) if row.get("channel") else None,
|
|
81
|
+
"user_hash": str(inbox.get("user_hash") or ""),
|
|
82
|
+
"chat_url": (
|
|
83
|
+
f"{WEB_BASE}/app/chat?itemId={item_hash}" if item_hash else None
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
opened = open_conversation(ref, client=http)
|
|
87
|
+
opened["user_hash"] = str(inbox.get("user_hash") or "")
|
|
88
|
+
return opened
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def publish_text(
|
|
92
|
+
*,
|
|
93
|
+
channel: str,
|
|
94
|
+
conversation_id: str,
|
|
95
|
+
from_user_hash: str,
|
|
96
|
+
to_user_hash: str,
|
|
97
|
+
text: str,
|
|
98
|
+
client: HttpClient | None = None,
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
"""Publish a chat text via Wallapop PubNub (REST message POST is 404)."""
|
|
101
|
+
http = client or _auth_client()
|
|
102
|
+
token_raw = http.get("/api/v3/instant-messaging/token", auth=True)
|
|
103
|
+
if not isinstance(token_raw, dict) or not token_raw.get("token"):
|
|
104
|
+
raise WallaUnsupportedError("Missing instant-messaging token.")
|
|
105
|
+
pn_token = str(token_raw["token"])
|
|
106
|
+
msg_id = str(uuid.uuid4())
|
|
107
|
+
payload = json.dumps({"id": msg_id, "payload": {"text": text}})
|
|
108
|
+
meta = json.dumps(
|
|
109
|
+
{
|
|
110
|
+
"type": "text",
|
|
111
|
+
"sender": {"platform": {"app_version": "8.2616.0", "os_version": "0"}},
|
|
112
|
+
"to_user_hash": to_user_hash,
|
|
113
|
+
"from_user_hash": from_user_hash,
|
|
114
|
+
"conversation_hash": conversation_id,
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
url = (
|
|
118
|
+
f"{_PN_ORIGIN}/publish/{_PN_PUB}/{_PN_SUB}/0/{quote(channel, safe='')}/0/"
|
|
119
|
+
f"{quote(payload, safe='')}"
|
|
120
|
+
f"?meta={quote(meta, safe='')}"
|
|
121
|
+
f"&uuid={quote(from_user_hash, safe='')}"
|
|
122
|
+
f"&requestid={uuid.uuid4()}"
|
|
123
|
+
f"&pnsdk={quote('PubNub-JS-Web/10.2.6', safe='')}"
|
|
124
|
+
f"&auth={quote(pn_token, safe='')}"
|
|
125
|
+
)
|
|
126
|
+
headers = default_headers(device_id=getattr(http, "device_id", None))
|
|
127
|
+
headers["Accept"] = "*/*"
|
|
128
|
+
headers["Origin"] = WEB_BASE
|
|
129
|
+
headers["Referer"] = f"{WEB_BASE}/app/chat"
|
|
130
|
+
resp = requests.get(url, headers=headers, impersonate="chrome", timeout=30)
|
|
131
|
+
body = (resp.text or "").strip()
|
|
132
|
+
if resp.status_code != 200 or not body.startswith("[1,"):
|
|
133
|
+
raise WallaUnsupportedError(
|
|
134
|
+
f"PubNub publish failed ({resp.status_code}): {body[:200]}"
|
|
135
|
+
)
|
|
136
|
+
return {"sent": True, "message_id": msg_id, "conversation_id": conversation_id}
|