adloop 0.25.0__tar.gz → 0.26.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.
- {adloop-0.25.0 → adloop-0.26.0}/PKG-INFO +1 -1
- {adloop-0.25.0 → adloop-0.26.0}/pyproject.toml +1 -1
- {adloop-0.25.0 → adloop-0.26.0}/pyproject.toml.orig +1 -1
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/write.py +38 -7
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/write.py +43 -3
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/adloop.md +15 -1
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/server.py +125 -6
- adloop-0.26.0/src/adloop/upload_card.html +247 -0
- adloop-0.26.0/src/adloop/uploads.py +286 -0
- {adloop-0.25.0 → adloop-0.26.0}/README.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/__main__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/auth.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/cli.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/config.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/crossref.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/runtime.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.25.0 → adloop-0.26.0}/src/adloop/tracking.py +0 -0
|
@@ -432,7 +432,7 @@ def _size_kb(data: bytes) -> float:
|
|
|
432
432
|
|
|
433
433
|
def _describe_image(image: dict[str, object]) -> str:
|
|
434
434
|
"""One preview line: source, type, dimensions and size."""
|
|
435
|
-
source = image.get("url") or image.get("path")
|
|
435
|
+
source = image.get("url") or image.get("path") or f"upload {image.get('upload_id')}"
|
|
436
436
|
kind = str(image.get("mime_type", "")).removeprefix("image/").upper()
|
|
437
437
|
return (
|
|
438
438
|
f"{source} — {kind} {image.get('width')}x{image.get('height')}, "
|
|
@@ -2457,12 +2457,14 @@ def draft_image_assets(
|
|
|
2457
2457
|
campaign_id: str = "",
|
|
2458
2458
|
image_paths: list[str] | None = None,
|
|
2459
2459
|
image_urls: list[str] | None = None,
|
|
2460
|
+
upload_ids: list[str] | None = None,
|
|
2460
2461
|
) -> dict:
|
|
2461
|
-
"""Draft campaign image assets from local files
|
|
2462
|
+
"""Draft campaign image assets from local files, public image URLs and/or uploads.
|
|
2462
2463
|
|
|
2463
2464
|
URL images are fetched now (public addresses only, pinned against DNS
|
|
2464
2465
|
rebinding, capped at Google's 5120 KB limit) and their sha256 is kept
|
|
2465
|
-
in the plan; apply re-fetches and refuses if the bytes changed.
|
|
2466
|
+
in the plan; apply re-fetches and refuses if the bytes changed. Uploads
|
|
2467
|
+
(``up_...`` ids from the upload card or link) are pinned the same way.
|
|
2466
2468
|
"""
|
|
2467
2469
|
from adloop.runtime import deployment_mode
|
|
2468
2470
|
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
@@ -2486,7 +2488,7 @@ def draft_image_assets(
|
|
|
2486
2488
|
return {"error": str(e)}
|
|
2487
2489
|
|
|
2488
2490
|
validated_images, errors = _validate_image_assets(
|
|
2489
|
-
campaign_id, image_paths, image_urls
|
|
2491
|
+
campaign_id, image_paths, image_urls, list(upload_ids or [])
|
|
2490
2492
|
)
|
|
2491
2493
|
if errors:
|
|
2492
2494
|
return {"error": "Validation failed", "details": errors}
|
|
@@ -3300,16 +3302,38 @@ def _validate_structured_snippets(
|
|
|
3300
3302
|
|
|
3301
3303
|
|
|
3302
3304
|
def _validate_image_assets(
|
|
3303
|
-
campaign_id: str,
|
|
3305
|
+
campaign_id: str,
|
|
3306
|
+
image_paths: list[str],
|
|
3307
|
+
image_urls: list[str] | None = None,
|
|
3308
|
+
upload_ids: list[str] | None = None,
|
|
3304
3309
|
) -> tuple[list[dict[str, object]], list[str]]:
|
|
3305
3310
|
errors = []
|
|
3306
3311
|
validated = []
|
|
3307
3312
|
image_urls = image_urls or []
|
|
3313
|
+
upload_ids = upload_ids or []
|
|
3308
3314
|
|
|
3309
3315
|
if not campaign_id:
|
|
3310
3316
|
errors.append("campaign_id is required")
|
|
3311
|
-
if not image_paths and not image_urls:
|
|
3312
|
-
errors.append("At least one image (image_paths or
|
|
3317
|
+
if not image_paths and not image_urls and not upload_ids:
|
|
3318
|
+
errors.append("At least one image (image_paths, image_urls or upload_ids) is required")
|
|
3319
|
+
|
|
3320
|
+
for index, upload_id in enumerate(upload_ids):
|
|
3321
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
3322
|
+
|
|
3323
|
+
try:
|
|
3324
|
+
upload, _data = get_upload_store().get(current_tenant(), upload_id)
|
|
3325
|
+
except UploadError as exc:
|
|
3326
|
+
errors.append(f"Upload {index + 1}: {exc}")
|
|
3327
|
+
continue
|
|
3328
|
+
validated.append({
|
|
3329
|
+
"upload_id": upload.upload_id,
|
|
3330
|
+
"sha256": upload.sha256,
|
|
3331
|
+
"name": _build_image_asset_name(PurePosixPath(upload.name), _data),
|
|
3332
|
+
"mime_type": upload.mime_type,
|
|
3333
|
+
"width": upload.width,
|
|
3334
|
+
"height": upload.height,
|
|
3335
|
+
"size_kb": round(upload.size_bytes / 1024, 1),
|
|
3336
|
+
})
|
|
3313
3337
|
|
|
3314
3338
|
for index, image_path in enumerate(image_paths):
|
|
3315
3339
|
try:
|
|
@@ -4916,6 +4940,13 @@ def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict:
|
|
|
4916
4940
|
if payload.get("url"):
|
|
4917
4941
|
image_bytes = _fetch_approved_image_url(payload)
|
|
4918
4942
|
name_source = _url_path(str(payload["url"]))
|
|
4943
|
+
elif payload.get("upload_id"):
|
|
4944
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
4945
|
+
|
|
4946
|
+
upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
|
|
4947
|
+
if upload.sha256 != payload.get("sha256"):
|
|
4948
|
+
raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
|
|
4949
|
+
name_source = PurePosixPath(upload.name)
|
|
4919
4950
|
elif deployment_mode() == "server":
|
|
4920
4951
|
# Drafts refuse paths on a server; never read server files even
|
|
4921
4952
|
# if a path-based plan reached apply some other way.
|
|
@@ -1595,6 +1595,40 @@ def _check_media(url: str, kind: str, timeout: int = 10) -> tuple[str | None, st
|
|
|
1595
1595
|
return None, None
|
|
1596
1596
|
|
|
1597
1597
|
|
|
1598
|
+
def _media_problem(url: str, kind: str) -> tuple[str | None, str | None]:
|
|
1599
|
+
"""Check a media field: an ``upload:<id>`` reference, or a URL Reddit downloads."""
|
|
1600
|
+
from adloop.uploads import UploadError, public_url_for, upload_ref
|
|
1601
|
+
|
|
1602
|
+
upload_id = upload_ref(url)
|
|
1603
|
+
if upload_id is None:
|
|
1604
|
+
return _check_media(url, kind)
|
|
1605
|
+
if kind == "video":
|
|
1606
|
+
return "is an upload; videos cannot be uploaded to AdLoop, pass a public video URL", None
|
|
1607
|
+
try:
|
|
1608
|
+
public_url_for(upload_id)
|
|
1609
|
+
except UploadError as exc:
|
|
1610
|
+
return f"cannot be used: {exc}", None
|
|
1611
|
+
return None, None
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _resolve_uploads(post: dict) -> dict:
|
|
1615
|
+
"""Swap ``upload:<id>`` media references for links Reddit can download now."""
|
|
1616
|
+
from adloop.uploads import public_url_for, upload_ref
|
|
1617
|
+
|
|
1618
|
+
def swap(value: Any) -> Any:
|
|
1619
|
+
upload_id = upload_ref(value) if isinstance(value, str) else None
|
|
1620
|
+
return public_url_for(upload_id) if upload_id else value
|
|
1621
|
+
|
|
1622
|
+
resolved = dict(post)
|
|
1623
|
+
if "thumbnail_url" in resolved:
|
|
1624
|
+
resolved["thumbnail_url"] = swap(resolved["thumbnail_url"])
|
|
1625
|
+
resolved["content"] = [
|
|
1626
|
+
{**item, "media_url": swap(item.get("media_url"))} if isinstance(item, dict) else item
|
|
1627
|
+
for item in resolved.get("content") or []
|
|
1628
|
+
]
|
|
1629
|
+
return resolved
|
|
1630
|
+
|
|
1631
|
+
|
|
1598
1632
|
def _carousel_content(
|
|
1599
1633
|
cards: list | None, *, click_url: str, call_to_action: str, display_url: str, errors: list[str]
|
|
1600
1634
|
) -> list[dict]:
|
|
@@ -1757,15 +1791,20 @@ def draft_reddit_ad(
|
|
|
1757
1791
|
from adloop.ads.write import _validate_urls
|
|
1758
1792
|
|
|
1759
1793
|
landing = list(dict.fromkeys([click_url] + [c["destination_url"] for c in cards]))
|
|
1760
|
-
|
|
1794
|
+
from adloop.uploads import upload_ref
|
|
1795
|
+
|
|
1796
|
+
image_is_upload = upload_ref(image_url) is not None
|
|
1797
|
+
url_errors, url_warnings = _validate_urls(landing + ([image_url] if image_url and not image_is_upload else []))
|
|
1761
1798
|
for url, problem in url_errors.items():
|
|
1762
1799
|
if problem:
|
|
1763
1800
|
errors.append(f"'{url}' is not reachable: {problem}")
|
|
1764
1801
|
warnings.extend(url_warnings.values())
|
|
1765
1802
|
media = [(video_url, "video"), (thumbnail_url, "image")] if post_type == "VIDEO" else []
|
|
1766
1803
|
media += [(c["media_url"], "image") for c in cards]
|
|
1804
|
+
if image_is_upload:
|
|
1805
|
+
media.append((image_url, "image"))
|
|
1767
1806
|
for url, kind in media:
|
|
1768
|
-
problem, note =
|
|
1807
|
+
problem, note = _media_problem(url, kind)
|
|
1769
1808
|
if problem:
|
|
1770
1809
|
errors.append(f"'{url}' {problem}")
|
|
1771
1810
|
if note:
|
|
@@ -2185,7 +2224,8 @@ def apply_plan(config: AdLoopConfig, plan: ChangePlan) -> dict:
|
|
|
2185
2224
|
post_id = str(changes["existing_post_id"])
|
|
2186
2225
|
post = get_reddit_post(config, post_id) or {"id": post_id}
|
|
2187
2226
|
else:
|
|
2188
|
-
|
|
2227
|
+
post_payload = _resolve_uploads(changes["post"])
|
|
2228
|
+
post = data_of(reddit_post(config, f"profiles/{profile_id}/posts", {"data": post_payload}))
|
|
2189
2229
|
post_id = post.get("id")
|
|
2190
2230
|
if not post_id:
|
|
2191
2231
|
raise ValueError("Reddit created the post but returned no post id; check Ads Manager.")
|
|
@@ -225,6 +225,20 @@ Reddit is a second ad platform with its own connection (own OAuth app, no develo
|
|
|
225
225
|
5. `gtm_paused_but_firing` means the event reaches GA4 from somewhere other than the paused tag — hardcoded gtag, another container, or a mobile SDK; don't advise unpausing without finding the real source first
|
|
226
226
|
6. Remember dynamic-event tags (`{{Event}}`) and Custom HTML tags are opaque to the audit — the tool lists them separately for manual review
|
|
227
227
|
|
|
228
|
+
### Image Uploads
|
|
229
|
+
|
|
230
|
+
For images that have no public URL: a file on the user's computer, or an image they attached in the chat (you can see it, but you cannot pass its bytes to a tool).
|
|
231
|
+
|
|
232
|
+
| Tool | When to Use | Key Parameters |
|
|
233
|
+
|------|-------------|----------------|
|
|
234
|
+
| `upload_images` | The user wants such an image in an ad. Apps with AdLoop cards show an upload card; on AdLoop Cloud in other apps the result has an `upload_url` | (none) |
|
|
235
|
+
| `list_uploads` | Find upload ids after the user used the upload link, or to reuse an earlier upload | (none) |
|
|
236
|
+
|
|
237
|
+
- **With the card** (`upload: "card"` in the result): the user drops PNG, JPEG or GIF files (up to 5 MB) on the card; it then posts the upload ids into the chat. Wait for that message instead of asking for a URL.
|
|
238
|
+
- **With an upload link** (`upload_url`): give the user the link (valid 30 minutes) to open in the browser, then call `list_uploads` once they say they are done. A coding agent that has the file on disk can upload it itself: `curl -T "logo.png" "<upload_url>/"`, one call per file.
|
|
239
|
+
- **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.
|
|
240
|
+
- Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
|
|
241
|
+
|
|
228
242
|
### Planning Tools
|
|
229
243
|
|
|
230
244
|
| Tool | When to Use | Key Parameters |
|
|
@@ -245,7 +259,7 @@ Reddit is a second ad platform with its own connection (own OAuth app, no develo
|
|
|
245
259
|
| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. |
|
|
246
260
|
| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
|
|
247
261
|
| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values |
|
|
248
|
-
| `draft_image_assets` | Create image assets for a campaign from local files
|
|
262
|
+
| `draft_image_assets` | Create image assets for a campaign from local files, public image URLs or uploads (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server), `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) and/or `upload_ids` (`up_...` from `upload_images` / `list_uploads`) |
|
|
249
263
|
| `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
|
|
250
264
|
| `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) |
|
|
251
265
|
| `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` |
|
|
@@ -15,6 +15,7 @@ from pydantic import BeforeValidator
|
|
|
15
15
|
|
|
16
16
|
from adloop import diagnostics
|
|
17
17
|
from adloop.runtime import current_config
|
|
18
|
+
from adloop.uploads import STORE_UPLOAD_TOOL, UPLOAD_CARD_URI
|
|
18
19
|
from adloop.safety.approval import (
|
|
19
20
|
APPROVE_TOOL,
|
|
20
21
|
CARD_MIME_TYPE,
|
|
@@ -144,7 +145,10 @@ def _build_orchestration_instructions() -> str:
|
|
|
144
145
|
"rejected too. Per-row problems in an upload are not a rejection: they "
|
|
145
146
|
"come back as `row_errors` with DRY_RUN_SUCCESS, and the apply would "
|
|
146
147
|
"carry out the other rows.\n"
|
|
147
|
-
"- One change at a time — don't batch unrelated writes.\n
|
|
148
|
+
"- One change at a time — don't batch unrelated writes.\n"
|
|
149
|
+
"- Images without a public URL (a file on the user's computer, an image "
|
|
150
|
+
"attached in the chat): call `upload_images`, which shows an upload card "
|
|
151
|
+
"or returns an upload link, then use the upload ids it yields.\n\n"
|
|
148
152
|
"PRE-WRITE CHECKS (before any `draft_*`):\n"
|
|
149
153
|
"- BROAD match keywords require Smart Bidding (MAXIMIZE_CONVERSIONS, "
|
|
150
154
|
"tCPA, tROAS). Refuse BROAD on MANUAL_CPC. This is the #1 cause of "
|
|
@@ -211,6 +215,27 @@ def approval_card() -> str:
|
|
|
211
215
|
return card_html()
|
|
212
216
|
|
|
213
217
|
|
|
218
|
+
@mcp.resource(
|
|
219
|
+
UPLOAD_CARD_URI,
|
|
220
|
+
name="upload_card",
|
|
221
|
+
title="AdLoop image upload",
|
|
222
|
+
description="A drop zone that uploads images to AdLoop for use as ad assets.",
|
|
223
|
+
mime_type=CARD_MIME_TYPE,
|
|
224
|
+
meta={"ui": {"prefersBorder": False}},
|
|
225
|
+
)
|
|
226
|
+
def upload_card() -> str:
|
|
227
|
+
from importlib import resources
|
|
228
|
+
|
|
229
|
+
return resources.files("adloop").joinpath("upload_card.html").read_text(encoding="utf-8")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# App-only tools and the card that calls each one.
|
|
233
|
+
_APP_ONLY_TOOLS = {
|
|
234
|
+
APPROVE_TOOL: "AdLoop approval card",
|
|
235
|
+
STORE_UPLOAD_TOOL: "AdLoop upload card",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
214
239
|
class _AppOnlyToolsForCardClients(Middleware):
|
|
215
240
|
"""List and run app-only tools only for apps that render cards.
|
|
216
241
|
|
|
@@ -226,10 +251,11 @@ class _AppOnlyToolsForCardClients(Middleware):
|
|
|
226
251
|
return [tool for tool in tools if not is_app_only(tool.meta)]
|
|
227
252
|
|
|
228
253
|
async def on_call_tool(self, context, call_next):
|
|
229
|
-
|
|
254
|
+
card = _APP_ONLY_TOOLS.get(context.message.name)
|
|
255
|
+
if card and not client_shows_cards():
|
|
230
256
|
from fastmcp.exceptions import ToolError
|
|
231
257
|
|
|
232
|
-
raise ToolError(f"{
|
|
258
|
+
raise ToolError(f"{context.message.name} can only be called from the {card}.")
|
|
233
259
|
return await call_next(context)
|
|
234
260
|
|
|
235
261
|
|
|
@@ -324,7 +350,7 @@ _API_DOCS: dict[str, str] = {
|
|
|
324
350
|
|
|
325
351
|
|
|
326
352
|
# Write tools that do not produce a preview of their own.
|
|
327
|
-
_NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL})
|
|
353
|
+
_NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL, STORE_UPLOAD_TOOL})
|
|
328
354
|
|
|
329
355
|
|
|
330
356
|
def _with_approval_card(fn: Callable) -> Callable:
|
|
@@ -3684,10 +3710,12 @@ def draft_image_assets(
|
|
|
3684
3710
|
image_paths: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
|
|
3685
3711
|
image_urls: _StrList = [], # noqa: B006
|
|
3686
3712
|
customer_id: str = "",
|
|
3713
|
+
upload_ids: _StrList = [], # noqa: B006
|
|
3687
3714
|
) -> dict:
|
|
3688
|
-
"""Draft campaign image assets from local files
|
|
3715
|
+
"""Draft campaign image assets from local files, public image URLs or uploads — returns a PREVIEW.
|
|
3689
3716
|
|
|
3690
|
-
At least one image in total is required across image_paths
|
|
3717
|
+
At least one image in total is required across image_paths, image_urls
|
|
3718
|
+
and upload_ids.
|
|
3691
3719
|
Images must be PNG, JPEG or GIF and at most 5120 KB (Google's limit).
|
|
3692
3720
|
URL images are downloaded now and checked; applying re-downloads them
|
|
3693
3721
|
and refuses if the image changed since the preview. Returns a preview with
|
|
@@ -3703,6 +3731,9 @@ def draft_image_assets(
|
|
|
3703
3731
|
private, local or internal addresses are refused.
|
|
3704
3732
|
customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
|
|
3705
3733
|
the configured account.
|
|
3734
|
+
upload_ids: Ids of images the user uploaded (up_..., from
|
|
3735
|
+
upload_images or list_uploads). The way to use an image that has
|
|
3736
|
+
no public URL.
|
|
3706
3737
|
"""
|
|
3707
3738
|
from adloop.ads.write import draft_image_assets as _impl
|
|
3708
3739
|
|
|
@@ -3712,6 +3743,7 @@ def draft_image_assets(
|
|
|
3712
3743
|
campaign_id=campaign_id,
|
|
3713
3744
|
image_paths=image_paths,
|
|
3714
3745
|
image_urls=image_urls,
|
|
3746
|
+
upload_ids=upload_ids,
|
|
3715
3747
|
)
|
|
3716
3748
|
|
|
3717
3749
|
|
|
@@ -3988,6 +4020,93 @@ def approve_change(
|
|
|
3988
4020
|
return _impl(config, plan_id=plan_id, dry_run=action == "test")
|
|
3989
4021
|
|
|
3990
4022
|
|
|
4023
|
+
# ---------------------------------------------------------------------------
|
|
4024
|
+
# Image uploads
|
|
4025
|
+
# ---------------------------------------------------------------------------
|
|
4026
|
+
# Images without a public URL reach AdLoop through the upload card (apps with
|
|
4027
|
+
# MCP Apps) or an upload link (hosted, every other app). Tools then take the
|
|
4028
|
+
# upload id: draft_image_assets(upload_ids=...), upload:<id> for Reddit.
|
|
4029
|
+
|
|
4030
|
+
|
|
4031
|
+
@_tool(
|
|
4032
|
+
title="Upload images",
|
|
4033
|
+
annotations=_READONLY,
|
|
4034
|
+
tags={"core"},
|
|
4035
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
|
|
4036
|
+
)
|
|
4037
|
+
@_safe
|
|
4038
|
+
def upload_images() -> dict:
|
|
4039
|
+
"""Let the user upload images (PNG, JPEG, GIF up to 5 MB) for ad assets.
|
|
4040
|
+
|
|
4041
|
+
For images without a public URL, such as a file on the user's computer
|
|
4042
|
+
or an image attached in the chat. Apps that show AdLoop's cards display
|
|
4043
|
+
an upload card where the user drops the files; the card then posts the
|
|
4044
|
+
upload ids into the chat. On AdLoop Cloud in other apps the result
|
|
4045
|
+
carries an upload link for the user (or a coding agent, with curl).
|
|
4046
|
+
Uploads expire after a day. The ids go into upload_ids of
|
|
4047
|
+
draft_image_assets, or as "upload:<id>" into the image fields of
|
|
4048
|
+
draft_reddit_ad. Returns the current uploads and how to add more.
|
|
4049
|
+
"""
|
|
4050
|
+
from adloop.uploads import upload_instructions
|
|
4051
|
+
|
|
4052
|
+
return upload_instructions(client_shows_cards())
|
|
4053
|
+
|
|
4054
|
+
|
|
4055
|
+
@_tool(
|
|
4056
|
+
title="Store an uploaded image",
|
|
4057
|
+
annotations=_WRITE,
|
|
4058
|
+
tags={"core"},
|
|
4059
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
|
|
4060
|
+
)
|
|
4061
|
+
@_safe
|
|
4062
|
+
def store_image_upload(name: str, data_base64: str) -> dict:
|
|
4063
|
+
"""Store an image dropped on the AdLoop upload card.
|
|
4064
|
+
|
|
4065
|
+
Called only by the upload card; AI apps keep this tool out of the
|
|
4066
|
+
model's tool list. Returns the upload's id, type and dimensions.
|
|
4067
|
+
|
|
4068
|
+
Args:
|
|
4069
|
+
name: The file name, used for the asset name.
|
|
4070
|
+
data_base64: The file content, base64-encoded.
|
|
4071
|
+
"""
|
|
4072
|
+
import base64
|
|
4073
|
+
import binascii
|
|
4074
|
+
|
|
4075
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
4076
|
+
|
|
4077
|
+
try:
|
|
4078
|
+
data = base64.b64decode(data_base64, validate=True)
|
|
4079
|
+
except (binascii.Error, ValueError):
|
|
4080
|
+
return {"error": "The file did not arrive intact; upload it again."}
|
|
4081
|
+
try:
|
|
4082
|
+
upload = get_upload_store().put(current_tenant(), name, data)
|
|
4083
|
+
except UploadError as exc:
|
|
4084
|
+
return {"error": f"{name}: {exc}"}
|
|
4085
|
+
return {"status": "UPLOADED", **upload.summary()}
|
|
4086
|
+
|
|
4087
|
+
|
|
4088
|
+
@_tool(title="List uploaded images", annotations=_READONLY, tags={"core"})
|
|
4089
|
+
@_safe
|
|
4090
|
+
def list_uploads() -> dict:
|
|
4091
|
+
"""List the images the user uploaded to AdLoop that have not expired.
|
|
4092
|
+
|
|
4093
|
+
Returns each upload's upload_id, name, type, dimensions, size and hours
|
|
4094
|
+
until it expires (uploads last a day): the ids of files the user sent
|
|
4095
|
+
through the upload link, or of earlier uploads.
|
|
4096
|
+
"""
|
|
4097
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
4098
|
+
|
|
4099
|
+
uploads = [u.summary() for u in get_upload_store().list(current_tenant())]
|
|
4100
|
+
return {
|
|
4101
|
+
"uploads": uploads,
|
|
4102
|
+
"usage": (
|
|
4103
|
+
"draft_image_assets(upload_ids=[...]) for Google Ads; "
|
|
4104
|
+
"\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
|
|
4105
|
+
"image_url in draft_reddit_ad (AdLoop Cloud only)."
|
|
4106
|
+
),
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
|
|
3991
4110
|
# ---------------------------------------------------------------------------
|
|
3992
4111
|
# Reddit Ads Tools
|
|
3993
4112
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>AdLoop upload</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
:root[data-theme="light"] { color-scheme: light; }
|
|
10
|
+
:root[data-theme="dark"] { color-scheme: dark; }
|
|
11
|
+
:root {
|
|
12
|
+
--bg: var(--color-background-primary, #ffffff);
|
|
13
|
+
--bg-2: var(--color-background-secondary, #f4f4f5);
|
|
14
|
+
--fg: var(--color-text-primary, #18181b);
|
|
15
|
+
--fg-2: var(--color-text-secondary, #52525b);
|
|
16
|
+
--border: var(--color-border-primary, #e4e4e7);
|
|
17
|
+
--accent: #2563eb;
|
|
18
|
+
--ok: var(--color-text-success, #15803d);
|
|
19
|
+
--ok-bg: var(--color-background-success, #f0fdf4);
|
|
20
|
+
--bad: var(--color-text-danger, #b91c1c);
|
|
21
|
+
--bad-bg: var(--color-background-danger, #fef2f2);
|
|
22
|
+
--radius: var(--border-radius-lg, 12px);
|
|
23
|
+
--font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
24
|
+
--mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
25
|
+
}
|
|
26
|
+
:root[data-theme="dark"] {
|
|
27
|
+
--bg: var(--color-background-primary, #18181b);
|
|
28
|
+
--bg-2: var(--color-background-secondary, #27272a);
|
|
29
|
+
--fg: var(--color-text-primary, #fafafa);
|
|
30
|
+
--fg-2: var(--color-text-secondary, #a1a1aa);
|
|
31
|
+
--border: var(--color-border-primary, #3f3f46);
|
|
32
|
+
--accent: #3b82f6;
|
|
33
|
+
--ok: var(--color-text-success, #4ade80);
|
|
34
|
+
--ok-bg: var(--color-background-success, #052e16);
|
|
35
|
+
--bad: var(--color-text-danger, #f87171);
|
|
36
|
+
--bad-bg: var(--color-background-danger, #2a0b0b);
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
html, body { margin: 0; background: transparent; }
|
|
40
|
+
body { font: 14px/1.45 var(--font); color: var(--fg); }
|
|
41
|
+
.card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
|
|
42
|
+
.head { display: flex; align-items: center; gap: 8px; color: var(--fg-2); font-size: 12px; }
|
|
43
|
+
h1 { font-size: 16px; font-weight: 600; margin: 10px 0 2px; }
|
|
44
|
+
.sub { color: var(--fg-2); font-size: 12px; margin: 0 0 12px; }
|
|
45
|
+
.drop { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 22px 12px; border: 1.5px dashed var(--border); border-radius: calc(var(--radius) - 4px); background: var(--bg-2); text-align: center; cursor: pointer; }
|
|
46
|
+
.drop.over { border-color: var(--accent); }
|
|
47
|
+
.drop small { color: var(--fg-2); font-size: 12px; }
|
|
48
|
+
.drop button { font: inherit; font-size: 13px; font-weight: 500; border-radius: 8px; padding: 6px 12px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
|
|
49
|
+
ul { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; }
|
|
50
|
+
li { display: grid; grid-template-columns: 40px 1fr auto; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; background: var(--bg-2); }
|
|
51
|
+
li img, li .ph { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; background: var(--border); }
|
|
52
|
+
li .name { overflow-wrap: anywhere; font-size: 13px; }
|
|
53
|
+
li .meta { color: var(--fg-2); font-size: 12px; font-family: var(--mono); }
|
|
54
|
+
li .state { font-size: 12px; }
|
|
55
|
+
li .state.ok { color: var(--ok); }
|
|
56
|
+
li .state.bad { color: var(--bad); }
|
|
57
|
+
.hidden { display: none !important; }
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
<div class="card">
|
|
62
|
+
<div class="head">
|
|
63
|
+
<svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#09090b"/><path d="M16 16 C 13.5 11.5, 6.5 11.5, 6.5 16 C 6.5 20.5, 13.5 20.5, 16 16 C 18.5 11.5, 25.5 11.5, 25.5 16 C 25.5 20.5, 18.5 20.5, 16 16 Z" stroke="#60a5fa" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/></svg>
|
|
64
|
+
<span data-t="brand">AdLoop · image upload</span>
|
|
65
|
+
</div>
|
|
66
|
+
<h1 data-t="title">Upload images for your ads</h1>
|
|
67
|
+
<p class="sub" data-t="sub">PNG, JPEG or GIF, up to 5 MB each. Uploads are kept for a day.</p>
|
|
68
|
+
<label class="drop" id="drop">
|
|
69
|
+
<span data-t="drop">Drop images here</span>
|
|
70
|
+
<small data-t="or">or</small>
|
|
71
|
+
<button type="button" id="pick" data-t="pick">Choose files</button>
|
|
72
|
+
<input type="file" id="file" accept="image/png,image/jpeg,image/gif" multiple class="hidden">
|
|
73
|
+
</label>
|
|
74
|
+
<ul id="list"></ul>
|
|
75
|
+
</div>
|
|
76
|
+
<script>
|
|
77
|
+
(() => {
|
|
78
|
+
"use strict";
|
|
79
|
+
const TOOL = "store_image_upload";
|
|
80
|
+
const MAX_BYTES = 5120 * 1024;
|
|
81
|
+
const TYPES = ["image/png", "image/jpeg", "image/gif"];
|
|
82
|
+
const STRINGS = {
|
|
83
|
+
en: {
|
|
84
|
+
brand: "AdLoop · image upload", title: "Upload images for your ads",
|
|
85
|
+
sub: "PNG, JPEG or GIF, up to 5 MB each. Uploads are kept for a day.",
|
|
86
|
+
drop: "Drop images here", or: "or", pick: "Choose files",
|
|
87
|
+
uploading: "Uploading…", done: "Uploaded", tooBig: "Over 5 MB", wrongType: "Not PNG, JPEG or GIF",
|
|
88
|
+
message: "I uploaded images to AdLoop:\n{list}\nUse these upload ids for the ad.",
|
|
89
|
+
},
|
|
90
|
+
de: {
|
|
91
|
+
brand: "AdLoop · Bilder hochladen", title: "Bilder für deine Anzeigen hochladen",
|
|
92
|
+
sub: "PNG, JPEG oder GIF, bis 5 MB pro Bild. Uploads werden einen Tag aufbewahrt.",
|
|
93
|
+
drop: "Bilder hierher ziehen", or: "oder", pick: "Dateien auswählen",
|
|
94
|
+
uploading: "Wird hochgeladen…", done: "Hochgeladen", tooBig: "Größer als 5 MB", wrongType: "Kein PNG, JPEG oder GIF",
|
|
95
|
+
message: "Ich habe Bilder bei AdLoop hochgeladen:\n{list}\nNutze diese Upload-IDs für die Anzeige.",
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let lang = "en";
|
|
100
|
+
document.documentElement.dataset.theme =
|
|
101
|
+
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
102
|
+
let nextId = 1;
|
|
103
|
+
const pending = new Map();
|
|
104
|
+
const $ = (id) => document.getElementById(id);
|
|
105
|
+
const t = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key] || key;
|
|
106
|
+
|
|
107
|
+
function post(message) { window.parent.postMessage(message, "*"); }
|
|
108
|
+
function request(method, params) {
|
|
109
|
+
const id = nextId++;
|
|
110
|
+
post({ jsonrpc: "2.0", id, method, params });
|
|
111
|
+
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
112
|
+
}
|
|
113
|
+
function notify(method, params) { post({ jsonrpc: "2.0", method, params }); }
|
|
114
|
+
function reportSize() {
|
|
115
|
+
const el = document.documentElement;
|
|
116
|
+
notify("ui/notifications/size-changed", { width: el.scrollWidth, height: el.scrollHeight });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyContext(ctx) {
|
|
120
|
+
if (!ctx) return;
|
|
121
|
+
if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.dataset.theme = ctx.theme;
|
|
122
|
+
const vars = ctx.styles && ctx.styles.variables;
|
|
123
|
+
if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
|
|
124
|
+
if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
|
|
125
|
+
for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function payloadOf(result) {
|
|
129
|
+
if (!result) return {};
|
|
130
|
+
if (result.structuredContent && typeof result.structuredContent === "object") return result.structuredContent;
|
|
131
|
+
const text = (result.content || []).filter((c) => c && c.type === "text").map((c) => c.text).join("");
|
|
132
|
+
try { return JSON.parse(text); } catch (_) { return { error: text }; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function addRow(name, src) {
|
|
136
|
+
const li = document.createElement("li");
|
|
137
|
+
const img = document.createElement(src ? "img" : "div");
|
|
138
|
+
if (src) { img.src = src; img.alt = ""; } else { img.className = "ph"; }
|
|
139
|
+
const info = document.createElement("div");
|
|
140
|
+
const n = document.createElement("div"); n.className = "name"; n.textContent = name;
|
|
141
|
+
const m = document.createElement("div"); m.className = "meta";
|
|
142
|
+
info.append(n, m);
|
|
143
|
+
const state = document.createElement("span"); state.className = "state";
|
|
144
|
+
li.append(img, info, state);
|
|
145
|
+
$("list").append(li);
|
|
146
|
+
reportSize();
|
|
147
|
+
return { meta: m, state };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function describe(u) {
|
|
151
|
+
return u.upload_id + " · " + u.width + "×" + u.height + " · " + u.size_kb + " KB";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readBase64(file) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const reader = new FileReader();
|
|
157
|
+
reader.onload = () => resolve(String(reader.result).split(",", 2)[1] || "");
|
|
158
|
+
reader.onerror = () => reject(reader.error || new Error("read failed"));
|
|
159
|
+
reader.readAsDataURL(file);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function upload(files) {
|
|
164
|
+
const stored = [];
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const row = addRow(file.name, TYPES.includes(file.type) ? URL.createObjectURL(file) : "");
|
|
167
|
+
if (!TYPES.includes(file.type)) { row.state.textContent = t("wrongType"); row.state.classList.add("bad"); continue; }
|
|
168
|
+
if (file.size > MAX_BYTES) { row.state.textContent = t("tooBig"); row.state.classList.add("bad"); continue; }
|
|
169
|
+
row.state.textContent = t("uploading");
|
|
170
|
+
try {
|
|
171
|
+
const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file) } });
|
|
172
|
+
const r = payloadOf(result);
|
|
173
|
+
if ((result && result.isError) || r.error || !r.upload_id) throw new Error(r.error || "upload failed");
|
|
174
|
+
row.meta.textContent = describe(r);
|
|
175
|
+
row.state.textContent = t("done");
|
|
176
|
+
row.state.classList.add("ok");
|
|
177
|
+
stored.push(r);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
row.state.textContent = err && err.message ? err.message : String(err);
|
|
180
|
+
row.state.classList.add("bad");
|
|
181
|
+
}
|
|
182
|
+
reportSize();
|
|
183
|
+
}
|
|
184
|
+
if (stored.length) {
|
|
185
|
+
const list = stored.map((u) => "- " + u.name + ": " + describe(u)).join("\n");
|
|
186
|
+
request("ui/message", { role: "user", content: { type: "text", text: t("message").replace("{list}", list) } }).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const drop = $("drop");
|
|
191
|
+
$("pick").addEventListener("click", (e) => { e.preventDefault(); $("file").click(); });
|
|
192
|
+
$("file").addEventListener("change", () => { upload(Array.from($("file").files || [])); $("file").value = ""; });
|
|
193
|
+
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
|
|
194
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("over"));
|
|
195
|
+
drop.addEventListener("drop", (e) => {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
drop.classList.remove("over");
|
|
198
|
+
upload(Array.from((e.dataTransfer && e.dataTransfer.files) || []));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
let shownExisting = false;
|
|
202
|
+
function onToolResult(result) {
|
|
203
|
+
if (shownExisting || !result) return;
|
|
204
|
+
shownExisting = true;
|
|
205
|
+
const r = payloadOf(result);
|
|
206
|
+
for (const u of r.uploads || []) {
|
|
207
|
+
const row = addRow(u.name, "");
|
|
208
|
+
row.meta.textContent = describe(u);
|
|
209
|
+
row.state.textContent = t("done");
|
|
210
|
+
row.state.classList.add("ok");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
window.addEventListener("message", (event) => {
|
|
215
|
+
if (event.source !== window.parent) return;
|
|
216
|
+
const msg = event.data;
|
|
217
|
+
if (!msg || msg.jsonrpc !== "2.0") return;
|
|
218
|
+
if (msg.id !== undefined && pending.has(msg.id) && !msg.method) {
|
|
219
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
220
|
+
pending.delete(msg.id);
|
|
221
|
+
if (msg.error) reject(new Error(msg.error.message || "error")); else resolve(msg.result);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
switch (msg.method) {
|
|
225
|
+
case "ui/notifications/tool-result": onToolResult(msg.params); break;
|
|
226
|
+
case "ui/notifications/host-context-changed": applyContext(msg.params); break;
|
|
227
|
+
case "ui/resource-teardown": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
|
|
228
|
+
case "ping": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
|
|
229
|
+
default:
|
|
230
|
+
if (msg.id !== undefined && msg.method) post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
new ResizeObserver(reportSize).observe(document.documentElement);
|
|
235
|
+
request("ui/initialize", {
|
|
236
|
+
protocolVersion: "2026-01-26",
|
|
237
|
+
appCapabilities: { availableDisplayModes: ["inline"] },
|
|
238
|
+
clientInfo: { name: "AdLoop upload card", version: "1" },
|
|
239
|
+
}).then((result) => {
|
|
240
|
+
applyContext(result && result.hostContext);
|
|
241
|
+
notify("ui/notifications/initialized", {});
|
|
242
|
+
reportSize();
|
|
243
|
+
}).catch(() => notify("ui/notifications/initialized", {}));
|
|
244
|
+
})();
|
|
245
|
+
</script>
|
|
246
|
+
</body>
|
|
247
|
+
</html>
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Image uploads: files a user hands to AdLoop without a public URL.
|
|
2
|
+
|
|
3
|
+
AI apps cannot pass a file from the chat to a tool, so an upload reaches
|
|
4
|
+
AdLoop on its own path: the upload card (MCP Apps) calls the app-only
|
|
5
|
+
``store_image_upload`` tool with the file, or, on a hosted server, the user
|
|
6
|
+
opens a one-time upload link. Either way the image lands in an
|
|
7
|
+
:class:`UploadStore` under an ``up_...`` id, and the tools take that id:
|
|
8
|
+
``draft_image_assets(upload_ids=[...])`` for Google Ads, ``upload:<id>`` in
|
|
9
|
+
the media fields of ``draft_reddit_ad``.
|
|
10
|
+
|
|
11
|
+
Uploads are short-lived: they expire after a day and exist to be turned
|
|
12
|
+
into ad assets, not to be kept. Self-hosted, the store is a folder under
|
|
13
|
+
``~/.adloop/uploads``. A hosted runtime sets its own store and the links
|
|
14
|
+
(:func:`set_upload_links`): the upload page and the short-lived public file
|
|
15
|
+
link Reddit downloads from.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import secrets
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import asdict, dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Protocol
|
|
29
|
+
|
|
30
|
+
UPLOAD_PREFIX = "upload:"
|
|
31
|
+
UPLOAD_CARD_URI = "ui://adloop/upload-card.html"
|
|
32
|
+
STORE_UPLOAD_TOOL = "store_image_upload"
|
|
33
|
+
MAX_UPLOAD_BYTES = 5120 * 1024 # Google's image asset limit; Reddit takes more
|
|
34
|
+
DEFAULT_TTL_SECONDS = 24 * 3600
|
|
35
|
+
MAX_FILES_PER_TENANT = 50
|
|
36
|
+
MAX_BYTES_PER_TENANT = 100 * 1024 * 1024
|
|
37
|
+
_ID_PATTERN = re.compile(r"^up_[A-Za-z0-9_-]{8,40}$")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Upload:
|
|
42
|
+
upload_id: str
|
|
43
|
+
name: str
|
|
44
|
+
mime_type: str
|
|
45
|
+
width: int
|
|
46
|
+
height: int
|
|
47
|
+
size_bytes: int
|
|
48
|
+
sha256: str
|
|
49
|
+
created_at: float
|
|
50
|
+
expires_at: float
|
|
51
|
+
|
|
52
|
+
def summary(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"upload_id": self.upload_id,
|
|
55
|
+
"name": self.name,
|
|
56
|
+
"mime_type": self.mime_type,
|
|
57
|
+
"width": self.width,
|
|
58
|
+
"height": self.height,
|
|
59
|
+
"size_kb": round(self.size_bytes / 1024, 1),
|
|
60
|
+
"expires_in_hours": max(0, round((self.expires_at - time.time()) / 3600, 1)),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class UploadError(ValueError):
|
|
65
|
+
"""An upload was refused or cannot be found; the message says why."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
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."""
|
|
71
|
+
|
|
72
|
+
def public_file(self, tenant: str, upload_id: str) -> str:
|
|
73
|
+
"""A short-lived public URL serving the upload (for Reddit to download)."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _clean_name(name: str) -> str:
|
|
77
|
+
base = os.path.basename(str(name or "").replace("\\", "/")).strip()
|
|
78
|
+
base = re.sub(r"[\x00-\x1f]", "", base)
|
|
79
|
+
return base[:120] or "image"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def inspect_image(data: bytes) -> tuple[str, int, int]:
|
|
83
|
+
"""(mime_type, width, height) of a PNG, JPEG or GIF; raises UploadError otherwise."""
|
|
84
|
+
from adloop.ads.write import _detect_image_type_and_size
|
|
85
|
+
|
|
86
|
+
if not data:
|
|
87
|
+
raise UploadError("the file is empty")
|
|
88
|
+
if len(data) > MAX_UPLOAD_BYTES:
|
|
89
|
+
raise UploadError(
|
|
90
|
+
f"the file is {len(data) / 1024:.0f} KB; images can be at most {MAX_UPLOAD_BYTES // 1024} KB"
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
return _detect_image_type_and_size(data)
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise UploadError(f"not a usable image: {exc} (PNG, JPEG or GIF)") from exc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LocalUploadStore:
|
|
99
|
+
"""Uploads as files in a folder, one subfolder per tenant.
|
|
100
|
+
|
|
101
|
+
Expired files are removed whenever the store is used; there is no timer.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
root: str | Path,
|
|
107
|
+
*,
|
|
108
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
109
|
+
max_files: int = MAX_FILES_PER_TENANT,
|
|
110
|
+
max_bytes: int = MAX_BYTES_PER_TENANT,
|
|
111
|
+
) -> None:
|
|
112
|
+
self.root = Path(root)
|
|
113
|
+
self.ttl_seconds = ttl_seconds
|
|
114
|
+
self.max_files = max_files
|
|
115
|
+
self.max_bytes = max_bytes
|
|
116
|
+
|
|
117
|
+
def _dir(self, tenant: str) -> Path:
|
|
118
|
+
key = hashlib.sha256(str(tenant or "local").encode()).hexdigest()[:24]
|
|
119
|
+
return self.root / key
|
|
120
|
+
|
|
121
|
+
def _entries(self, tenant: str) -> list[Upload]:
|
|
122
|
+
folder = self._dir(tenant)
|
|
123
|
+
if not folder.is_dir():
|
|
124
|
+
return []
|
|
125
|
+
now = time.time()
|
|
126
|
+
uploads: list[Upload] = []
|
|
127
|
+
for meta_path in folder.glob("up_*.json"):
|
|
128
|
+
try:
|
|
129
|
+
upload = Upload(**json.loads(meta_path.read_text()))
|
|
130
|
+
except (OSError, ValueError, TypeError):
|
|
131
|
+
continue
|
|
132
|
+
if upload.expires_at <= now:
|
|
133
|
+
self._remove(folder, upload.upload_id)
|
|
134
|
+
continue
|
|
135
|
+
uploads.append(upload)
|
|
136
|
+
return sorted(uploads, key=lambda u: u.created_at)
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _remove(folder: Path, upload_id: str) -> None:
|
|
140
|
+
for suffix in (".bin", ".json"):
|
|
141
|
+
try:
|
|
142
|
+
(folder / f"{upload_id}{suffix}").unlink()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def put(self, tenant: str, name: str, data: bytes) -> Upload:
|
|
147
|
+
mime_type, width, height = inspect_image(data)
|
|
148
|
+
existing = self._entries(tenant)
|
|
149
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
150
|
+
for upload in existing:
|
|
151
|
+
if upload.sha256 == digest:
|
|
152
|
+
return upload # the same image twice is one upload
|
|
153
|
+
if len(existing) >= self.max_files:
|
|
154
|
+
raise UploadError(
|
|
155
|
+
f"this workspace already holds {len(existing)} uploads; they expire after a day"
|
|
156
|
+
)
|
|
157
|
+
if sum(u.size_bytes for u in existing) + len(data) > self.max_bytes:
|
|
158
|
+
raise UploadError(
|
|
159
|
+
f"uploads are limited to {self.max_bytes // (1024 * 1024)} MB per workspace; "
|
|
160
|
+
"they expire after a day"
|
|
161
|
+
)
|
|
162
|
+
now = time.time()
|
|
163
|
+
upload = Upload(
|
|
164
|
+
upload_id="up_" + secrets.token_urlsafe(12),
|
|
165
|
+
name=_clean_name(name),
|
|
166
|
+
mime_type=mime_type,
|
|
167
|
+
width=width,
|
|
168
|
+
height=height,
|
|
169
|
+
size_bytes=len(data),
|
|
170
|
+
sha256=digest,
|
|
171
|
+
created_at=now,
|
|
172
|
+
expires_at=now + self.ttl_seconds,
|
|
173
|
+
)
|
|
174
|
+
folder = self._dir(tenant)
|
|
175
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
for suffix, payload in ((".bin", data), (".json", json.dumps(asdict(upload)).encode())):
|
|
177
|
+
tmp = folder / f".{upload.upload_id}{suffix}.tmp"
|
|
178
|
+
tmp.write_bytes(payload)
|
|
179
|
+
os.replace(tmp, folder / f"{upload.upload_id}{suffix}")
|
|
180
|
+
return upload
|
|
181
|
+
|
|
182
|
+
def get(self, tenant: str, upload_id: str) -> tuple[Upload, bytes]:
|
|
183
|
+
upload_id = str(upload_id or "").strip()
|
|
184
|
+
if not _ID_PATTERN.match(upload_id):
|
|
185
|
+
raise UploadError(f"'{upload_id}' is not an upload id (they look like up_...)")
|
|
186
|
+
for upload in self._entries(tenant):
|
|
187
|
+
if upload.upload_id == upload_id:
|
|
188
|
+
data = (self._dir(tenant) / f"{upload_id}.bin").read_bytes()
|
|
189
|
+
return upload, data
|
|
190
|
+
raise UploadError(
|
|
191
|
+
f"upload {upload_id} was not found; uploads expire after a day (see list_uploads)"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def list(self, tenant: str) -> list[Upload]:
|
|
195
|
+
return self._entries(tenant)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_store: LocalUploadStore | None = None
|
|
199
|
+
_links: UploadLinks | None = None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def set_upload_store(store: LocalUploadStore | None) -> None:
|
|
203
|
+
global _store
|
|
204
|
+
_store = store
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def get_upload_store() -> LocalUploadStore:
|
|
208
|
+
global _store
|
|
209
|
+
if _store is None:
|
|
210
|
+
_store = LocalUploadStore(Path.home() / ".adloop" / "uploads")
|
|
211
|
+
return _store
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def set_upload_links(links: UploadLinks | None) -> None:
|
|
215
|
+
"""Hosted runtimes: where users upload, and how Reddit downloads uploads."""
|
|
216
|
+
global _links
|
|
217
|
+
_links = links
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_upload_links() -> UploadLinks | None:
|
|
221
|
+
return _links
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def current_tenant() -> str:
|
|
225
|
+
from adloop.runtime import current_tenant as _tenant
|
|
226
|
+
|
|
227
|
+
return _tenant() or ""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def upload_ref(value: str) -> str | None:
|
|
231
|
+
"""The upload id in an ``upload:<id>`` reference, else None."""
|
|
232
|
+
text = str(value or "").strip()
|
|
233
|
+
if text.lower().startswith(UPLOAD_PREFIX):
|
|
234
|
+
return text[len(UPLOAD_PREFIX):].strip()
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def public_url_for(upload_id: str) -> str:
|
|
239
|
+
"""A URL Reddit can download the upload from; raises UploadError without one."""
|
|
240
|
+
links = get_upload_links()
|
|
241
|
+
if links is None:
|
|
242
|
+
raise UploadError(
|
|
243
|
+
"upload: references need a public link Reddit can download, which only AdLoop "
|
|
244
|
+
"Cloud provides. Self-hosted, pass a public image URL instead."
|
|
245
|
+
)
|
|
246
|
+
get_upload_store().get(current_tenant(), upload_id) # exists and not expired
|
|
247
|
+
return links.public_file(current_tenant(), upload_id)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def upload_instructions(shows_cards: bool) -> dict:
|
|
251
|
+
"""What ``upload_images`` returns: current uploads plus the way to add more."""
|
|
252
|
+
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
|
+
)
|
|
258
|
+
if shows_cards:
|
|
259
|
+
result["upload"] = "card"
|
|
260
|
+
result["instructions"] = (
|
|
261
|
+
"The user sees an AdLoop upload card and drops the images there. The card posts "
|
|
262
|
+
"the upload ids into the chat when it is done; wait for that. " + use
|
|
263
|
+
)
|
|
264
|
+
return result
|
|
265
|
+
links = get_upload_links()
|
|
266
|
+
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
|
+
)
|
|
272
|
+
return result
|
|
273
|
+
url, lifetime = links.upload_page(tenant)
|
|
274
|
+
minutes = max(1, lifetime // 60)
|
|
275
|
+
result.update({
|
|
276
|
+
"upload_url": url,
|
|
277
|
+
"expires_in_minutes": minutes,
|
|
278
|
+
"curl_example": f'curl -T "logo.png" "{url}/"',
|
|
279
|
+
"instructions": (
|
|
280
|
+
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 "
|
|
282
|
+
"the ids. A coding agent with the files on disk can upload them itself with curl "
|
|
283
|
+
"(curl_example; one call per file). " + use
|
|
284
|
+
),
|
|
285
|
+
})
|
|
286
|
+
return result
|
|
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
|
|
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
|
|
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
|
|
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
|