adloop 0.25.0__tar.gz → 0.26.1__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.1}/PKG-INFO +19 -2
- {adloop-0.25.0 → adloop-0.26.1}/README.md +18 -1
- {adloop-0.25.0 → adloop-0.26.1}/pyproject.toml +1 -1
- {adloop-0.25.0 → adloop-0.26.1}/pyproject.toml.orig +1 -1
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/write.py +38 -7
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/write.py +43 -3
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/adloop.md +15 -1
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/server.py +129 -6
- adloop-0.26.1/src/adloop/upload_card.html +247 -0
- adloop-0.26.1/src/adloop/uploads.py +286 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/__main__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/brands.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/currency.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/enums.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/auth.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/cli.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/config.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/crossref.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/diagnostics.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/write.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/write.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/pagespeed.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/client.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/read.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules_install.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/runtime.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/approval.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/audit.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/guards.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/preview.py +0 -0
- {adloop-0.25.0 → adloop-0.26.1}/src/adloop/tracking.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.26.1
|
|
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
|
|
@@ -262,6 +262,19 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
|
|
|
262
262
|
|
|
263
263
|
> **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
|
|
264
264
|
|
|
265
|
+
### Image Uploads
|
|
266
|
+
|
|
267
|
+
An image on your computer, or one you attached in the chat, has no public URL, and an AI app cannot hand the file to a tool. `upload_images` gives it a way in.
|
|
268
|
+
|
|
269
|
+
<p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/upload-card.gif" alt="An AdLoop upload card in an AI chat: the user drags a banner onto it, the card shows the upload id, posts it into the chat, and the AI drafts the image asset" width="760"></p>
|
|
270
|
+
|
|
271
|
+
| Tool | What It Does |
|
|
272
|
+
|------|-------------|
|
|
273
|
+
| `upload_images` | Shows an upload card in apps with MCP Apps (drop PNG, JPEG or GIF up to 5 MB); on AdLoop Cloud in other apps it returns a one-time upload link, which a coding agent can also use with `curl -T` |
|
|
274
|
+
| `list_uploads` | The uploads that have not expired yet (kept 24 hours), with id, dimensions and size |
|
|
275
|
+
|
|
276
|
+
The upload ids go into `draft_image_assets(upload_ids=[...])`, or as `upload:<id>` into the image fields of `draft_reddit_ad` on AdLoop Cloud.
|
|
277
|
+
|
|
265
278
|
### Planning Tools
|
|
266
279
|
|
|
267
280
|
| Tool | What It Does |
|
|
@@ -282,7 +295,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
282
295
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
283
296
|
| `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
|
|
284
297
|
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
285
|
-
| `draft_image_assets` | Create campaign image assets from local files
|
|
298
|
+
| `draft_image_assets` | Create campaign image assets from local files, public image URLs or uploads (PNG, JPEG, or GIF). |
|
|
286
299
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
|
|
287
300
|
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
288
301
|
| `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
|
|
@@ -337,6 +350,10 @@ AdLoop includes pre-built slash commands in `.claude/commands/` for common workf
|
|
|
337
350
|
|
|
338
351
|
AdLoop manages real ad spend, so safety is not optional.
|
|
339
352
|
|
|
353
|
+
<p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/approval-card.gif" alt="An AdLoop approval card in an AI chat: the user clicks Test run, Google validates the change, then Apply makes it live and the card reports back to the chat" width="760"></p>
|
|
354
|
+
|
|
355
|
+
- **Approval cards.** In apps that show MCP Apps (Claude, ChatGPT, VS Code and others), every change appears as a card with Test run and Apply. Only the click applies it: the AI's own `confirm_and_apply(dry_run=false)` is refused with `APPROVE_ON_CARD`, whatever it was told in the chat.
|
|
356
|
+
- **Confirmation dialogs.** Apps without cards that support elicitation (Claude Code, for example) get a dialog from AdLoop itself before every real change. The AI cannot answer it; declining changes nothing.
|
|
340
357
|
- **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
|
|
341
358
|
- **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
|
|
342
359
|
- **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
|
|
@@ -234,6 +234,19 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
|
|
|
234
234
|
|
|
235
235
|
> **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
|
|
236
236
|
|
|
237
|
+
### Image Uploads
|
|
238
|
+
|
|
239
|
+
An image on your computer, or one you attached in the chat, has no public URL, and an AI app cannot hand the file to a tool. `upload_images` gives it a way in.
|
|
240
|
+
|
|
241
|
+
<p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/upload-card.gif" alt="An AdLoop upload card in an AI chat: the user drags a banner onto it, the card shows the upload id, posts it into the chat, and the AI drafts the image asset" width="760"></p>
|
|
242
|
+
|
|
243
|
+
| Tool | What It Does |
|
|
244
|
+
|------|-------------|
|
|
245
|
+
| `upload_images` | Shows an upload card in apps with MCP Apps (drop PNG, JPEG or GIF up to 5 MB); on AdLoop Cloud in other apps it returns a one-time upload link, which a coding agent can also use with `curl -T` |
|
|
246
|
+
| `list_uploads` | The uploads that have not expired yet (kept 24 hours), with id, dimensions and size |
|
|
247
|
+
|
|
248
|
+
The upload ids go into `draft_image_assets(upload_ids=[...])`, or as `upload:<id>` into the image fields of `draft_reddit_ad` on AdLoop Cloud.
|
|
249
|
+
|
|
237
250
|
### Planning Tools
|
|
238
251
|
|
|
239
252
|
| Tool | What It Does |
|
|
@@ -254,7 +267,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
254
267
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
255
268
|
| `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
|
|
256
269
|
| `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
|
|
257
|
-
| `draft_image_assets` | Create campaign image assets from local files
|
|
270
|
+
| `draft_image_assets` | Create campaign image assets from local files, public image URLs or uploads (PNG, JPEG, or GIF). |
|
|
258
271
|
| `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
|
|
259
272
|
| `add_negative_keywords` | Propose negative keywords directly on a campaign |
|
|
260
273
|
| `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
|
|
@@ -309,6 +322,10 @@ AdLoop includes pre-built slash commands in `.claude/commands/` for common workf
|
|
|
309
322
|
|
|
310
323
|
AdLoop manages real ad spend, so safety is not optional.
|
|
311
324
|
|
|
325
|
+
<p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/approval-card.gif" alt="An AdLoop approval card in an AI chat: the user clicks Test run, Google validates the change, then Apply makes it live and the card reports back to the chat" width="760"></p>
|
|
326
|
+
|
|
327
|
+
- **Approval cards.** In apps that show MCP Apps (Claude, ChatGPT, VS Code and others), every change appears as a card with Test run and Apply. Only the click applies it: the AI's own `confirm_and_apply(dry_run=false)` is refused with `APPROVE_ON_CARD`, whatever it was told in the chat.
|
|
328
|
+
- **Confirmation dialogs.** Apps without cards that support elicitation (Claude Code, for example) get a dialog from AdLoop itself before every real change. The AI cannot answer it; declining changes nothing.
|
|
312
329
|
- **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
|
|
313
330
|
- **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
|
|
314
331
|
- **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
|
|
@@ -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. **Attaching an image to the chat does not get it into an ad:** you can see it, but no tool can receive it. Never suggest attaching as the way in; offer `upload_images` (card or link) or a public image URL, and when the user already attached one, ask them to drop the same file on the upload card.
|
|
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,13 @@ 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, or one "
|
|
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. An image "
|
|
152
|
+
"attached to the chat cannot be passed to any tool, so don't suggest "
|
|
153
|
+
"attaching as the way to get it into an ad; offer the upload or a public "
|
|
154
|
+
"image URL.\n\n"
|
|
148
155
|
"PRE-WRITE CHECKS (before any `draft_*`):\n"
|
|
149
156
|
"- BROAD match keywords require Smart Bidding (MAXIMIZE_CONVERSIONS, "
|
|
150
157
|
"tCPA, tROAS). Refuse BROAD on MANUAL_CPC. This is the #1 cause of "
|
|
@@ -211,6 +218,27 @@ def approval_card() -> str:
|
|
|
211
218
|
return card_html()
|
|
212
219
|
|
|
213
220
|
|
|
221
|
+
@mcp.resource(
|
|
222
|
+
UPLOAD_CARD_URI,
|
|
223
|
+
name="upload_card",
|
|
224
|
+
title="AdLoop image upload",
|
|
225
|
+
description="A drop zone that uploads images to AdLoop for use as ad assets.",
|
|
226
|
+
mime_type=CARD_MIME_TYPE,
|
|
227
|
+
meta={"ui": {"prefersBorder": False}},
|
|
228
|
+
)
|
|
229
|
+
def upload_card() -> str:
|
|
230
|
+
from importlib import resources
|
|
231
|
+
|
|
232
|
+
return resources.files("adloop").joinpath("upload_card.html").read_text(encoding="utf-8")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# App-only tools and the card that calls each one.
|
|
236
|
+
_APP_ONLY_TOOLS = {
|
|
237
|
+
APPROVE_TOOL: "AdLoop approval card",
|
|
238
|
+
STORE_UPLOAD_TOOL: "AdLoop upload card",
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
214
242
|
class _AppOnlyToolsForCardClients(Middleware):
|
|
215
243
|
"""List and run app-only tools only for apps that render cards.
|
|
216
244
|
|
|
@@ -226,10 +254,11 @@ class _AppOnlyToolsForCardClients(Middleware):
|
|
|
226
254
|
return [tool for tool in tools if not is_app_only(tool.meta)]
|
|
227
255
|
|
|
228
256
|
async def on_call_tool(self, context, call_next):
|
|
229
|
-
|
|
257
|
+
card = _APP_ONLY_TOOLS.get(context.message.name)
|
|
258
|
+
if card and not client_shows_cards():
|
|
230
259
|
from fastmcp.exceptions import ToolError
|
|
231
260
|
|
|
232
|
-
raise ToolError(f"{
|
|
261
|
+
raise ToolError(f"{context.message.name} can only be called from the {card}.")
|
|
233
262
|
return await call_next(context)
|
|
234
263
|
|
|
235
264
|
|
|
@@ -324,7 +353,7 @@ _API_DOCS: dict[str, str] = {
|
|
|
324
353
|
|
|
325
354
|
|
|
326
355
|
# Write tools that do not produce a preview of their own.
|
|
327
|
-
_NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL})
|
|
356
|
+
_NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL, STORE_UPLOAD_TOOL})
|
|
328
357
|
|
|
329
358
|
|
|
330
359
|
def _with_approval_card(fn: Callable) -> Callable:
|
|
@@ -3684,10 +3713,12 @@ def draft_image_assets(
|
|
|
3684
3713
|
image_paths: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
|
|
3685
3714
|
image_urls: _StrList = [], # noqa: B006
|
|
3686
3715
|
customer_id: str = "",
|
|
3716
|
+
upload_ids: _StrList = [], # noqa: B006
|
|
3687
3717
|
) -> dict:
|
|
3688
|
-
"""Draft campaign image assets from local files
|
|
3718
|
+
"""Draft campaign image assets from local files, public image URLs or uploads — returns a PREVIEW.
|
|
3689
3719
|
|
|
3690
|
-
At least one image in total is required across image_paths
|
|
3720
|
+
At least one image in total is required across image_paths, image_urls
|
|
3721
|
+
and upload_ids.
|
|
3691
3722
|
Images must be PNG, JPEG or GIF and at most 5120 KB (Google's limit).
|
|
3692
3723
|
URL images are downloaded now and checked; applying re-downloads them
|
|
3693
3724
|
and refuses if the image changed since the preview. Returns a preview with
|
|
@@ -3703,6 +3734,9 @@ def draft_image_assets(
|
|
|
3703
3734
|
private, local or internal addresses are refused.
|
|
3704
3735
|
customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
|
|
3705
3736
|
the configured account.
|
|
3737
|
+
upload_ids: Ids of images the user uploaded (up_..., from
|
|
3738
|
+
upload_images or list_uploads). The way to use an image that has
|
|
3739
|
+
no public URL.
|
|
3706
3740
|
"""
|
|
3707
3741
|
from adloop.ads.write import draft_image_assets as _impl
|
|
3708
3742
|
|
|
@@ -3712,6 +3746,7 @@ def draft_image_assets(
|
|
|
3712
3746
|
campaign_id=campaign_id,
|
|
3713
3747
|
image_paths=image_paths,
|
|
3714
3748
|
image_urls=image_urls,
|
|
3749
|
+
upload_ids=upload_ids,
|
|
3715
3750
|
)
|
|
3716
3751
|
|
|
3717
3752
|
|
|
@@ -3988,6 +4023,94 @@ def approve_change(
|
|
|
3988
4023
|
return _impl(config, plan_id=plan_id, dry_run=action == "test")
|
|
3989
4024
|
|
|
3990
4025
|
|
|
4026
|
+
# ---------------------------------------------------------------------------
|
|
4027
|
+
# Image uploads
|
|
4028
|
+
# ---------------------------------------------------------------------------
|
|
4029
|
+
# Images without a public URL reach AdLoop through the upload card (apps with
|
|
4030
|
+
# MCP Apps) or an upload link (hosted, every other app). Tools then take the
|
|
4031
|
+
# upload id: draft_image_assets(upload_ids=...), upload:<id> for Reddit.
|
|
4032
|
+
|
|
4033
|
+
|
|
4034
|
+
@_tool(
|
|
4035
|
+
title="Upload images",
|
|
4036
|
+
annotations=_READONLY,
|
|
4037
|
+
tags={"core"},
|
|
4038
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
|
|
4039
|
+
)
|
|
4040
|
+
@_safe
|
|
4041
|
+
def upload_images() -> dict:
|
|
4042
|
+
"""Let the user upload images (PNG, JPEG, GIF up to 5 MB) for ad assets.
|
|
4043
|
+
|
|
4044
|
+
For images without a public URL, such as a file on the user's computer
|
|
4045
|
+
or an image attached in the chat. An attached image cannot reach a tool,
|
|
4046
|
+
so the user drops the same file on the card. Apps that show AdLoop's
|
|
4047
|
+
cards display an upload card where the user drops the files; the card
|
|
4048
|
+
then posts the upload ids into the chat. On AdLoop Cloud in other apps the result
|
|
4049
|
+
carries an upload link for the user (or a coding agent, with curl).
|
|
4050
|
+
Uploads expire after a day. The ids go into upload_ids of
|
|
4051
|
+
draft_image_assets, or as "upload:<id>" into the image fields of
|
|
4052
|
+
draft_reddit_ad. Returns the current uploads and how to add more.
|
|
4053
|
+
"""
|
|
4054
|
+
from adloop.uploads import upload_instructions
|
|
4055
|
+
|
|
4056
|
+
return upload_instructions(client_shows_cards())
|
|
4057
|
+
|
|
4058
|
+
|
|
4059
|
+
@_tool(
|
|
4060
|
+
title="Store an uploaded image",
|
|
4061
|
+
annotations=_WRITE,
|
|
4062
|
+
tags={"core"},
|
|
4063
|
+
meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
|
|
4064
|
+
)
|
|
4065
|
+
@_safe
|
|
4066
|
+
def store_image_upload(name: str, data_base64: str) -> dict:
|
|
4067
|
+
"""Store an image dropped on the AdLoop upload card.
|
|
4068
|
+
|
|
4069
|
+
Called only by the upload card; AI apps keep this tool out of the
|
|
4070
|
+
model's tool list. Returns the upload's id, type and dimensions.
|
|
4071
|
+
|
|
4072
|
+
Args:
|
|
4073
|
+
name: The file name, used for the asset name.
|
|
4074
|
+
data_base64: The file content, base64-encoded.
|
|
4075
|
+
"""
|
|
4076
|
+
import base64
|
|
4077
|
+
import binascii
|
|
4078
|
+
|
|
4079
|
+
from adloop.uploads import UploadError, current_tenant, get_upload_store
|
|
4080
|
+
|
|
4081
|
+
try:
|
|
4082
|
+
data = base64.b64decode(data_base64, validate=True)
|
|
4083
|
+
except (binascii.Error, ValueError):
|
|
4084
|
+
return {"error": "The file did not arrive intact; upload it again."}
|
|
4085
|
+
try:
|
|
4086
|
+
upload = get_upload_store().put(current_tenant(), name, data)
|
|
4087
|
+
except UploadError as exc:
|
|
4088
|
+
return {"error": f"{name}: {exc}"}
|
|
4089
|
+
return {"status": "UPLOADED", **upload.summary()}
|
|
4090
|
+
|
|
4091
|
+
|
|
4092
|
+
@_tool(title="List uploaded images", annotations=_READONLY, tags={"core"})
|
|
4093
|
+
@_safe
|
|
4094
|
+
def list_uploads() -> dict:
|
|
4095
|
+
"""List the images the user uploaded to AdLoop that have not expired.
|
|
4096
|
+
|
|
4097
|
+
Returns each upload's upload_id, name, type, dimensions, size and hours
|
|
4098
|
+
until it expires (uploads last a day): the ids of files the user sent
|
|
4099
|
+
through the upload link, or of earlier uploads.
|
|
4100
|
+
"""
|
|
4101
|
+
from adloop.uploads import current_tenant, get_upload_store
|
|
4102
|
+
|
|
4103
|
+
uploads = [u.summary() for u in get_upload_store().list(current_tenant())]
|
|
4104
|
+
return {
|
|
4105
|
+
"uploads": uploads,
|
|
4106
|
+
"usage": (
|
|
4107
|
+
"draft_image_assets(upload_ids=[...]) for Google Ads; "
|
|
4108
|
+
"\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
|
|
4109
|
+
"image_url in draft_reddit_ad (AdLoop Cloud only)."
|
|
4110
|
+
),
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
|
|
3991
4114
|
# ---------------------------------------------------------------------------
|
|
3992
4115
|
# Reddit Ads Tools
|
|
3993
4116
|
# ---------------------------------------------------------------------------
|
|
@@ -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
|