adloop 0.32.0__tar.gz → 0.33.0__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 (72) hide show
  1. {adloop-0.32.0 → adloop-0.33.0}/PKG-INFO +1 -1
  2. {adloop-0.32.0 → adloop-0.33.0}/pyproject.toml +1 -1
  3. {adloop-0.32.0 → adloop-0.33.0}/pyproject.toml.orig +1 -1
  4. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/conversion_actions.py +97 -25
  5. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/write.py +2 -2
  6. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/read.py +11 -1
  7. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/adloop.md +15 -0
  8. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/server.py +65 -22
  9. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/upload_card.html +26 -5
  10. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/uploads.py +132 -34
  11. {adloop-0.32.0 → adloop-0.33.0}/README.md +0 -0
  12. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/__init__.py +0 -0
  13. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/__main__.py +0 -0
  14. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/_mcp_patches.py +0 -0
  15. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/__init__.py +0 -0
  16. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/ai_max.py +0 -0
  17. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/audiences.py +0 -0
  18. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/brands.py +0 -0
  19. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/client.py +0 -0
  20. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/currency.py +0 -0
  21. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  22. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/enums.py +0 -0
  23. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/forecast.py +0 -0
  24. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/gaql.py +0 -0
  25. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/image_fetch.py +0 -0
  26. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/pmax.py +0 -0
  27. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/read.py +0 -0
  28. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/shopping.py +0 -0
  29. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ads/validate_only.py +0 -0
  30. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/auth.py +0 -0
  31. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/cli.py +0 -0
  32. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/config.py +0 -0
  33. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/crossref.py +0 -0
  34. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/diagnostics.py +0 -0
  35. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/__init__.py +0 -0
  36. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/client.py +0 -0
  37. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/reports.py +0 -0
  38. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/tracking.py +0 -0
  39. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/ga4/write.py +0 -0
  40. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/__init__.py +0 -0
  41. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/client.py +0 -0
  42. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gsc/reports.py +0 -0
  43. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/__init__.py +0 -0
  44. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/client.py +0 -0
  45. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/entities.py +0 -0
  46. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/read.py +0 -0
  47. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/gtm/write.py +0 -0
  48. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/__init__.py +0 -0
  49. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/merchant/client.py +0 -0
  50. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/pagespeed.py +0 -0
  51. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/__init__.py +0 -0
  52. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/auth.py +0 -0
  53. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/client.py +0 -0
  54. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/read.py +0 -0
  55. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/schedule.py +0 -0
  56. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/reddit/write.py +0 -0
  57. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/__init__.py +0 -0
  58. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  59. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  60. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/create-ad.md +0 -0
  61. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  62. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  63. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  64. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/rules_install.py +0 -0
  65. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/runtime.py +0 -0
  66. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/__init__.py +0 -0
  67. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/approval.py +0 -0
  68. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/approval_card.html +0 -0
  69. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/audit.py +0 -0
  70. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/guards.py +0 -0
  71. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/safety/preview.py +0 -0
  72. {adloop-0.32.0 → adloop-0.33.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.32.0
3
+ Version: 0.33.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.32.0"
3
+ version = "0.33.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.32.0"
3
+ version = "0.33.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -25,6 +25,7 @@ import hashlib
25
25
  import math
26
26
  import re
27
27
  import threading
28
+ from dataclasses import dataclass
28
29
  from datetime import datetime, timezone as _tz
29
30
  from typing import TYPE_CHECKING, Callable
30
31
 
@@ -1009,8 +1010,16 @@ _CSV_COMMENT_PREFIX = "#"
1009
1010
  _MAX_CSV_BYTES = 5 * 1024 * 1024
1010
1011
 
1011
1012
 
1013
+ @dataclass(frozen=True)
1014
+ class UploadedCsv:
1015
+ """A conversion CSV that arrived as an upload instead of a local path."""
1016
+
1017
+ name: str
1018
+ data: bytes
1019
+
1020
+
1012
1021
  def _read_upload_csv(
1013
- csv_path: str,
1022
+ csv_path: "str | UploadedCsv",
1014
1023
  ) -> tuple[list[tuple[int, list[str]]], list[str], str]:
1015
1024
  """Read an upload CSV into ``(source_line, cells)`` records (header first).
1016
1025
 
@@ -1020,8 +1029,9 @@ def _read_upload_csv(
1020
1029
  Counting records instead would drift as soon as a comment or a bad row is
1021
1030
  dropped.
1022
1031
 
1023
- Local-only by design: the path is read from the machine running AdLoop, so
1024
- the tool refuses in server mode before it gets here. Errors name the file
1032
+ ``csv_path`` is a path on the machine running AdLoop (refused in server
1033
+ mode before it gets here) or an :class:`UploadedCsv` from the upload
1034
+ store, which a hosted server reads instead. Errors name the file
1025
1035
  and the schema, never file *content* — a hosted runtime can read files the
1026
1036
  caller is not allowed to see.
1027
1037
  """
@@ -1036,15 +1046,21 @@ def _read_upload_csv(
1036
1046
  # which is what timestamps without an offset are resolved against.
1037
1047
  timezone = ""
1038
1048
 
1039
- path = Path(csv_path).expanduser()
1040
- if not path.is_file():
1041
- return [], [f"CSV not found or not a regular file: {path}"], ""
1042
- if path.suffix.lower() != ".csv":
1043
- return [], [f"CSV must be a .csv file, got: {path.name}"], ""
1044
- try:
1045
- size = path.stat().st_size
1046
- except OSError as exc:
1047
- return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
1049
+ import io
1050
+
1051
+ if isinstance(csv_path, UploadedCsv):
1052
+ path = None
1053
+ size = len(csv_path.data)
1054
+ else:
1055
+ path = Path(csv_path).expanduser()
1056
+ if not path.is_file():
1057
+ return [], [f"CSV not found or not a regular file: {path}"], ""
1058
+ if path.suffix.lower() != ".csv":
1059
+ return [], [f"CSV must be a .csv file, got: {path.name}"], ""
1060
+ try:
1061
+ size = path.stat().st_size
1062
+ except OSError as exc:
1063
+ return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
1048
1064
  if size > _MAX_CSV_BYTES:
1049
1065
  return [], [
1050
1066
  f"CSV is {size / 1_048_576:.1f} MB; the limit is "
@@ -1053,7 +1069,11 @@ def _read_upload_csv(
1053
1069
  ], ""
1054
1070
 
1055
1071
  try:
1056
- with path.open("r", newline="", encoding="utf-8-sig") as handle:
1072
+ with (
1073
+ io.StringIO(csv_path.data.decode("utf-8-sig"), newline="")
1074
+ if path is None
1075
+ else path.open("r", newline="", encoding="utf-8-sig")
1076
+ ) as handle:
1057
1077
  position = {"line": 0}
1058
1078
 
1059
1079
  def _lines():
@@ -1164,7 +1184,7 @@ def _pad_to_header(raw: list[str], columns: int) -> list[str]:
1164
1184
 
1165
1185
 
1166
1186
  def _parse_call_conversion_csv(
1167
- csv_path: str, default_region: str = ""
1187
+ csv_path: "str | UploadedCsv", default_region: str = ""
1168
1188
  ) -> tuple[list[dict], list[str], list[str], list[dict]]:
1169
1189
  """Read the call-conversions CSV (local file) and normalize each row.
1170
1190
 
@@ -1310,7 +1330,8 @@ def draft_upload_call_conversions(
1310
1330
  config: AdLoopConfig,
1311
1331
  *,
1312
1332
  customer_id: str = "",
1313
- csv_path: str,
1333
+ csv_path: str = "",
1334
+ upload_id: str = "",
1314
1335
  default_region: str = "",
1315
1336
  consent: dict | None = None,
1316
1337
  ) -> dict:
@@ -1353,12 +1374,17 @@ def draft_upload_call_conversions(
1353
1374
  operation="upload_call_conversions",
1354
1375
  default_region=default_region,
1355
1376
  consent=consent,
1377
+ csv_path=csv_path,
1378
+ upload_id=upload_id,
1356
1379
  )
1380
+ if error:
1381
+ return error
1382
+ source, error = _csv_source(csv_path, upload_id)
1357
1383
  if error:
1358
1384
  return error
1359
1385
 
1360
1386
  rows, parse_advisories, skipped, error = _upload_parse_result(
1361
- *_parse_call_conversion_csv(csv_path, default_region),
1387
+ *_parse_call_conversion_csv(source, default_region),
1362
1388
  empty_message="CSV contained zero conversion rows",
1363
1389
  )
1364
1390
  if error:
@@ -1493,7 +1519,7 @@ def draft_upload_call_conversions(
1493
1519
  apply_only_payload={"rows": frozen_rows},
1494
1520
  )
1495
1521
  store_plan(plan)
1496
- return plan.to_preview()
1522
+ return _consume_upload(plan.to_preview(), upload_id)
1497
1523
 
1498
1524
 
1499
1525
  def _resolve_upload_action(
@@ -1714,6 +1740,8 @@ def _upload_draft_preflight(
1714
1740
  operation: str,
1715
1741
  default_region: str,
1716
1742
  consent: dict | None,
1743
+ csv_path: str = "",
1744
+ upload_id: str = "",
1717
1745
  ) -> tuple[str, dict | None, dict | None]:
1718
1746
  """The guards both upload drafts run before they touch the CSV.
1719
1747
 
@@ -1724,12 +1752,19 @@ def _upload_draft_preflight(
1724
1752
  from adloop.runtime import deployment_mode
1725
1753
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
1726
1754
 
1727
- if deployment_mode() == "server":
1755
+ if bool(csv_path) == bool(upload_id):
1756
+ return "", None, {
1757
+ "error": (
1758
+ "Pass exactly one of csv_path (a file on the machine running "
1759
+ "AdLoop) or upload_id (a CSV uploaded with upload_conversion_csv)."
1760
+ )
1761
+ }
1762
+ if csv_path and deployment_mode() == "server":
1728
1763
  return "", None, {
1729
1764
  "error": (
1730
- "This tool uploads conversions from a CSV file on the machine "
1731
- "running AdLoop and is not available on the hosted server. "
1732
- "Use the self-hosted AdLoop MCP server for conversion uploads."
1765
+ "csv_path names a file on the machine running AdLoop, which on "
1766
+ "the hosted server is not yours. Upload the CSV with "
1767
+ "upload_conversion_csv and pass its upload_id instead."
1733
1768
  )
1734
1769
  }
1735
1770
 
@@ -1754,6 +1789,37 @@ def _upload_draft_preflight(
1754
1789
  return region, consent_norm, None
1755
1790
 
1756
1791
 
1792
+ def _csv_source(csv_path: str, upload_id: str) -> "tuple[str | UploadedCsv | None, dict | None]":
1793
+ """The CSV to parse: the local path, or the uploaded file's bytes."""
1794
+ if not upload_id:
1795
+ return csv_path, None
1796
+ from adloop.uploads import UploadError, current_tenant, get_upload_store
1797
+
1798
+ try:
1799
+ upload, data = get_upload_store().get(current_tenant(), upload_id, kind="csv")
1800
+ except UploadError as exc:
1801
+ return None, {"error": f"{exc}. Upload the CSV again with upload_conversion_csv."}
1802
+ return UploadedCsv(name=upload.name, data=data), None
1803
+
1804
+
1805
+ def _consume_upload(preview: dict, upload_id: str) -> dict:
1806
+ """Delete an uploaded CSV once its plan holds the rows.
1807
+
1808
+ The plan carries everything apply needs, so the raw file (phone numbers,
1809
+ emails) has no reason to stay. A draft that failed keeps the file, so a
1810
+ retry with another default_region needs no new upload.
1811
+ """
1812
+ if upload_id:
1813
+ from adloop.uploads import current_tenant, get_upload_store
1814
+
1815
+ get_upload_store().delete(current_tenant(), upload_id)
1816
+ preview["source_upload"] = (
1817
+ f"{upload_id} was read and deleted; the plan holds the rows. A new "
1818
+ "draft needs a new upload."
1819
+ )
1820
+ return preview
1821
+
1822
+
1757
1823
  def _upload_parse_result(
1758
1824
  rows: list[dict],
1759
1825
  errors: list[str],
@@ -2262,7 +2328,7 @@ _OPTIONAL_EC_HEADERS = [
2262
2328
 
2263
2329
 
2264
2330
  def _parse_ec_for_leads_csv(
2265
- csv_path: str, default_region: str = ""
2331
+ csv_path: "str | UploadedCsv", default_region: str = ""
2266
2332
  ) -> tuple[list[dict], list[str], list[str], list[dict]]:
2267
2333
  """Parse the EC-for-Leads CSV (local file) and hash PII at parse time.
2268
2334
 
@@ -2463,7 +2529,8 @@ def draft_upload_enhanced_conversions_for_leads(
2463
2529
  config: AdLoopConfig,
2464
2530
  *,
2465
2531
  customer_id: str = "",
2466
- csv_path: str,
2532
+ csv_path: str = "",
2533
+ upload_id: str = "",
2467
2534
  default_region: str = "",
2468
2535
  consent: dict | None = None,
2469
2536
  ) -> dict:
@@ -2501,12 +2568,17 @@ def draft_upload_enhanced_conversions_for_leads(
2501
2568
  operation="upload_enhanced_conversions_for_leads",
2502
2569
  default_region=default_region,
2503
2570
  consent=consent,
2571
+ csv_path=csv_path,
2572
+ upload_id=upload_id,
2504
2573
  )
2574
+ if error:
2575
+ return error
2576
+ source, error = _csv_source(csv_path, upload_id)
2505
2577
  if error:
2506
2578
  return error
2507
2579
 
2508
2580
  rows, parse_advisories, skipped, error = _upload_parse_result(
2509
- *_parse_ec_for_leads_csv(csv_path, default_region),
2581
+ *_parse_ec_for_leads_csv(source, default_region),
2510
2582
  empty_message="CSV contained zero conversion rows",
2511
2583
  )
2512
2584
  if error:
@@ -2735,7 +2807,7 @@ def draft_upload_enhanced_conversions_for_leads(
2735
2807
  apply_only_payload={"rows": frozen_rows},
2736
2808
  )
2737
2809
  store_plan(plan)
2738
- return plan.to_preview()
2810
+ return _consume_upload(plan.to_preview(), upload_id)
2739
2811
 
2740
2812
 
2741
2813
  def _apply_upload_enhanced_conversions_for_leads(
@@ -3351,7 +3351,7 @@ def image_payloads(
3351
3351
  from adloop.uploads import UploadError, current_tenant, get_upload_store
3352
3352
 
3353
3353
  try:
3354
- upload, _data = get_upload_store().get(current_tenant(), upload_id)
3354
+ upload, _data = get_upload_store().get(current_tenant(), upload_id, kind="image")
3355
3355
  except UploadError as exc:
3356
3356
  errors.append(f"Upload {index + 1}: {exc}")
3357
3357
  continue
@@ -5135,7 +5135,7 @@ def load_image_bytes(payload: dict) -> tuple[bytes, PurePath]:
5135
5135
  if payload.get("upload_id"):
5136
5136
  from adloop.uploads import current_tenant, get_upload_store
5137
5137
 
5138
- upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
5138
+ upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]), kind="image")
5139
5139
  if upload.sha256 != payload.get("sha256"):
5140
5140
  raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
5141
5141
  return image_bytes, PurePosixPath(upload.name)
@@ -216,7 +216,17 @@ def get_merchant_feed_health(
216
216
  f"severity — CRITICAL means offers stop serving entirely: "
217
217
  + "; ".join(i["title"] for i in critical[:3])
218
218
  )
219
- if not account_issues and not totals["disapproved"]:
219
+ if not serving_total:
220
+ # Zero products is not a healthy feed: Shopping and Performance Max
221
+ # have nothing to advertise.
222
+ insights.append(
223
+ "The account has no products at all (0 approved, pending or "
224
+ "disapproved), so Shopping and Performance Max campaigns have "
225
+ "nothing to show. Either no feed or data source is set up yet, "
226
+ "products were added in the last ~30 minutes (status lags), or "
227
+ "the products live in another Merchant Center account."
228
+ )
229
+ elif not account_issues and not totals["disapproved"]:
220
230
  insights.append("No disapprovals or account issues — feed is healthy.")
221
231
 
222
232
  return {
@@ -249,6 +249,21 @@ For images that have no public URL: a file on the user's computer, or an image t
249
249
  - **Using an upload:** `draft_image_assets(upload_ids=["up_..."])` for Google Ads; `"upload:up_..."` as `image_url`, `thumbnail_url` or a carousel card's `image_url` in `draft_reddit_ad` (AdLoop Cloud only, because Reddit downloads the image from a short-lived public link). Videos cannot be uploaded.
250
250
  - Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
251
251
 
252
+ ### Offline Conversion Uploads
253
+
254
+ Conversions that happen offline (a sales call, a lead that turned into a deal) go back to Google Ads from a CSV in Google's upload template.
255
+
256
+ | Tool | When to Use | Key Parameters |
257
+ |------|-------------|----------------|
258
+ | `upload_conversion_csv` | The CSV is not on the machine running AdLoop (always on AdLoop Cloud). Shows the upload card, or returns an upload link | (none) |
259
+ | `draft_upload_call_conversions` | Calls from call assets or call-tracking numbers that became a conversion; matched by the caller's phone number | exactly one of `csv_path` (local file) or `upload_id`; `default_region` for numbers without a country code; `consent` |
260
+ | `draft_upload_enhanced_conversions_for_leads` | Form leads that became a conversion; matched by hashed email, phone or name and address | exactly one of `csv_path` or `upload_id`; `default_region`; `consent` |
261
+
262
+ - The conversion action named in the CSV's `Conversion Name` column must exist and be of type UPLOAD_CALLS (calls) or UPLOAD_CLICKS (leads); the draft checks it against the account.
263
+ - **The CSV holds personal data** (phone numbers, emails, names). An uploaded file lasts two hours at most and is deleted as soon as a draft has read it, so a second draft needs a new upload; a failed draft keeps it for a retry (for example with `default_region`). Lead identifiers are hashed while drafting. Never ask the user to paste rows into the chat.
264
+ - EEA traffic needs consent signals: pass `consent` or fill the CSV's `Ad User Data` / `Ad Personalization` columns. Present the preview's `consent_summary`, `skipped_rows` and `dedup_warnings`.
265
+ - Apply sends batches of 2,000 rows. `PARTIAL_UPLOAD` means some batches are already in the account: the plan is retired, and a new draft continues at `resume_from_line`. Don't re-upload rows that went through.
266
+
252
267
  ### Planning Tools
253
268
 
254
269
  | Tool | When to Use | Key Parameters |
@@ -4590,10 +4590,15 @@ def upload_images() -> dict:
4590
4590
  draft_image_assets, or as "upload:<id>" into the image fields of
4591
4591
  draft_reddit_ad. Returns the current uploads and how to add more.
4592
4592
  """
4593
+ return _upload_tool_result("image")
4594
+
4595
+
4596
+ def _upload_tool_result(kind: str):
4597
+ """The upload card (with its code) or the upload link, for ``kind``."""
4593
4598
  from adloop.uploads import upload_instructions
4594
4599
 
4595
4600
  shows_cards = client_shows_cards()
4596
- result = upload_instructions(shows_cards)
4601
+ result = upload_instructions(shows_cards, kind)
4597
4602
  if not shows_cards:
4598
4603
  return result
4599
4604
  from fastmcp.tools import ToolResult
@@ -4609,6 +4614,29 @@ def upload_images() -> dict:
4609
4614
  )
4610
4615
 
4611
4616
 
4617
+ @_tool(
4618
+ title="Upload a conversion CSV",
4619
+ annotations=_READONLY,
4620
+ tags={"ads"},
4621
+ meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
4622
+ )
4623
+ @_safe
4624
+ def upload_conversion_csv() -> dict:
4625
+ """Let the user upload a conversion CSV for an offline conversion upload.
4626
+
4627
+ For the CSV behind draft_upload_call_conversions or
4628
+ draft_upload_enhanced_conversions_for_leads when the file is not on the
4629
+ machine running AdLoop, as on AdLoop Cloud. Apps that show AdLoop's
4630
+ cards display an upload card where the user drops the .csv file; the
4631
+ card then posts the upload id into the chat. Other apps on AdLoop Cloud
4632
+ get an upload link for the user (or a coding agent, with curl). The
4633
+ file holds personal data (phone numbers, emails), so it is kept for two
4634
+ hours at most and deleted as soon as a draft has read it. Returns the
4635
+ CSV uploads waiting to be used and how to add one.
4636
+ """
4637
+ return _upload_tool_result("csv")
4638
+
4639
+
4612
4640
  @_tool(
4613
4641
  title="Store an uploaded image",
4614
4642
  annotations=_WRITE,
@@ -4616,16 +4644,19 @@ def upload_images() -> dict:
4616
4644
  meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
4617
4645
  )
4618
4646
  @_safe
4619
- def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
4620
- """Store an image dropped on the AdLoop upload card.
4647
+ def store_image_upload(name: str, data_base64: str, code: str = "", kind: str = "image") -> dict:
4648
+ """Store a file dropped on the AdLoop upload card.
4621
4649
 
4622
4650
  Called only by the upload card; AI apps keep this tool out of the
4623
- model's tool list. Returns the upload's id, type and dimensions.
4651
+ model's tool list. Returns the upload's id, type and size (dimensions
4652
+ for images, lines for CSVs).
4624
4653
 
4625
4654
  Args:
4626
4655
  name: The file name, used for the asset name.
4627
4656
  data_base64: The file content, base64-encoded.
4628
- code: The upload card's code from upload_images.
4657
+ code: The upload card's code from upload_images or
4658
+ upload_conversion_csv.
4659
+ kind: "image" (default) or "csv" for a conversion CSV.
4629
4660
  """
4630
4661
  import base64
4631
4662
  import binascii
@@ -4637,20 +4668,21 @@ def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
4637
4668
  except (binascii.Error, ValueError):
4638
4669
  return {"error": "The file did not arrive intact; upload it again."}
4639
4670
  try:
4640
- upload = get_upload_store().put(current_tenant(), name, data)
4671
+ upload = get_upload_store().put(current_tenant(), name, data, kind=kind)
4641
4672
  except UploadError as exc:
4642
4673
  return {"error": f"{name}: {exc}"}
4643
4674
  return {"status": "UPLOADED", **upload.summary()}
4644
4675
 
4645
4676
 
4646
- @_tool(title="List uploaded images", annotations=_READONLY, tags={"core"})
4677
+ @_tool(title="List uploads", annotations=_READONLY, tags={"core"})
4647
4678
  @_safe
4648
4679
  def list_uploads() -> dict:
4649
- """List the images the user uploaded to AdLoop that have not expired.
4680
+ """List the files the user uploaded to AdLoop that have not expired.
4650
4681
 
4651
- Returns each upload's upload_id, name, type, dimensions, size and hours
4652
- until it expires (uploads last a day): the ids of files the user sent
4653
- through the upload link, or of earlier uploads.
4682
+ Returns each upload's upload_id, kind (image or csv), name, type, size,
4683
+ dimensions (images) or lines (CSVs) and hours until it expires: images
4684
+ last a day, conversion CSVs two hours or until a draft reads them. The
4685
+ ids of files the user sent through an upload link show up here.
4654
4686
  """
4655
4687
  from adloop.uploads import current_tenant, get_upload_store
4656
4688
 
@@ -4660,7 +4692,8 @@ def list_uploads() -> dict:
4660
4692
  "usage": (
4661
4693
  "draft_image_assets(upload_ids=[...]) for Google Ads; "
4662
4694
  "\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
4663
- "image_url in draft_reddit_ad (AdLoop Cloud only)."
4695
+ "image_url in draft_reddit_ad (AdLoop Cloud only). CSV uploads: upload_id in "
4696
+ "draft_upload_call_conversions or draft_upload_enhanced_conversions_for_leads."
4664
4697
  ),
4665
4698
  }
4666
4699
 
@@ -6141,7 +6174,8 @@ def draft_remove_conversion_action(
6141
6174
  @_tool(title="Draft call conversion upload", annotations=_WRITE, tags={"ads"})
6142
6175
  @_safe
6143
6176
  def draft_upload_call_conversions(
6144
- csv_path: str,
6177
+ csv_path: str = "",
6178
+ upload_id: str = "",
6145
6179
  default_region: str = "",
6146
6180
  consent: dict | None = None,
6147
6181
  customer_id: str = "",
@@ -6157,8 +6191,9 @@ def draft_upload_call_conversions(
6157
6191
  drafting, so a typo fails here rather than after the upload. Optional
6158
6192
  `Ad User Data` and `Ad Personalization` columns carry consent per row.
6159
6193
 
6160
- Local file only: on the hosted server this tool refuses, because it would
6161
- read a path on the server rather than the caller's machine.
6194
+ The CSV comes from csv_path (a file on the machine running AdLoop) or
6195
+ from upload_id (a file uploaded with upload_conversion_csv, the only way
6196
+ on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
6162
6197
 
6163
6198
  PII: the caller phone number is required RAW by Google (it cannot be
6164
6199
  hashed), so it lives in the plan's apply-only payload — the preview shows
@@ -6181,8 +6216,10 @@ def draft_upload_call_conversions(
6181
6216
 
6182
6217
  Args:
6183
6218
  csv_path: Path of the CSV file holding the upload rows, read on the
6184
- machine that runs AdLoop. The hosted server refuses this tool
6185
- rather than reading a path of its own.
6219
+ machine that runs AdLoop. Refused on the hosted server; use
6220
+ upload_id there.
6221
+ upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
6222
+ instead of csv_path.
6186
6223
  default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
6187
6224
  that carry no country code, e.g. "DE" for "0151 12345678". Empty
6188
6225
  leaves such numbers unusable and the row is skipped.
@@ -6202,6 +6239,7 @@ def draft_upload_call_conversions(
6202
6239
  current_config(),
6203
6240
  customer_id=customer_id or current_config().ads.customer_id,
6204
6241
  csv_path=csv_path,
6242
+ upload_id=upload_id,
6205
6243
  default_region=default_region,
6206
6244
  consent=consent,
6207
6245
  )
@@ -6210,7 +6248,8 @@ def draft_upload_call_conversions(
6210
6248
  @_tool(title="Draft enhanced conversions upload", annotations=_WRITE, tags={"ads"})
6211
6249
  @_safe
6212
6250
  def draft_upload_enhanced_conversions_for_leads(
6213
- csv_path: str,
6251
+ csv_path: str = "",
6252
+ upload_id: str = "",
6214
6253
  default_region: str = "",
6215
6254
  consent: dict | None = None,
6216
6255
  customer_id: str = "",
@@ -6235,8 +6274,9 @@ def draft_upload_enhanced_conversions_for_leads(
6235
6274
  sharing an Order ID count once at Google; two rows with different Order
6236
6275
  IDs are two real orders and are not reported.
6237
6276
 
6238
- Local file only: on the hosted server this tool refuses, because it would
6239
- read a path on the server rather than the caller's machine.
6277
+ The CSV comes from csv_path (a file on the machine running AdLoop) or
6278
+ from upload_id (a file uploaded with upload_conversion_csv, the only way
6279
+ on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
6240
6280
 
6241
6281
  Returns a preview with a plan_id, the row counts, the consent the rows
6242
6282
  actually carry (`consent_summary`) and the skipped rows; nothing is
@@ -6244,8 +6284,10 @@ def draft_upload_enhanced_conversions_for_leads(
6244
6284
 
6245
6285
  Args:
6246
6286
  csv_path: Path of the CSV file holding the upload rows, read on the
6247
- machine that runs AdLoop. The hosted server refuses this tool
6248
- rather than reading a path of its own.
6287
+ machine that runs AdLoop. Refused on the hosted server; use
6288
+ upload_id there.
6289
+ upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
6290
+ instead of csv_path.
6249
6291
  default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
6250
6292
  that carry no country code, e.g. "DE" for "0151 12345678". Empty
6251
6293
  leaves such numbers unusable; the row still uploads when its email
@@ -6266,6 +6308,7 @@ def draft_upload_enhanced_conversions_for_leads(
6266
6308
  current_config(),
6267
6309
  customer_id=customer_id or current_config().ads.customer_id,
6268
6310
  csv_path=csv_path,
6311
+ upload_id=upload_id,
6269
6312
  default_region=default_region,
6270
6313
  consent=consent,
6271
6314
  )
@@ -90,6 +90,11 @@
90
90
  drop: "Drop images here", or: "or", pick: "Choose files",
91
91
  uploading: "Uploading…", done: "Uploaded", tooBig: "Over 5 MB", wrongType: "Not PNG, JPEG or GIF",
92
92
  message: "I uploaded images to AdLoop:\n{list}\nUse these upload ids for the ad.",
93
+ brandCsv: "AdLoop · conversion CSV", titleCsv: "Upload your conversion CSV",
94
+ subCsv: "A .csv file in Google's upload template, up to 5 MB. It is deleted once AdLoop has read it, after two hours at the latest.",
95
+ dropCsv: "Drop the CSV here", pickCsv: "Choose file", wrongTypeCsv: "Not a .csv file",
96
+ messageCsv: "I uploaded a conversion CSV to AdLoop:\n{list}\nDraft the conversion upload with this upload_id.",
97
+ lines: "lines",
93
98
  },
94
99
  de: {
95
100
  brand: "AdLoop · Bilder hochladen", title: "Bilder für deine Anzeigen hochladen",
@@ -97,16 +102,30 @@
97
102
  drop: "Bilder hierher ziehen", or: "oder", pick: "Dateien auswählen",
98
103
  uploading: "Wird hochgeladen…", done: "Hochgeladen", tooBig: "Größer als 5 MB", wrongType: "Kein PNG, JPEG oder GIF",
99
104
  message: "Ich habe Bilder bei AdLoop hochgeladen:\n{list}\nNutze diese Upload-IDs für die Anzeige.",
105
+ brandCsv: "AdLoop · Conversion-CSV", titleCsv: "Conversion-CSV hochladen",
106
+ subCsv: "Eine .csv-Datei in Googles Upload-Vorlage, bis 5 MB. Sie wird gelöscht, sobald AdLoop sie gelesen hat, spätestens nach zwei Stunden.",
107
+ dropCsv: "CSV hierher ziehen", pickCsv: "Datei auswählen", wrongTypeCsv: "Keine .csv-Datei",
108
+ messageCsv: "Ich habe eine Conversion-CSV bei AdLoop hochgeladen:\n{list}\nErstelle den Conversion-Upload mit dieser upload_id.",
109
+ lines: "Zeilen",
100
110
  },
101
111
  };
102
112
 
103
113
  let lang = "en";
114
+ // "image" or "csv": upload_images and upload_conversion_csv share this card.
115
+ let kind = "image";
104
116
  document.documentElement.dataset.theme =
105
117
  window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
106
118
  let nextId = 1;
107
119
  const pending = new Map();
108
120
  const $ = (id) => document.getElementById(id);
109
- const t = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key] || key;
121
+ const pick = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key];
122
+ const t = (key) => (kind === "csv" && pick(key + "Csv")) || pick(key) || key;
123
+ const accepted = (file) => kind === "csv" ? /\.csv$/i.test(file.name) : TYPES.includes(file.type);
124
+ function render() {
125
+ for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
126
+ $("file").accept = kind === "csv" ? ".csv,text/csv" : TYPES.join(",");
127
+ $("file").multiple = kind !== "csv";
128
+ }
110
129
 
111
130
  function post(message) { window.parent.postMessage(message, "*"); }
112
131
  function request(method, params) {
@@ -126,7 +145,7 @@
126
145
  const vars = ctx.styles && ctx.styles.variables;
127
146
  if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
128
147
  if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
129
- for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
148
+ render();
130
149
  }
131
150
 
132
151
  function payloadOf(result) {
@@ -152,6 +171,7 @@
152
171
  }
153
172
 
154
173
  function describe(u) {
174
+ if (u.kind === "csv") return u.upload_id + " · " + u.lines + " " + t("lines") + " · " + u.size_kb + " KB";
155
175
  return u.upload_id + " · " + u.width + "×" + u.height + " · " + u.size_kb + " KB";
156
176
  }
157
177
 
@@ -167,12 +187,12 @@
167
187
  async function upload(files) {
168
188
  const stored = [];
169
189
  for (const file of files) {
170
- const row = addRow(file.name, TYPES.includes(file.type) ? URL.createObjectURL(file) : "");
171
- if (!TYPES.includes(file.type)) { row.state.textContent = t("wrongType"); row.state.classList.add("bad"); continue; }
190
+ const row = addRow(file.name, kind === "image" && accepted(file) ? URL.createObjectURL(file) : "");
191
+ if (!accepted(file)) { row.state.textContent = t("wrongType"); row.state.classList.add("bad"); continue; }
172
192
  if (file.size > MAX_BYTES) { row.state.textContent = t("tooBig"); row.state.classList.add("bad"); continue; }
173
193
  row.state.textContent = t("uploading");
174
194
  try {
175
- const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file), code } });
195
+ const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file), code, kind } });
176
196
  const r = payloadOf(result);
177
197
  if ((result && result.isError) || r.error || !r.upload_id) throw new Error(r.error || "upload failed");
178
198
  row.meta.textContent = describe(r);
@@ -209,6 +229,7 @@
209
229
  if (shownExisting || !result) return;
210
230
  shownExisting = true;
211
231
  const r = payloadOf(result);
232
+ if (r.upload_kind === "csv") { kind = "csv"; render(); }
212
233
  for (const u of r.uploads || []) {
213
234
  const row = addRow(u.name, "");
214
235
  row.meta.textContent = describe(u);
@@ -1,4 +1,4 @@
1
- """Image uploads: files a user hands to AdLoop without a public URL.
1
+ """Uploads: files a user hands to AdLoop without a public URL or a local path.
2
2
 
3
3
  AI apps cannot pass a file from the chat to a tool, so an upload reaches
4
4
  AdLoop on its own path: the upload card (MCP Apps) calls the app-only
@@ -8,7 +8,13 @@ opens a one-time upload link. Either way the image lands in an
8
8
  ``draft_image_assets(upload_ids=[...])`` for Google Ads, ``upload:<id>`` in
9
9
  the media fields of ``draft_reddit_ad``.
10
10
 
11
- Uploads are short-lived: they expire after a day and exist to be turned
11
+ Conversion CSVs take the same path (``upload_conversion_csv``) and go into
12
+ the ``upload_id`` of the conversion upload drafts. They hold personal data
13
+ (phone numbers, emails), so they live two hours at most and are deleted as
14
+ soon as a draft has read them; a hosted store also encrypts every file at
15
+ rest (:class:`LocalUploadStore` ``cipher``).
16
+
17
+ Uploads are short-lived: images expire after a day and exist to be turned
12
18
  into ad assets, not to be kept. Self-hosted, the store is a folder under
13
19
  ``~/.adloop/uploads``. A hosted runtime sets its own store and the links
14
20
  (:func:`set_upload_links`): the upload page and the short-lived public file
@@ -32,6 +38,9 @@ UPLOAD_CARD_URI = "ui://adloop/upload-card.html"
32
38
  STORE_UPLOAD_TOOL = "store_image_upload"
33
39
  MAX_UPLOAD_BYTES = 5120 * 1024 # Google's image asset limit; Reddit takes more
34
40
  DEFAULT_TTL_SECONDS = 24 * 3600
41
+ # Conversion CSVs carry personal data; they are read once by a draft.
42
+ CSV_TTL_SECONDS = 2 * 3600
43
+ KINDS = ("image", "csv")
35
44
  MAX_FILES_PER_TENANT = 50
36
45
  MAX_BYTES_PER_TENANT = 100 * 1024 * 1024
37
46
  _ID_PATTERN = re.compile(r"^up_[A-Za-z0-9_-]{8,40}$")
@@ -48,17 +57,26 @@ class Upload:
48
57
  sha256: str
49
58
  created_at: float
50
59
  expires_at: float
60
+ # Stored metadata from before CSV uploads has no kind: those are images.
61
+ kind: str = "image"
62
+ # Data rows of a CSV (lines that are not the header, blank or comments
63
+ # are not told apart here; the draft counts precisely).
64
+ lines: int = 0
51
65
 
52
66
  def summary(self) -> dict:
53
- return {
67
+ out = {
54
68
  "upload_id": self.upload_id,
69
+ "kind": self.kind,
55
70
  "name": self.name,
56
71
  "mime_type": self.mime_type,
57
- "width": self.width,
58
- "height": self.height,
59
72
  "size_kb": round(self.size_bytes / 1024, 1),
60
73
  "expires_in_hours": max(0, round((self.expires_at - time.time()) / 3600, 1)),
61
74
  }
75
+ if self.kind == "csv":
76
+ out["lines"] = self.lines
77
+ else:
78
+ out["width"], out["height"] = self.width, self.height
79
+ return out
62
80
 
63
81
 
64
82
  class UploadError(ValueError):
@@ -66,8 +84,8 @@ class UploadError(ValueError):
66
84
 
67
85
 
68
86
  class UploadLinks(Protocol):
69
- def upload_page(self, tenant: str) -> tuple[str, int]:
70
- """A URL where the user uploads files for ``tenant``, and its lifetime in seconds."""
87
+ def upload_page(self, tenant: str, kind: str = "image") -> tuple[str, int]:
88
+ """A URL where the user uploads files of ``kind`` for ``tenant``, and its lifetime in seconds."""
71
89
 
72
90
  def public_file(self, tenant: str, upload_id: str) -> str:
73
91
  """A short-lived public URL serving the upload (for Reddit to download)."""
@@ -95,10 +113,45 @@ def inspect_image(data: bytes) -> tuple[str, int, int]:
95
113
  raise UploadError(f"not a usable image: {exc} (PNG, JPEG or GIF)") from exc
96
114
 
97
115
 
116
+ def inspect_csv(name: str, data: bytes) -> int:
117
+ """Line count of a conversion CSV; raises UploadError for anything else.
118
+
119
+ Only the container is checked here (a .csv name, UTF-8 text, the size).
120
+ The columns are the draft's business: it names every problem with its
121
+ line, which a refusal at upload time could not.
122
+ """
123
+ if not data:
124
+ raise UploadError("the file is empty")
125
+ if len(data) > MAX_UPLOAD_BYTES:
126
+ raise UploadError(
127
+ f"the file is {len(data) / 1024:.0f} KB; CSV uploads can be at most "
128
+ f"{MAX_UPLOAD_BYTES // 1024} KB (Google takes 2,000 rows per request anyway)"
129
+ )
130
+ if not str(name or "").lower().endswith(".csv"):
131
+ raise UploadError("conversion uploads must be .csv files (Google's upload template)")
132
+ try:
133
+ text = data.decode("utf-8-sig")
134
+ except UnicodeDecodeError as exc:
135
+ raise UploadError("the CSV is not UTF-8 text; export it as CSV UTF-8") from exc
136
+ if "\x00" in text:
137
+ raise UploadError("the file is not a text CSV")
138
+ return sum(1 for line in text.splitlines() if line.strip())
139
+
140
+
141
+ class UploadCipher(Protocol):
142
+ """Encryption at rest for a hosted store (the file bytes, not the metadata)."""
143
+
144
+ def encrypt(self, data: bytes) -> bytes: ...
145
+
146
+ def decrypt(self, data: bytes) -> bytes: ...
147
+
148
+
98
149
  class LocalUploadStore:
99
150
  """Uploads as files in a folder, one subfolder per tenant.
100
151
 
101
152
  Expired files are removed whenever the store is used; there is no timer.
153
+ With a ``cipher`` every file is stored encrypted (hosted runtimes);
154
+ self-hosted, the files sit on the user's own machine as they are.
102
155
  """
103
156
 
104
157
  def __init__(
@@ -106,13 +159,17 @@ class LocalUploadStore:
106
159
  root: str | Path,
107
160
  *,
108
161
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
162
+ csv_ttl_seconds: int = CSV_TTL_SECONDS,
109
163
  max_files: int = MAX_FILES_PER_TENANT,
110
164
  max_bytes: int = MAX_BYTES_PER_TENANT,
165
+ cipher: UploadCipher | None = None,
111
166
  ) -> None:
112
167
  self.root = Path(root)
113
168
  self.ttl_seconds = ttl_seconds
169
+ self.csv_ttl_seconds = csv_ttl_seconds
114
170
  self.max_files = max_files
115
171
  self.max_bytes = max_bytes
172
+ self.cipher = cipher
116
173
 
117
174
  def _dir(self, tenant: str) -> Path:
118
175
  key = hashlib.sha256(str(tenant or "local").encode()).hexdigest()[:24]
@@ -143,13 +200,20 @@ class LocalUploadStore:
143
200
  except FileNotFoundError:
144
201
  pass
145
202
 
146
- def put(self, tenant: str, name: str, data: bytes) -> Upload:
147
- mime_type, width, height = inspect_image(data)
203
+ def put(self, tenant: str, name: str, data: bytes, *, kind: str = "image") -> Upload:
204
+ if kind not in KINDS:
205
+ raise UploadError(f"unknown upload kind '{kind}'")
206
+ lines = 0
207
+ if kind == "csv":
208
+ lines = inspect_csv(name, data)
209
+ mime_type, width, height = "text/csv", 0, 0
210
+ else:
211
+ mime_type, width, height = inspect_image(data)
148
212
  existing = self._entries(tenant)
149
213
  digest = hashlib.sha256(data).hexdigest()
150
214
  for upload in existing:
151
- if upload.sha256 == digest:
152
- return upload # the same image twice is one upload
215
+ if upload.sha256 == digest and upload.kind == kind:
216
+ return upload # the same file twice is one upload
153
217
  if len(existing) >= self.max_files:
154
218
  raise UploadError(
155
219
  f"this workspace already holds {len(existing)} uploads; they expire after a day"
@@ -169,30 +233,46 @@ class LocalUploadStore:
169
233
  size_bytes=len(data),
170
234
  sha256=digest,
171
235
  created_at=now,
172
- expires_at=now + self.ttl_seconds,
236
+ expires_at=now + (self.csv_ttl_seconds if kind == "csv" else self.ttl_seconds),
237
+ kind=kind,
238
+ lines=lines,
173
239
  )
174
240
  folder = self._dir(tenant)
175
241
  folder.mkdir(parents=True, exist_ok=True)
176
- for suffix, payload in ((".bin", data), (".json", json.dumps(asdict(upload)).encode())):
242
+ stored = self.cipher.encrypt(data) if self.cipher else data
243
+ for suffix, payload in ((".bin", stored), (".json", json.dumps(asdict(upload)).encode())):
177
244
  tmp = folder / f".{upload.upload_id}{suffix}.tmp"
178
245
  tmp.write_bytes(payload)
179
246
  os.replace(tmp, folder / f"{upload.upload_id}{suffix}")
180
247
  return upload
181
248
 
182
- def get(self, tenant: str, upload_id: str) -> tuple[Upload, bytes]:
249
+ def get(self, tenant: str, upload_id: str, *, kind: str | None = None) -> tuple[Upload, bytes]:
183
250
  upload_id = str(upload_id or "").strip()
184
251
  if not _ID_PATTERN.match(upload_id):
185
252
  raise UploadError(f"'{upload_id}' is not an upload id (they look like up_...)")
186
253
  for upload in self._entries(tenant):
187
254
  if upload.upload_id == upload_id:
255
+ if kind and upload.kind != kind:
256
+ raise UploadError(
257
+ f"upload {upload_id} is a {upload.kind} upload, not a {kind} upload"
258
+ )
188
259
  data = (self._dir(tenant) / f"{upload_id}.bin").read_bytes()
260
+ if self.cipher:
261
+ data = self.cipher.decrypt(data)
189
262
  return upload, data
190
263
  raise UploadError(
191
- f"upload {upload_id} was not found; uploads expire after a day (see list_uploads)"
264
+ f"upload {upload_id} was not found; uploads expire (images after a day, "
265
+ "CSVs after two hours or once a draft has read them; see list_uploads)"
192
266
  )
193
267
 
194
- def list(self, tenant: str) -> list[Upload]:
195
- return self._entries(tenant)
268
+ def delete(self, tenant: str, upload_id: str) -> None:
269
+ """Remove an upload now (a CSV once a draft has read it)."""
270
+ upload_id = str(upload_id or "").strip()
271
+ if _ID_PATTERN.match(upload_id):
272
+ self._remove(self._dir(tenant), upload_id)
273
+
274
+ def list(self, tenant: str, *, kind: str | None = None) -> list[Upload]:
275
+ return [u for u in self._entries(tenant) if kind is None or u.kind == kind]
196
276
 
197
277
 
198
278
  _store: LocalUploadStore | None = None
@@ -243,42 +323,60 @@ def public_url_for(upload_id: str) -> str:
243
323
  "upload: references need a public link Reddit can download, which only AdLoop "
244
324
  "Cloud provides. Self-hosted, pass a public image URL instead."
245
325
  )
246
- get_upload_store().get(current_tenant(), upload_id) # exists and not expired
326
+ get_upload_store().get(current_tenant(), upload_id, kind="image") # exists, not expired
247
327
  return links.public_file(current_tenant(), upload_id)
248
328
 
249
329
 
250
- def upload_instructions(shows_cards: bool) -> dict:
251
- """What ``upload_images`` returns: current uploads plus the way to add more."""
330
+ def upload_instructions(shows_cards: bool, kind: str = "image") -> dict:
331
+ """What ``upload_images`` / ``upload_conversion_csv`` return: current
332
+ uploads of that kind plus the way to add more."""
252
333
  tenant = current_tenant()
253
- result: dict = {"uploads": [u.summary() for u in get_upload_store().list(tenant)]}
254
- use = (
255
- "Use the ids as upload_ids in draft_image_assets, or as \"upload:<upload_id>\" in "
256
- "the image fields of draft_reddit_ad."
257
- )
334
+ result: dict = {
335
+ "upload_kind": kind,
336
+ "uploads": [u.summary() for u in get_upload_store().list(tenant, kind=kind)],
337
+ }
338
+ if kind == "csv":
339
+ what, example = "the CSV", 'curl -T "conversions.csv" "{url}/"'
340
+ use = (
341
+ "Pass the id as upload_id to draft_upload_call_conversions or "
342
+ "draft_upload_enhanced_conversions_for_leads. The file is deleted once a draft "
343
+ "has read it, and after two hours at the latest."
344
+ )
345
+ local = (
346
+ "This app shows no upload card. Self-hosted, pass the file directly: the "
347
+ "conversion upload drafts take csv_path, a file on this machine."
348
+ )
349
+ else:
350
+ what, example = "the images", 'curl -T "logo.png" "{url}/"'
351
+ use = (
352
+ "Use the ids as upload_ids in draft_image_assets, or as \"upload:<upload_id>\" in "
353
+ "the image fields of draft_reddit_ad."
354
+ )
355
+ local = (
356
+ "This app shows no upload card. Self-hosted, pass the files directly: "
357
+ "draft_image_assets(image_paths=[...]) takes local files; Reddit ads need a "
358
+ "public image URL."
359
+ )
258
360
  if shows_cards:
259
361
  result["upload"] = "card"
260
362
  result["instructions"] = (
261
- "The user sees an AdLoop upload card and drops the images there. The card posts "
363
+ f"The user sees an AdLoop upload card and drops {what} there. The card posts "
262
364
  "the upload ids into the chat when it is done; wait for that. " + use
263
365
  )
264
366
  return result
265
367
  links = get_upload_links()
266
368
  if links is None:
267
- result["instructions"] = (
268
- "This app shows no upload card. Self-hosted, pass the files directly: "
269
- "draft_image_assets(image_paths=[...]) takes local files; Reddit ads need a "
270
- "public image URL."
271
- )
369
+ result["instructions"] = local
272
370
  return result
273
- url, lifetime = links.upload_page(tenant)
371
+ url, lifetime = links.upload_page(tenant, kind)
274
372
  minutes = max(1, lifetime // 60)
275
373
  result.update({
276
374
  "upload_url": url,
277
375
  "expires_in_minutes": minutes,
278
- "curl_example": f'curl -T "logo.png" "{url}/"',
376
+ "curl_example": example.format(url=url),
279
377
  "instructions": (
280
378
  f"Give the user this upload link (valid {minutes} minutes): they open it in the "
281
- "browser and drop the images, then tell you they are done; call list_uploads for "
379
+ f"browser and drop {what}, then tell you they are done; call list_uploads for "
282
380
  "the ids. A coding agent with the files on disk can upload them itself with curl "
283
381
  "(curl_example; one call per file). " + use
284
382
  ),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes