walla-cli 0.1.10__tar.gz → 0.1.11__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.
Files changed (115) hide show
  1. {walla_cli-0.1.10 → walla_cli-0.1.11}/PKG-INFO +1 -1
  2. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/WIRE.md +5 -0
  3. walla_cli-0.1.11/fixtures/sell_suggest_draft.json +23 -0
  4. {walla_cli-0.1.10 → walla_cli-0.1.11}/pyproject.toml +1 -1
  5. {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla-sell/SKILL.md +9 -8
  6. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_live.py +2 -1
  7. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_sell.py +14 -13
  8. walla_cli-0.1.11/tests/test_suggest_watch.py +101 -0
  9. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_wire_contracts.py +3 -2
  10. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/__init__.py +1 -1
  11. walla_cli-0.1.11/walla/account/desk_watch.py +36 -0
  12. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/sell.py +25 -58
  13. walla_cli-0.1.11/walla/account/sell_steps.py +82 -0
  14. walla_cli-0.1.11/walla/account/sell_suggest.py +77 -0
  15. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_desk.py +12 -2
  16. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_sell.py +34 -8
  17. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/instruct.py +4 -0
  18. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/server.py +10 -2
  19. {walla_cli-0.1.10 → walla_cli-0.1.11}/.cursor/rules/quality.mdc +0 -0
  20. {walla_cli-0.1.10 → walla_cli-0.1.11}/.github/workflows/ci.yml +0 -0
  21. {walla_cli-0.1.10 → walla_cli-0.1.11}/.gitignore +0 -0
  22. {walla_cli-0.1.10 → walla_cli-0.1.11}/AGENTS.md +0 -0
  23. {walla_cli-0.1.10 → walla_cli-0.1.11}/LICENSE +0 -0
  24. {walla_cli-0.1.10 → walla_cli-0.1.11}/README.md +0 -0
  25. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo-poster.jpg +0 -0
  26. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo-preview.gif +0 -0
  27. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-demo.mp4 +0 -0
  28. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/assets/walla-github-banner.jpg +0 -0
  29. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/code_quality.md +0 -0
  30. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/data_engineering_standards.md +0 -0
  31. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/mcp.md +0 -0
  32. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/objective_function.md +0 -0
  33. {walla_cli-0.1.10 → walla_cli-0.1.11}/docs/rate_limit_analysis.md +0 -0
  34. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/categories.json +0 -0
  35. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/item_detail.json +0 -0
  36. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/offer_buyer_request.json +0 -0
  37. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/search_bicicleta.json +0 -0
  38. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/sell_item_request.json +0 -0
  39. {walla_cli-0.1.10 → walla_cli-0.1.11}/fixtures/wire_contracts.json +0 -0
  40. {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/check_code_quality.py +0 -0
  41. {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/dogfood_run.py +0 -0
  42. {walla_cli-0.1.10 → walla_cli-0.1.11}/scripts/probe_rate.py +0 -0
  43. {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla/SKILL.md +0 -0
  44. {walla_cli-0.1.10 → walla_cli-0.1.11}/skills/walla-buy/SKILL.md +0 -0
  45. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_account.py +0 -0
  46. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_account_more.py +0 -0
  47. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_catch.py +0 -0
  48. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_categories_find.py +0 -0
  49. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_chat_open.py +0 -0
  50. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_cli_json.py +0 -0
  51. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_core.py +0 -0
  52. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_desk.py +0 -0
  53. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_edges.py +0 -0
  54. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_export.py +0 -0
  55. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_http_more.py +0 -0
  56. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_negotiate.py +0 -0
  57. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_parsers.py +0 -0
  58. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_polite.py +0 -0
  59. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_regressions.py +0 -0
  60. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_search_api.py +0 -0
  61. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_session_store.py +0 -0
  62. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_shortlist.py +0 -0
  63. {walla_cli-0.1.10 → walla_cli-0.1.11}/tests/test_verdict.py +0 -0
  64. {walla_cli-0.1.10 → walla_cli-0.1.11}/uv.lock +0 -0
  65. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/__init__.py +0 -0
  66. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/actions.py +0 -0
  67. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/chat.py +0 -0
  68. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/cookies.py +0 -0
  69. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/desk.py +0 -0
  70. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/inbox.py +0 -0
  71. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/login.py +0 -0
  72. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/offer_wire.py +0 -0
  73. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/prompt.py +0 -0
  74. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/sell_wire.py +0 -0
  75. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/account/session_store.py +0 -0
  76. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/__init__.py +0 -0
  77. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/banner.py +0 -0
  78. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/catch.py +0 -0
  79. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_account.py +0 -0
  80. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_look.py +0 -0
  81. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/cmd_ops.py +0 -0
  82. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/console.py +0 -0
  83. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/cli/main.py +0 -0
  84. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/__init__.py +0 -0
  85. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/envelope.py +0 -0
  86. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/errors.py +0 -0
  87. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/exceptions.py +0 -0
  88. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/human_fix.py +0 -0
  89. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/core/path.py +0 -0
  90. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/__init__.py +0 -0
  91. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/client.py +0 -0
  92. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/headers.py +0 -0
  93. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/http/polite.py +0 -0
  94. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/__init__.py +0 -0
  95. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/converge.py +0 -0
  96. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/export.py +0 -0
  97. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/export_pdf.py +0 -0
  98. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/negotiate.py +0 -0
  99. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/profile_store.py +0 -0
  100. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/pursuits.py +0 -0
  101. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/shortlist.py +0 -0
  102. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/stack.py +0 -0
  103. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/verdict.py +0 -0
  104. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/hunter/watches.py +0 -0
  105. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/__init__.py +0 -0
  106. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/mcp/_entry.py +0 -0
  107. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/__init__.py +0 -0
  108. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/account.py +0 -0
  109. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/listing.py +0 -0
  110. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/models/profile.py +0 -0
  111. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/__init__.py +0 -0
  112. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/api.py +0 -0
  113. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/category_find.py +0 -0
  114. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/parse.py +0 -0
  115. {walla_cli-0.1.10 → walla_cli-0.1.11}/walla/search/pick_category.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.10
3
+ Version: 0.1.11
4
4
  Summary: Wallapop.es CLI - look · offer · talk · sell 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
@@ -34,6 +34,11 @@ Public URL: `https://es.wallapop.com/item/<web_slug>`.
34
34
  `leaf_id` + `root_id`. Sell accepts `--category <leaf-or-name>`; `--root` is
35
35
  optional.
36
36
 
37
+ After `current_step=photo`, the steps `draft` may include `category_leaf_id`
38
+ (title+photo hint). `GET /api/v3/suggested-item-data/{upload_id}` often returns
39
+ empty HTTP 200; still poll before `loading`. CLI: `walla sell … --title … --suggest`.
40
+ `walla desk --watch` polls the inbox until `stack.best` converges.
41
+
37
42
  ## Auth
38
43
 
39
44
  `POST https://api.wallapop.com/api/v3/access/login` with
@@ -0,0 +1,23 @@
1
+ {
2
+ "schema_version": 1,
3
+ "note": "After photo step, Wallapop may inject category_leaf_id from title+photos. suggested-item-data often returns empty HTTP 200.",
4
+ "after_photo": {
5
+ "step": {"id": "category", "components": []},
6
+ "draft": {
7
+ "title": "Escritorio madera oscura patas cromadas",
8
+ "category_leaf_id": "24208"
9
+ }
10
+ },
11
+ "after_loading": {
12
+ "step": {"id": "listing", "components": []},
13
+ "draft": {
14
+ "title": "Escritorio madera oscura patas cromadas",
15
+ "category_leaf_id": "24208",
16
+ "root_category_id": "12467"
17
+ }
18
+ },
19
+ "suggested_item_data": {
20
+ "http_status": 200,
21
+ "body": null
22
+ }
23
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.10"
3
+ version = "0.1.11"
4
4
  description = "Wallapop.es CLI - look · offer · talk · sell for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -14,10 +14,11 @@ Do not ask the human to type buyer replies. Desk sends those.
14
14
 
15
15
  ```bash
16
16
  walla categories --find escritorio --json
17
- walla sell ./shot1.jpg ./shot2.jpg --json
17
+ walla sell ./shot1.jpg ./shot2.jpg --title "Escritorio madera" --suggest --json
18
18
  ```
19
19
 
20
- Ask every question in `data.questions`, then publish once:
20
+ `--suggest` (or draft with `--title` and no `--category`) asks Wallapop to prefill
21
+ `category_leaf_id` from title+photos. Ask remaining `data.questions`, then:
21
22
 
22
23
  ```bash
23
24
  walla sell ./shot1.jpg ./shot2.jpg \
@@ -26,17 +27,17 @@ walla sell ./shot1.jpg ./shot2.jpg \
26
27
  --floor 35 --yes --json
27
28
  ```
28
29
 
29
- `--category` accepts a leaf id **or** name tokens (`escritorio`, `hogar/muebles`).
30
- `--root` is optional (derived from the leaf). `--floor` is the lowest EUR desk may
31
- accept. Omit it to hold the asking price.
30
+ `--category` accepts a leaf id **or** name tokens. `--root` is optional.
31
+ `--floor` is the lowest EUR desk may accept.
32
+
32
33
  Then:
33
34
 
34
35
  ```bash
35
- walla desk --json
36
+ walla desk --watch --seconds 45 --rounds 12 --json
36
37
  ```
37
38
 
38
- Desk reads the inbox once and answers buyers. Repeat desk until `stack.best`
39
- is a sell row. Show the title, agreed EUR, `stack.why`, and the item link.
39
+ Desk answers buyers until `stack.best` converges or rounds end. Show the title,
40
+ agreed EUR, `stack.why`, and the item link.
40
41
 
41
42
  The human accepts the sale in the Wallapop app. `walla unsell <id> --yes` deletes
42
43
  a listing. Never mark the item sold from walla.
@@ -11,7 +11,8 @@ import pytest
11
11
  from walla.account.chat import open_conversation
12
12
  from walla.account.inbox import list_conversations_raw
13
13
  from walla.account.login import ensure_access_token, whoami
14
- from walla.account.sell import _step, delete_listing, publish_listing, upload_pictures
14
+ from walla.account.sell import delete_listing, publish_listing, upload_pictures
15
+ from walla.account.sell_steps import post_step as _step
15
16
  from walla.account.session_store import load_session
16
17
  from walla.http.client import HttpClient
17
18
  from walla.http.polite import reset_polite
@@ -119,23 +119,23 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
119
119
  photo.write_bytes(b"\xff\xd8\xff\xd9")
120
120
  fake_http = MagicMock()
121
121
  fake_http.post.side_effect = [
122
- {"step": {"id": "title"}},
123
- {"step": {"id": "photo", "picture_upload": {"path": "/api/v3/upload/x/pictures"}}},
124
- {"step": {"id": "category"}},
122
+ {"step": {"id": "title"}, "draft": {}},
123
+ {"step": {"id": "photo"}, "draft": {"title": "Test item"}},
125
124
  {
126
- "step": {
127
- "id": "loading",
128
- "poll_before_next_step": {"path": "/api/v3/suggested-item-data/x"},
129
- }
125
+ "step": {"id": "category"},
126
+ "draft": {"title": "Test item", "category_leaf_id": "10105"},
130
127
  },
131
- {"step": {"id": "listing"}},
128
+ {"step": {"id": "loading"}, "draft": {"title": "Test item"}},
129
+ {"step": {"id": "listing"}, "draft": {"title": "Test item"}},
132
130
  ]
133
131
  fake_http.get.return_value = {}
134
132
  listing = MagicMock()
135
133
  listing.url = "https://es.wallapop.com/item/test-item-99"
136
134
  listing.web_slug = "test-item-99"
137
135
  with (
138
- patch("walla.account.sell._auth_client", return_value=fake_http),
136
+ patch("walla.account.sell.auth_client", return_value=fake_http),
137
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
138
+ patch("walla.account.sell.poll_suggested", return_value=None),
139
139
  patch("walla.account.sell.upload_pictures", return_value=1),
140
140
  patch(
141
141
  "walla.account.sell.create_listing",
@@ -156,7 +156,6 @@ def test_publish_listing_mocked(tmp_path: Path) -> None:
156
156
  assert out["id"] == "abc123"
157
157
  assert out["photos"] == 1
158
158
  assert out["url"] == "https://es.wallapop.com/item/test-item-99"
159
- assert fake_http.post.call_count == 5
160
159
 
161
160
 
162
161
  def test_create_listing_multipart(tmp_path: Path) -> None:
@@ -179,7 +178,7 @@ def test_create_listing_multipart(tmp_path: Path) -> None:
179
178
  fake_session = MagicMock()
180
179
  fake_session.request.return_value = fake_resp
181
180
  with (
182
- patch("walla.account.sell._auth_headers", return_value={"Authorization": "Bearer x"}),
181
+ patch("walla.account.sell.auth_headers", return_value={"Authorization": "Bearer x"}),
183
182
  patch("walla.account.sell.requests.Session", return_value=fake_session),
184
183
  patch("walla.account.sell.CurlMime") as mime_cls,
185
184
  patch("walla.account.sell.wait_turn"),
@@ -227,14 +226,16 @@ def test_publish_listing_two_photos(tmp_path: Path) -> None:
227
226
  listing.url = "https://es.wallapop.com/item/test-item-99"
228
227
  listing.web_slug = "test-item-99"
229
228
  with (
230
- patch("walla.account.sell._auth_client", return_value=fake_http),
229
+ patch("walla.account.sell.auth_client", return_value=fake_http),
230
+ patch("walla.account.sell.post_step", side_effect=fake_http.post.side_effect),
231
+ patch("walla.account.sell.poll_suggested", return_value=None),
231
232
  patch("walla.account.sell.upload_pictures", return_value=2),
232
233
  patch(
233
234
  "walla.account.sell.create_listing",
234
235
  return_value={"id": "abc123", "flags": {}},
235
236
  ),
236
237
  patch("walla.account.sell.get_item", return_value=listing),
237
- patch("walla.account.sell._auth_headers", return_value={}),
238
+ patch("walla.account.sell.auth_headers", return_value={}),
238
239
  patch("walla.account.sell.requests.Session", return_value=fake_session),
239
240
  patch("walla.account.sell.CurlMime") as mime_cls,
240
241
  patch("walla.account.sell.wait_turn"),
@@ -0,0 +1,101 @@
1
+ """Suggest draft parse + desk watch stop conditions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ from walla.account.desk_watch import run_desk_watch
10
+ from walla.account.sell_steps import parse_steps_draft
11
+
12
+ FIX = Path(__file__).resolve().parents[1] / "fixtures"
13
+
14
+
15
+ def test_parse_steps_draft_fixture() -> None:
16
+ raw = json.loads((FIX / "sell_suggest_draft.json").read_text(encoding="utf-8"))
17
+ after = parse_steps_draft(raw["after_photo"])
18
+ assert after["category_leaf_id"] == "24208"
19
+ assert after["title"].startswith("Escritorio")
20
+ loaded = parse_steps_draft(raw["after_loading"])
21
+ assert loaded["root_category_id"] == "12467"
22
+
23
+
24
+ def test_desk_watch_stops_on_converged() -> None:
25
+ converged = {
26
+ "replies": [],
27
+ "stack": {"best": {"status": "converged", "title": "X", "agreed_eur": 40}},
28
+ "close_in_app": True,
29
+ }
30
+ with patch("walla.account.desk_watch.run_desk", return_value=converged) as desk:
31
+ with patch("walla.account.desk_watch.time.sleep") as slept:
32
+ out = run_desk_watch(seconds=5, rounds=5)
33
+ assert out["watch_stopped"] == "converged"
34
+ assert out["watch_round"] == 1
35
+ desk.assert_called_once()
36
+ slept.assert_not_called()
37
+
38
+
39
+ def test_desk_watch_max_rounds() -> None:
40
+ waiting = {
41
+ "replies": [],
42
+ "stack": {"best": None},
43
+ "close_in_app": True,
44
+ }
45
+ with patch("walla.account.desk_watch.run_desk", return_value=waiting) as desk:
46
+ with patch("walla.account.desk_watch.time.sleep") as slept:
47
+ out = run_desk_watch(seconds=5, rounds=3)
48
+ assert out["watch_stopped"] == "max_rounds"
49
+ assert out["watch_round"] == 3
50
+ assert desk.call_count == 3
51
+ assert slept.call_count == 2
52
+
53
+
54
+ def test_suggest_from_photos_mocked(tmp_path: Path) -> None:
55
+ from walla.account.sell_suggest import suggest_from_photos
56
+
57
+ photo = tmp_path / "a.jpg"
58
+ photo.write_bytes(b"\xff\xd8\xff\xd9")
59
+ steps = [
60
+ {"step": {"id": "title"}, "draft": {}},
61
+ {"step": {"id": "photo"}, "draft": {"title": "Escritorio madera"}},
62
+ {
63
+ "step": {"id": "category"},
64
+ "draft": {"title": "Escritorio madera", "category_leaf_id": "24208"},
65
+ },
66
+ {
67
+ "step": {"id": "loading"},
68
+ "draft": {
69
+ "title": "Escritorio madera",
70
+ "category_leaf_id": "24208",
71
+ "root_category_id": "12467",
72
+ },
73
+ },
74
+ {
75
+ "step": {"id": "listing"},
76
+ "draft": {
77
+ "title": "Escritorio madera",
78
+ "category_leaf_id": "24208",
79
+ "root_category_id": "12467",
80
+ },
81
+ },
82
+ ]
83
+ fake = MagicMock()
84
+ with (
85
+ patch("walla.account.sell_suggest.auth_client", return_value=fake),
86
+ patch("walla.account.sell_suggest.post_step", side_effect=steps),
87
+ patch("walla.account.sell_suggest.upload_pictures", return_value=1),
88
+ patch("walla.account.sell_suggest.poll_suggested", return_value=None),
89
+ patch(
90
+ "walla.account.sell_suggest.get_categories",
91
+ return_value=__import__(
92
+ "walla.search.parse", fromlist=["parse_categories"]
93
+ ).parse_categories(
94
+ json.loads((FIX / "categories.json").read_text(encoding="utf-8"))
95
+ ),
96
+ ),
97
+ ):
98
+ out = suggest_from_photos([photo], title="Escritorio madera")
99
+ assert out["category_leaf_id"] == "24208"
100
+ assert out["root_category_id"] == "12467"
101
+ assert "Escritorios" in (out.get("path") or "")
@@ -250,8 +250,9 @@ def test_every_contract_is_sent(
250
250
  expires_at=9_999_999_999,
251
251
  )
252
252
  monkeypatch.setattr("walla.account.login.ensure_access_token", lambda: sess)
253
- monkeypatch.setattr("walla.account.sell.ensure_access_token", lambda: sess)
254
- monkeypatch.setattr("walla.account.sell._auth_client", lambda: rec)
253
+ monkeypatch.setattr("walla.account.sell_steps.ensure_access_token", lambda: sess)
254
+ monkeypatch.setattr("walla.account.sell.auth_client", lambda: rec)
255
+ monkeypatch.setattr("walla.account.sell_steps.auth_client", lambda: rec)
255
256
  monkeypatch.setattr("walla.account.chat.ensure_access_token", _must_not_mint)
256
257
  monkeypatch.setattr(
257
258
  "walla.account.actions.load_profile",
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.10"
7
+ __version__ = "0.1.11"
@@ -0,0 +1,36 @@
1
+ """Poll the inbox until a deal converges or rounds run out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from walla.account.desk import run_desk
9
+ from walla.http.client import HttpClient
10
+
11
+ __all__ = ("run_desk_watch",)
12
+
13
+
14
+ def run_desk_watch(
15
+ *,
16
+ seconds: float = 45.0,
17
+ rounds: int = 12,
18
+ client: HttpClient | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Repeat run_desk until stack.best is converged or max rounds."""
21
+ if seconds < 5:
22
+ raise ValueError("--seconds must be >= 5")
23
+ if rounds < 1 or rounds > 60:
24
+ raise ValueError("--rounds must be 1..60")
25
+ last: dict[str, Any] = {}
26
+ for i in range(rounds):
27
+ last = run_desk(client=client)
28
+ last["watch_round"] = i + 1
29
+ best = (last.get("stack") or {}).get("best")
30
+ if isinstance(best, dict) and best.get("status") == "converged":
31
+ last["watch_stopped"] = "converged"
32
+ return last
33
+ if i + 1 < rounds:
34
+ time.sleep(float(seconds))
35
+ last["watch_stopped"] = "max_rounds"
36
+ return last
@@ -9,7 +9,13 @@ from typing import Any
9
9
 
10
10
  from curl_cffi import CurlMime, requests
11
11
 
12
- from walla.account.login import ensure_access_token
12
+ from walla.account.sell_steps import (
13
+ auth_client,
14
+ auth_headers,
15
+ parse_steps_draft,
16
+ poll_suggested,
17
+ post_step,
18
+ )
13
19
  from walla.account.sell_wire import build_item_body
14
20
  from walla.core.exceptions import (
15
21
  WallaHTTPError,
@@ -18,7 +24,7 @@ from walla.core.exceptions import (
18
24
  WallaUnsupportedError,
19
25
  )
20
26
  from walla.http.client import HttpClient
21
- from walla.http.headers import API_BASE, WEB_BASE, default_headers
27
+ from walla.http.headers import API_BASE, WEB_BASE
22
28
  from walla.http.polite import wait_turn
23
29
  from walla.search.api import get_item
24
30
  from walla.search.parse import item_url
@@ -39,42 +45,11 @@ _IMAGE_TYPES = {
39
45
  }
40
46
 
41
47
 
42
- def _auth_client() -> HttpClient:
43
- sess = ensure_access_token()
44
- return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
45
-
46
-
47
- def _auth_headers() -> dict[str, str]:
48
- sess = ensure_access_token()
49
- headers = default_headers(device_id=sess.device_id)
50
- headers["Authorization"] = f"Bearer {sess.access_token}"
51
- return headers
52
-
53
-
54
- def _step(
55
- http: HttpClient,
56
- upload_id: str,
57
- *,
58
- current: str | None,
59
- draft: dict[str, Any],
60
- ) -> dict[str, Any]:
61
- body: dict[str, Any] = {
62
- "mode": {"action": "upload", "id": upload_id},
63
- "draft": draft,
64
- }
65
- if current:
66
- body["current_step"] = current
67
- raw = http.post("/api/v3/steps", json_body=body, auth=True)
68
- if not isinstance(raw, dict):
69
- raise WallaUnsupportedError("steps returned non-object")
70
- return raw
71
-
72
-
73
48
  def upload_pictures(upload_id: str, photos: list[Path]) -> int:
74
49
  """POST each photo to /api/v3/upload/{id}/pictures (HTTP 204)."""
75
50
  if not photos:
76
51
  raise ValueError("at least one photo required")
77
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
52
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
78
53
  session: Any = requests.Session()
79
54
  for path in photos:
80
55
  data = path.read_bytes()
@@ -101,7 +76,7 @@ def upload_pictures(upload_id: str, photos: list[Path]) -> int:
101
76
 
102
77
  def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
103
78
  """Multipart POST /api/v3/items with Accept upload-v2."""
104
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
79
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
105
80
  headers["Accept"] = _UPLOAD_ACCEPT
106
81
  data = first_photo.read_bytes()
107
82
  ctype = _IMAGE_TYPES.get(first_photo.suffix.lower(), "image/jpeg")
@@ -130,7 +105,7 @@ def create_listing(item: dict[str, Any], first_photo: Path) -> dict[str, Any]:
130
105
 
131
106
 
132
107
  def delete_listing(item_id: str, *, client: HttpClient | None = None) -> dict[str, Any]:
133
- http = client or _auth_client()
108
+ http = client or auth_client()
134
109
  http.delete(f"/api/v3/items/{item_id}", auth=True)
135
110
  return {"id": item_id, "deleted": True}
136
111
 
@@ -154,34 +129,26 @@ def publish_listing(
154
129
  for p in paths:
155
130
  if not p.is_file():
156
131
  raise ValueError(f"photo not found: {p}")
157
- http = _auth_client()
132
+ http = auth_client()
158
133
  upload_id = str(uuid.uuid4())
159
134
  draft: dict[str, Any] = {"title": title.strip()[:50]}
160
- _step(http, upload_id, current=None, draft={})
161
- _step(http, upload_id, current="title", draft=draft)
135
+ post_step(http, upload_id, current=None, draft={})
136
+ post_step(http, upload_id, current="title", draft=draft)
162
137
  upload_pictures(upload_id, paths)
163
- _step(http, upload_id, current="photo", draft=draft)
164
- draft = {
165
- **draft,
166
- "category_leaf_id": str(category_leaf_id),
167
- "root_category_id": str(root_category_id),
168
- }
169
- _step(http, upload_id, current="category", draft=draft)
170
- for _ in range(8):
171
- wait_turn()
172
- try:
173
- http.get(f"/api/v3/suggested-item-data/{upload_id}", auth=True)
174
- break
175
- except WallaHTTPError as exc:
176
- if exc.status_code != 404:
177
- raise
178
- _step(http, upload_id, current="loading", draft=draft)
138
+ after_photo = post_step(http, upload_id, current="photo", draft=draft)
139
+ wire = parse_steps_draft(after_photo)
140
+ leaf = str(category_leaf_id)
141
+ root = str(root_category_id)
142
+ draft = {**draft, "category_leaf_id": leaf, "root_category_id": root}
143
+ post_step(http, upload_id, current="category", draft=draft)
144
+ poll_suggested(http, upload_id)
145
+ post_step(http, upload_id, current="loading", draft=draft)
179
146
  item = build_item_body(
180
147
  upload_id=upload_id,
181
148
  title=title,
182
149
  description=description,
183
150
  price_eur=price_eur,
184
- category_leaf_id=category_leaf_id,
151
+ category_leaf_id=leaf,
185
152
  lat=lat,
186
153
  lon=lon,
187
154
  condition=condition,
@@ -197,11 +164,11 @@ def publish_listing(
197
164
  "title": title.strip()[:50],
198
165
  "url": _public_item_url(str(created["id"])),
199
166
  "photos": len(paths),
167
+ "wire_draft": wire,
200
168
  }
201
169
 
202
170
 
203
171
  def _public_item_url(item_id: str) -> str:
204
- """Wallapop web needs the slug path, not the opaque item hash."""
205
172
  try:
206
173
  listing = get_item(item_id)
207
174
  except (
@@ -222,7 +189,7 @@ def _public_item_url(item_id: str) -> str:
222
189
 
223
190
 
224
191
  def _upload_extra_pictures(item_id: str, photos: list[Path]) -> None:
225
- headers = {k: v for k, v in _auth_headers().items() if k.lower() != "content-type"}
192
+ headers = {k: v for k, v in auth_headers().items() if k.lower() != "content-type"}
226
193
  headers["Accept"] = _UPLOAD_ACCEPT
227
194
  session: Any = requests.Session()
228
195
  for idx, path in enumerate(photos, start=1):
@@ -0,0 +1,82 @@
1
+ """Shared Vender step helpers (auth + /api/v3/steps + suggest poll)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from walla.account.login import ensure_access_token
8
+ from walla.core.exceptions import WallaHTTPError, WallaUnsupportedError
9
+ from walla.http.client import HttpClient
10
+ from walla.http.headers import default_headers
11
+ from walla.http.polite import wait_turn
12
+
13
+ __all__ = (
14
+ "auth_client",
15
+ "auth_headers",
16
+ "parse_steps_draft",
17
+ "poll_suggested",
18
+ "post_step",
19
+ )
20
+
21
+
22
+ def auth_client() -> HttpClient:
23
+ sess = ensure_access_token()
24
+ return HttpClient(access_token=sess.access_token, device_id=sess.device_id)
25
+
26
+
27
+ def auth_headers() -> dict[str, str]:
28
+ sess = ensure_access_token()
29
+ headers = default_headers(device_id=sess.device_id)
30
+ headers["Authorization"] = f"Bearer {sess.access_token}"
31
+ return headers
32
+
33
+
34
+ def post_step(
35
+ http: HttpClient,
36
+ upload_id: str,
37
+ *,
38
+ current: str | None,
39
+ draft: dict[str, Any],
40
+ ) -> dict[str, Any]:
41
+ body: dict[str, Any] = {
42
+ "mode": {"action": "upload", "id": upload_id},
43
+ "draft": draft,
44
+ }
45
+ if current:
46
+ body["current_step"] = current
47
+ raw = http.post("/api/v3/steps", json_body=body, auth=True)
48
+ if not isinstance(raw, dict):
49
+ raise WallaUnsupportedError("steps returned non-object")
50
+ return raw
51
+
52
+
53
+ def poll_suggested(http: HttpClient, upload_id: str, *, attempts: int = 8) -> Any:
54
+ """GET suggested-item-data until non-404 (body often empty 200)."""
55
+ last: Any = None
56
+ for _ in range(attempts):
57
+ wait_turn()
58
+ try:
59
+ last = http.get(f"/api/v3/suggested-item-data/{upload_id}", auth=True)
60
+ return last
61
+ except WallaHTTPError as exc:
62
+ if exc.status_code != 404:
63
+ raise
64
+ return last
65
+
66
+
67
+ def parse_steps_draft(payload: dict[str, Any]) -> dict[str, str]:
68
+ """Pull title/category ids from a steps response draft."""
69
+ draft = payload.get("draft")
70
+ if not isinstance(draft, dict):
71
+ return {}
72
+ out: dict[str, str] = {}
73
+ title = draft.get("title")
74
+ if title:
75
+ out["title"] = str(title).strip()[:50]
76
+ leaf = draft.get("category_leaf_id") or draft.get("categoryLeafId")
77
+ if leaf:
78
+ out["category_leaf_id"] = str(leaf)
79
+ root = draft.get("root_category_id") or draft.get("rootCategoryId")
80
+ if root:
81
+ out["root_category_id"] = str(root)
82
+ return out
@@ -0,0 +1,77 @@
1
+ """Ask Wallapop to prefill category from title+photos (steps draft)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from walla.account.sell import upload_pictures
10
+ from walla.account.sell_steps import (
11
+ auth_client,
12
+ parse_steps_draft,
13
+ poll_suggested,
14
+ post_step,
15
+ )
16
+ from walla.search.api import get_categories
17
+ from walla.search.category_find import flatten_categories, resolve_root_id
18
+
19
+ __all__ = ("suggest_from_photos",)
20
+
21
+
22
+ def suggest_from_photos(
23
+ photos: list[Path],
24
+ *,
25
+ title: str,
26
+ root_category_id: str | None = None,
27
+ ) -> dict[str, Any]:
28
+ """Run title→photos→photo; return draft fields Wallapop injected."""
29
+ paths = [Path(p) for p in photos]
30
+ for p in paths:
31
+ if not p.is_file():
32
+ raise ValueError(f"photo not found: {p}")
33
+ text = title.strip()[:50]
34
+ if not text:
35
+ raise ValueError("title required for suggest")
36
+ http = auth_client()
37
+ upload_id = str(uuid.uuid4())
38
+ draft: dict[str, Any] = {"title": text}
39
+ post_step(http, upload_id, current=None, draft={})
40
+ post_step(http, upload_id, current="title", draft=draft)
41
+ upload_pictures(upload_id, paths)
42
+ after_photo = post_step(http, upload_id, current="photo", draft=draft)
43
+ parsed = parse_steps_draft(after_photo)
44
+ leaf = parsed.get("category_leaf_id")
45
+ root = parsed.get("root_category_id") or root_category_id
46
+ path = None
47
+ if leaf:
48
+ roots = get_categories()
49
+ if not root:
50
+ root = resolve_root_id(roots, leaf)
51
+ path = next(
52
+ (h.path for h in flatten_categories(roots) if h.leaf_id == leaf),
53
+ None,
54
+ )
55
+ draft = {
56
+ **draft,
57
+ "category_leaf_id": leaf,
58
+ "root_category_id": str(root),
59
+ }
60
+ post_step(http, upload_id, current="category", draft=draft)
61
+ suggested_body = poll_suggested(http, upload_id)
62
+ after_load = post_step(http, upload_id, current="loading", draft=draft)
63
+ parsed = {**parsed, **parse_steps_draft(after_load)}
64
+ else:
65
+ suggested_body = None
66
+ return {
67
+ "upload_id": upload_id,
68
+ "title": parsed.get("title") or text,
69
+ "category_leaf_id": leaf,
70
+ "root_category_id": str(root) if root else None,
71
+ "path": path,
72
+ "suggested_item_data": suggested_body,
73
+ "hint": (
74
+ "Wallapop filled category_leaf_id from title+photos when present. "
75
+ "Pass --category / --title overrides, then --yes to publish."
76
+ ),
77
+ }
@@ -7,6 +7,7 @@ from typing import Any
7
7
  import typer
8
8
 
9
9
  from walla.account.desk import run_desk, run_pursue
10
+ from walla.account.desk_watch import run_desk_watch
10
11
  from walla.cli.catch import run_cmd
11
12
 
12
13
  __all__ = ("register",)
@@ -28,10 +29,19 @@ def register(app: typer.Typer) -> None:
28
29
  run_cmd(_run, as_json=json)
29
30
 
30
31
  @app.command("desk")
31
- def desk_cmd(json: bool = typer.Option(False, "--json")) -> None:
32
- """Read the inbox once, answer open threads, summarize the best price."""
32
+ def desk_cmd(
33
+ watch: bool = typer.Option(
34
+ False, "--watch", help="Poll inbox until a price converges"
35
+ ),
36
+ seconds: float = typer.Option(45.0, "--seconds", help="Sleep between watch rounds"),
37
+ rounds: int = typer.Option(12, "--rounds", help="Max watch rounds (1..60)"),
38
+ json: bool = typer.Option(False, "--json"),
39
+ ) -> None:
40
+ """Read the inbox, answer open threads, summarize the best price."""
33
41
 
34
42
  def _run() -> dict[str, Any]:
43
+ if watch:
44
+ return run_desk_watch(seconds=seconds, rounds=rounds)
35
45
  return run_desk()
36
46
 
37
47
  run_cmd(_run, as_json=json)
@@ -8,6 +8,7 @@ from typing import Any
8
8
  import typer
9
9
 
10
10
  from walla.account.sell import delete_listing, publish_listing
11
+ from walla.account.sell_suggest import suggest_from_photos
11
12
  from walla.account.sell_wire import CONDITIONS, SELL_QUESTIONS, missing_sell_fields
12
13
  from walla.cli.catch import run_cmd
13
14
  from walla.hunter.profile_store import load_profile
@@ -37,6 +38,11 @@ def register(app: typer.Typer) -> None:
37
38
  floor: float | None = typer.Option(
38
39
  None, "--floor", help="Lowest EUR desk may accept from a buyer"
39
40
  ),
41
+ suggest: bool = typer.Option(
42
+ False,
43
+ "--suggest",
44
+ help="Prefill category via Wallapop steps (needs --title)",
45
+ ),
40
46
  yes: bool = typer.Option(False, "--yes", help="Publish for real"),
41
47
  json: bool = typer.Option(False, "--json"),
42
48
  ) -> None:
@@ -47,17 +53,35 @@ def register(app: typer.Typer) -> None:
47
53
  for p in paths:
48
54
  if not p.is_file():
49
55
  raise ValueError(f"photo not found: {p}")
56
+ title_use = title
50
57
  leaf = category
51
58
  root_id = root
52
59
  picked = None
60
+ suggested = None
61
+ auto = bool(title) and not category and not yes
62
+ if suggest or auto:
63
+ if not title:
64
+ raise ValueError("--suggest needs --title")
65
+ suggested = suggest_from_photos(
66
+ paths, title=title, root_category_id=root
67
+ )
68
+ if suggested.get("category_leaf_id"):
69
+ leaf = str(suggested["category_leaf_id"])
70
+ root_id = str(suggested.get("root_category_id") or "")
53
71
  if category:
54
72
  picked = pick_sell_category(
55
73
  get_categories(), category=category, root=root
56
74
  )
57
75
  leaf = picked.leaf_id
58
76
  root_id = picked.root_id
77
+ elif leaf and str(leaf).isdigit():
78
+ picked = pick_sell_category(
79
+ get_categories(), category=str(leaf), root=root_id or root
80
+ )
81
+ leaf = picked.leaf_id
82
+ root_id = picked.root_id
59
83
  fields: dict[str, Any] = {
60
- "title": title,
84
+ "title": title_use,
61
85
  "description": description,
62
86
  "eur": eur,
63
87
  "category_leaf_id": leaf,
@@ -82,7 +106,7 @@ def register(app: typer.Typer) -> None:
82
106
  },
83
107
  "hint": (
84
108
  "Fill missing fields, then walla sell <photos> … --yes. "
85
- "Category: leaf id or name (walla categories --find …)."
109
+ "walla sell … --title … --suggest prefills category from Wallapop."
86
110
  ),
87
111
  }
88
112
  if picked is not None:
@@ -91,6 +115,8 @@ def register(app: typer.Typer) -> None:
91
115
  "root_id": picked.root_id,
92
116
  "path": picked.path,
93
117
  }
118
+ if suggested is not None:
119
+ draft["suggested"] = suggested
94
120
  if not yes:
95
121
  return {"status": "draft", **draft}
96
122
  if missing:
@@ -100,15 +126,15 @@ def register(app: typer.Typer) -> None:
100
126
  )
101
127
  if profile.lat is None or profile.lon is None:
102
128
  raise ValueError("Set location first: walla setup --lat … --lon …")
103
- assert title and description and eur is not None and leaf and root_id
129
+ assert title_use and description and eur is not None and leaf and root_id
104
130
  lowest = min(floor, eur) if floor is not None else eur
105
131
  created = publish_listing(
106
132
  paths,
107
- title=title,
133
+ title=title_use,
108
134
  description=description,
109
135
  price_eur=eur,
110
- category_leaf_id=leaf,
111
- root_category_id=root_id,
136
+ category_leaf_id=str(leaf),
137
+ root_category_id=str(root_id),
112
138
  lat=profile.lat,
113
139
  lon=profile.lon,
114
140
  condition=condition,
@@ -118,13 +144,13 @@ def register(app: typer.Typer) -> None:
118
144
  remember(
119
145
  Pursuit(
120
146
  item_id=str(created["id"]),
121
- title=title,
147
+ title=title_use,
122
148
  url=str(created.get("url") or ""),
123
149
  side="sell",
124
150
  ask_eur=eur,
125
151
  offer_eur=eur,
126
152
  walk_away_eur=lowest,
127
- query=title,
153
+ query=title_use,
128
154
  status="waiting",
129
155
  why="Listing is live. Desk talks to buyers. You accept in the app.",
130
156
  )
@@ -123,6 +123,7 @@ def instruct_recipe() -> dict[str, Any]:
123
123
  "talk": [
124
124
  'walla pursue "<query>" --json',
125
125
  "walla desk --json",
126
+ "walla desk --watch --seconds 45 --rounds 12 --json",
126
127
  "walla negotiate <id> --json",
127
128
  "walla inbox --json",
128
129
  ],
@@ -132,6 +133,7 @@ def instruct_recipe() -> dict[str, Any]:
132
133
  ],
133
134
  "sell": [
134
135
  "walla categories --find <name> --json",
136
+ "walla sell <photos> --title … --suggest --json",
135
137
  "walla sell <photo.jpg>… --json # returns questions",
136
138
  "walla sell <photos> --title … --desc … --eur N "
137
139
  "--category <leaf-or-name> [--root <root>] --yes",
@@ -142,8 +144,10 @@ def instruct_recipe() -> dict[str, Any]:
142
144
  "Always present Wallapop shortlists ranked best-first with markdown item links.",
143
145
  "search / item / inbox / negotiate are safe reads or drafts.",
144
146
  "pursue and desk send negotiation messages. Do not ask the human to send.",
147
+ "desk --watch polls until stack.best converges; still never pay.",
145
148
  "Bare say, offer, and sell still need --yes. Never pay or accept in walla.",
146
149
  "Sell: drop photos, ask data.questions, then publish with --yes only.",
150
+ "Sell --suggest prefills category from Wallapop steps when title is set.",
147
151
  "If only asked to look, do not send.",
148
152
  "Ambiguous match or multiple GRABs: ask once with candidates.",
149
153
  "Default search is all of Spain. Shipping is enough; do not fence on profile.km.",
@@ -167,8 +167,16 @@ def build_server() -> Any:
167
167
  return _wrap(lambda: run_pursue(keywords, wave=wave))
168
168
 
169
169
  @mcp.tool()
170
- def desk() -> dict[str, Any]:
171
- """One inbox pass: answer threads and summarize the best agreed price."""
170
+ def desk(
171
+ watch: bool = False,
172
+ seconds: float = 45.0,
173
+ rounds: int = 12,
174
+ ) -> dict[str, Any]:
175
+ """Inbox pass (or --watch loop) that answers threads until a price converges."""
176
+ from walla.account.desk_watch import run_desk_watch
177
+
178
+ if watch:
179
+ return _wrap(lambda: run_desk_watch(seconds=seconds, rounds=rounds))
172
180
  return _wrap(run_desk)
173
181
 
174
182
  @mcp.tool()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes