@topy-ai/maggie 0.7.47 → 0.7.49
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.
- package/README-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pure, deterministic checks for Maggie Social Share adapters, connections, and the slot queue.
|
|
3
|
+
|
|
4
|
+
No function in this module calls a provider, a model, or the network. Every
|
|
5
|
+
input is a reviewable JSON document and every output is a JSON-serialisable
|
|
6
|
+
report, so the same contract can be enforced in CI, in the CLI, and in a host
|
|
7
|
+
application's own tests.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
12
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
13
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
WEEKDAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
|
|
17
|
+
SECRET_KEYS = {"client_secret", "access_token", "refresh_token", "token", "secret"}
|
|
18
|
+
REVOKED_PROVIDER_STATES = {"revoked", "invalid", "expired", "unauthorized"}
|
|
19
|
+
# Hosts whose avatar/media URLs are known to be signed with an expiry.
|
|
20
|
+
SIGNED_MEDIA_HOSTS = ("media.licdn.com", "fbcdn.net", "cdninstagram.com", "pbs.twimg.com")
|
|
21
|
+
# Query parameters that carry an absolute expiry (unix seconds, or hex for Meta's `oe`).
|
|
22
|
+
EXPIRY_PARAMS = ("e", "oe", "expires", "Expires")
|
|
23
|
+
RELATIVE_EXPIRY_PARAMS = ("X-Amz-Expires", "X-Goog-Expires")
|
|
24
|
+
CAMPAIGN_STATUSES = {"planned", "started", "paused", "completed"}
|
|
25
|
+
IDEA_STATUSES = {"planned", "written"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_instant(value: str, label: str) -> datetime:
|
|
29
|
+
try:
|
|
30
|
+
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
31
|
+
except ValueError as error:
|
|
32
|
+
raise ValueError(f"{label} must be an ISO 8601 date-time") from error
|
|
33
|
+
if parsed.tzinfo is None:
|
|
34
|
+
raise ValueError(f"{label} must include a UTC offset")
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def iso(value: datetime) -> str:
|
|
39
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- T01: canonical OAuth scope sets -----------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_adapters(manifest: dict) -> dict:
|
|
46
|
+
"""Fail when connect paths disagree on scopes; warn about OAuth clients shared across products.
|
|
47
|
+
|
|
48
|
+
LinkedIn (and other providers) invalidate a member's earlier tokens when a
|
|
49
|
+
later sign-in requests a different scope set, so every connect path for one
|
|
50
|
+
provider must request exactly the adapter's canonical set.
|
|
51
|
+
"""
|
|
52
|
+
errors: list[str] = []
|
|
53
|
+
warnings: list[str] = []
|
|
54
|
+
adapters = manifest.get("adapters")
|
|
55
|
+
if not isinstance(adapters, list) or not adapters:
|
|
56
|
+
return {"valid": False, "errors": ["adapters must be a non-empty list"], "warnings": []}
|
|
57
|
+
seen_clients: dict[str, str] = {}
|
|
58
|
+
for index, adapter in enumerate(adapters):
|
|
59
|
+
label = f"adapters[{index}]"
|
|
60
|
+
if not isinstance(adapter, dict):
|
|
61
|
+
errors.append(f"{label} must be an object")
|
|
62
|
+
continue
|
|
63
|
+
provider = adapter.get("provider") or label
|
|
64
|
+
label = f"adapter {provider}"
|
|
65
|
+
leaked = sorted(SECRET_KEYS & set(adapter))
|
|
66
|
+
if leaked:
|
|
67
|
+
errors.append(f"{label} must not contain secret fields: {', '.join(leaked)}")
|
|
68
|
+
canonical = adapter.get("canonical_scopes")
|
|
69
|
+
if not isinstance(canonical, list) or not canonical or not all(isinstance(scope, str) and scope for scope in canonical):
|
|
70
|
+
errors.append(f"{label} must declare canonical_scopes as a non-empty list of strings")
|
|
71
|
+
continue
|
|
72
|
+
canonical_set = set(canonical)
|
|
73
|
+
paths = adapter.get("connect_paths")
|
|
74
|
+
if not isinstance(paths, list) or not paths:
|
|
75
|
+
errors.append(f"{label} must list connect_paths (every client that starts the OAuth flow)")
|
|
76
|
+
paths = []
|
|
77
|
+
for path in paths:
|
|
78
|
+
path_id = path.get("id") if isinstance(path, dict) else None
|
|
79
|
+
scopes = path.get("scopes") if isinstance(path, dict) else None
|
|
80
|
+
if not path_id or not isinstance(scopes, list):
|
|
81
|
+
errors.append(f"{label} connect path needs id and scopes")
|
|
82
|
+
continue
|
|
83
|
+
missing = sorted(canonical_set - set(scopes))
|
|
84
|
+
extra = sorted(set(scopes) - canonical_set)
|
|
85
|
+
if missing or extra:
|
|
86
|
+
detail = "; ".join(part for part in (
|
|
87
|
+
f"missing {', '.join(missing)}" if missing else "",
|
|
88
|
+
f"extra {', '.join(extra)}" if extra else "",
|
|
89
|
+
) if part)
|
|
90
|
+
errors.append(f"{label} connect path {path_id} requests a different scope set ({detail}); "
|
|
91
|
+
"a sign-in with a different scope set can revoke tokens issued to the other paths")
|
|
92
|
+
client = str(adapter.get("client_id_ref") or "")
|
|
93
|
+
if client:
|
|
94
|
+
if client in seen_clients and seen_clients[client] != provider:
|
|
95
|
+
warnings.append(f"{label} reuses OAuth client {client} from adapter {seen_clients[client]}")
|
|
96
|
+
seen_clients.setdefault(client, provider)
|
|
97
|
+
shared = adapter.get("client_shared_with") or []
|
|
98
|
+
if shared:
|
|
99
|
+
warnings.append(f"{label} OAuth client is shared with {len(shared)} other product(s); their sign-ins can "
|
|
100
|
+
"revoke this product's tokens unless they request the identical scope set — prefer a dedicated client")
|
|
101
|
+
if adapter.get("publishes"):
|
|
102
|
+
errors.extend(f"{label} {problem}" for problem in publish_contract_errors(adapter))
|
|
103
|
+
hosts = sorted({urlsplit(uri).hostname or "" for uri in adapter.get("redirect_uris") or [] if isinstance(uri, str)} - {""})
|
|
104
|
+
if len(hosts) > 1 and not shared:
|
|
105
|
+
warnings.append(f"{label} redirect URIs span {len(hosts)} hosts ({', '.join(hosts)}); confirm they belong to "
|
|
106
|
+
"one product or record them in client_shared_with")
|
|
107
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --- D33: publish contract and reconciliation ----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
RETRY_CLASSES = ("retryable", "terminal", "unknown_outcome")
|
|
114
|
+
READBACK_LOOKUPS = {"by_idempotency_key", "by_marker", "by_recent_posts"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def publish_contract_errors(adapter: dict) -> list[str]:
|
|
118
|
+
"""A publishing adapter declares limits, media rules, pending behaviour, retry classes, idempotency, and readback."""
|
|
119
|
+
problems: list[str] = []
|
|
120
|
+
limits = adapter.get("limits")
|
|
121
|
+
if not isinstance(limits, dict) or not isinstance(limits.get("text_max"), int) or limits["text_max"] <= 0:
|
|
122
|
+
problems.append("publish contract needs limits.text_max")
|
|
123
|
+
media = adapter.get("media")
|
|
124
|
+
if not isinstance(media, dict) or not isinstance(media.get("types"), list) or not isinstance(media.get("max_bytes"), int):
|
|
125
|
+
problems.append("publish contract needs media.types and media.max_bytes")
|
|
126
|
+
pending = adapter.get("pending")
|
|
127
|
+
if not isinstance(pending, dict) or not isinstance(pending.get("max_pending_seconds"), int) or not pending.get("states"):
|
|
128
|
+
problems.append("publish contract needs pending.states and pending.max_pending_seconds")
|
|
129
|
+
retry = adapter.get("retry_classes")
|
|
130
|
+
if not isinstance(retry, dict) or any(not isinstance(retry.get(name), list) for name in RETRY_CLASSES):
|
|
131
|
+
problems.append("publish contract needs retry_classes.retryable, .terminal and .unknown_outcome lists")
|
|
132
|
+
else:
|
|
133
|
+
overlap = (set(retry["retryable"]) & set(retry["terminal"])) | (set(retry["retryable"]) & set(retry["unknown_outcome"]))
|
|
134
|
+
if overlap:
|
|
135
|
+
problems.append(f"retry classes overlap: {', '.join(sorted(overlap))}")
|
|
136
|
+
if "timeout" in retry["retryable"]:
|
|
137
|
+
problems.append("timeout must be classed unknown_outcome, not retryable: the post may already exist")
|
|
138
|
+
idem = adapter.get("idempotency")
|
|
139
|
+
if not isinstance(idem, dict) or not idem.get("key_source"):
|
|
140
|
+
problems.append("publish contract needs idempotency.key_source")
|
|
141
|
+
idem = {}
|
|
142
|
+
readback = adapter.get("readback")
|
|
143
|
+
if not isinstance(readback, dict) or readback.get("lookup") not in READBACK_LOOKUPS:
|
|
144
|
+
problems.append(f"publish contract needs readback.lookup ({', '.join(sorted(READBACK_LOOKUPS))})")
|
|
145
|
+
elif not idem.get("provider_supported") and readback["lookup"] == "by_idempotency_key":
|
|
146
|
+
problems.append("provider has no idempotency support; readback must look up by_marker or by_recent_posts")
|
|
147
|
+
return problems
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _matches(post: dict, key: str) -> bool:
|
|
151
|
+
return post.get("idempotency_key") == key or (isinstance(post.get("marker"), str) and key in post["marker"])
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def reconcile(document: dict) -> dict:
|
|
155
|
+
"""Decide the next safe action per queue item from the attempt log and provider readback.
|
|
156
|
+
|
|
157
|
+
Timeout-after-success never produces a duplicate: an unknown outcome waits
|
|
158
|
+
for readback, a readback post carrying the idempotency key is adopted, and
|
|
159
|
+
a retry always reuses the original key.
|
|
160
|
+
"""
|
|
161
|
+
errors: list[str] = []
|
|
162
|
+
warnings: list[str] = []
|
|
163
|
+
attempts = document.get("attempts")
|
|
164
|
+
readback = document.get("readback") or {}
|
|
165
|
+
posts = readback.get("posts") if isinstance(readback, dict) else None
|
|
166
|
+
if not isinstance(attempts, list) or not isinstance(posts, list):
|
|
167
|
+
return {"valid": False, "errors": ["attempts and readback.posts must be lists"], "warnings": [], "items": []}
|
|
168
|
+
complete = bool(readback.get("complete"))
|
|
169
|
+
by_item: dict[str, list[dict]] = {}
|
|
170
|
+
for attempt in attempts:
|
|
171
|
+
if not isinstance(attempt, dict) or not attempt.get("queue_item_id") or not attempt.get("idempotency_key"):
|
|
172
|
+
errors.append("each attempt needs queue_item_id and idempotency_key")
|
|
173
|
+
continue
|
|
174
|
+
by_item.setdefault(attempt["queue_item_id"], []).append(attempt)
|
|
175
|
+
items = []
|
|
176
|
+
for item_id, history in by_item.items():
|
|
177
|
+
keys = {attempt["idempotency_key"] for attempt in history}
|
|
178
|
+
if len(keys) > 1:
|
|
179
|
+
errors.append(f"queue item {item_id} used {len(keys)} idempotency keys; retries must reuse the original key")
|
|
180
|
+
key = history[0]["idempotency_key"]
|
|
181
|
+
matched = [post for post in posts if isinstance(post, dict) and _matches(post, key)]
|
|
182
|
+
claimed = sorted({attempt["provider_post_id"] for attempt in history if attempt.get("provider_post_id")})
|
|
183
|
+
ids = sorted({post.get("id") for post in matched} | set(claimed))
|
|
184
|
+
if len(matched) > 1 or len(ids) > 1:
|
|
185
|
+
errors.append(f"queue item {item_id} has duplicate provider posts ({', '.join(map(str, ids))}); delete extras and keep the first")
|
|
186
|
+
items.append({"queue_item_id": item_id, "action": "resolve_duplicate", "provider_post_ids": ids, "retry_allowed": False})
|
|
187
|
+
continue
|
|
188
|
+
last = history[-1].get("status")
|
|
189
|
+
if matched:
|
|
190
|
+
action, status = "adopt", "published"
|
|
191
|
+
elif claimed:
|
|
192
|
+
action, status = "verify_readback", "published"
|
|
193
|
+
warnings.append(f"queue item {item_id} reports {claimed[0]} but readback did not find it")
|
|
194
|
+
elif last in {"timeout", "pending", "unknown"}:
|
|
195
|
+
if complete:
|
|
196
|
+
action, status = "retry_same_key", "retrying"
|
|
197
|
+
else:
|
|
198
|
+
action, status = "await_readback", "pending"
|
|
199
|
+
elif last == "failed_retryable":
|
|
200
|
+
action, status = "retry_same_key", "retrying"
|
|
201
|
+
elif last == "failed_terminal":
|
|
202
|
+
action, status = "fail", "failed"
|
|
203
|
+
else:
|
|
204
|
+
action, status = "await_readback", "pending"
|
|
205
|
+
warnings.append(f"queue item {item_id} has unclassified last status {last!r}")
|
|
206
|
+
items.append({"queue_item_id": item_id, "idempotency_key": key, "action": action, "status": status,
|
|
207
|
+
"provider_post_id": (matched[0].get("id") if matched else (claimed[0] if claimed else None)),
|
|
208
|
+
"retry_allowed": action == "retry_same_key"})
|
|
209
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings, "readback_complete": complete, "items": items}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --- T02: connection health --------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def avatar_expiry(url: str) -> tuple[bool, datetime | None]:
|
|
216
|
+
"""Return (is_signed, absolute_expiry) for a provider media URL."""
|
|
217
|
+
parsed = urlsplit(url)
|
|
218
|
+
host = (parsed.hostname or "").lower()
|
|
219
|
+
query = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
220
|
+
for key in EXPIRY_PARAMS:
|
|
221
|
+
raw = query.get(key)
|
|
222
|
+
if not raw:
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
seconds = int(raw, 16) if key == "oe" else int(raw)
|
|
226
|
+
except ValueError:
|
|
227
|
+
continue
|
|
228
|
+
if seconds > 10**12:
|
|
229
|
+
seconds //= 1000
|
|
230
|
+
return True, datetime.fromtimestamp(seconds, tz=timezone.utc)
|
|
231
|
+
if any(key in query for key in RELATIVE_EXPIRY_PARAMS) or "Signature" in query or "X-Amz-Signature" in query:
|
|
232
|
+
return True, None
|
|
233
|
+
return any(host == known or host.endswith("." + known) for known in SIGNED_MEDIA_HOSTS), None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def connection_health(document: dict, now: datetime) -> dict:
|
|
237
|
+
"""Derive each connection's real status before anything is scheduled on it."""
|
|
238
|
+
connections = document.get("connections")
|
|
239
|
+
if not isinstance(connections, list):
|
|
240
|
+
return {"valid": False, "errors": ["connections must be a list"], "warnings": [], "connections": []}
|
|
241
|
+
errors: list[str] = []
|
|
242
|
+
warnings: list[str] = []
|
|
243
|
+
report = []
|
|
244
|
+
for connection in connections:
|
|
245
|
+
if not isinstance(connection, dict):
|
|
246
|
+
errors.append("each connection must be an object")
|
|
247
|
+
continue
|
|
248
|
+
cid = connection.get("id") or "?"
|
|
249
|
+
stored = connection.get("status")
|
|
250
|
+
reasons: list[str] = []
|
|
251
|
+
if connection.get("token_decryptable") is False:
|
|
252
|
+
reasons.append("token cannot be decrypted on this host (check encryption-key parity)")
|
|
253
|
+
provider_state = (connection.get("provider_check") or {}).get("status")
|
|
254
|
+
if provider_state in REVOKED_PROVIDER_STATES:
|
|
255
|
+
reasons.append(f"provider reports token {provider_state}")
|
|
256
|
+
elif provider_state in (None, "unknown"):
|
|
257
|
+
warnings.append(f"connection {cid} has no provider token check; verify before scheduling")
|
|
258
|
+
derived = "needs_reconnect" if reasons else (stored or "unknown")
|
|
259
|
+
if reasons and stored == "active":
|
|
260
|
+
scheduled = int(connection.get("scheduled_items") or 0)
|
|
261
|
+
errors.append(f"connection {cid} is stored as active but must be needs_reconnect: {'; '.join(reasons)}"
|
|
262
|
+
+ (f" ({scheduled} scheduled item(s) would fail at publish time)" if scheduled else ""))
|
|
263
|
+
avatar = connection.get("avatar_url")
|
|
264
|
+
avatar_state = "none"
|
|
265
|
+
if isinstance(avatar, str) and avatar:
|
|
266
|
+
signed, expires = avatar_expiry(avatar)
|
|
267
|
+
avatar_state = "stable"
|
|
268
|
+
if signed:
|
|
269
|
+
avatar_state = "signed"
|
|
270
|
+
if expires and expires <= now:
|
|
271
|
+
avatar_state = "expired"
|
|
272
|
+
if not connection.get("avatar_cached") and not connection.get("avatar_refresh_on_read"):
|
|
273
|
+
when = f" (expires {iso(expires)})" if expires else ""
|
|
274
|
+
errors.append(f"connection {cid} stores a signed, expiring provider avatar URL{when}; cache the image "
|
|
275
|
+
"bytes in owned storage or refresh the URL on readback")
|
|
276
|
+
report.append({"id": cid, "provider": connection.get("provider"), "stored_status": stored,
|
|
277
|
+
"status": derived, "reasons": reasons, "avatar": avatar_state})
|
|
278
|
+
return {"valid": not errors, "errors": errors, "warnings": warnings, "connections": report}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --- T03: campaign idea stage ------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def campaign_readiness(campaign: dict) -> list[str]:
|
|
285
|
+
"""Return the blockers that prevent a campaign from starting (empty list = can start)."""
|
|
286
|
+
blockers: list[str] = []
|
|
287
|
+
ideas = campaign.get("ideas")
|
|
288
|
+
if not isinstance(ideas, list) or not ideas:
|
|
289
|
+
return ["campaign has no ideas"]
|
|
290
|
+
for idea in ideas:
|
|
291
|
+
status = idea.get("status") if isinstance(idea, dict) else None
|
|
292
|
+
if status not in IDEA_STATUSES:
|
|
293
|
+
blockers.append(f"idea {idea.get('id') if isinstance(idea, dict) else '?'} has invalid status")
|
|
294
|
+
elif status == "planned":
|
|
295
|
+
blockers.append(f"idea {idea.get('id')} is not written yet")
|
|
296
|
+
elif not idea.get("draft_id"):
|
|
297
|
+
blockers.append(f"idea {idea.get('id')} is written but has no draft_id")
|
|
298
|
+
orders = [idea.get("order") for idea in ideas if isinstance(idea, dict)]
|
|
299
|
+
if any(not isinstance(order, int) for order in orders) or len(set(orders)) != len(orders):
|
|
300
|
+
blockers.append("ideas need unique integer order values")
|
|
301
|
+
try:
|
|
302
|
+
date.fromisoformat(str(campaign.get("start_day")))
|
|
303
|
+
except ValueError:
|
|
304
|
+
blockers.append("start_day (YYYY-MM-DD) is required")
|
|
305
|
+
per_day = campaign.get("posts_per_day")
|
|
306
|
+
if not isinstance(per_day, int) or per_day < 1:
|
|
307
|
+
blockers.append("posts_per_day must be a positive integer")
|
|
308
|
+
return blockers
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_campaigns(document: dict) -> dict:
|
|
312
|
+
errors: list[str] = []
|
|
313
|
+
report = []
|
|
314
|
+
for campaign in document.get("campaigns") or []:
|
|
315
|
+
cid = campaign.get("id") or "?"
|
|
316
|
+
status = campaign.get("status", "planned")
|
|
317
|
+
if status not in CAMPAIGN_STATUSES:
|
|
318
|
+
errors.append(f"campaign {cid} has invalid status {status}")
|
|
319
|
+
continue
|
|
320
|
+
blockers = campaign_readiness(campaign)
|
|
321
|
+
if status == "started" and blockers:
|
|
322
|
+
errors.append(f"campaign {cid} is started but not startable: {'; '.join(blockers)}")
|
|
323
|
+
report.append({"id": cid, "status": status, "can_start": not blockers, "blockers": blockers,
|
|
324
|
+
"ideas_planned": sum(1 for idea in campaign.get("ideas") or [] if idea.get("status") == "planned"),
|
|
325
|
+
"ideas_written": sum(1 for idea in campaign.get("ideas") or [] if idea.get("status") == "written")})
|
|
326
|
+
return {"valid": not errors, "errors": errors, "campaigns": report}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --- T03: slot-based queue planner ------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def parse_posting_hours(hours: dict) -> dict[int, list[time]]:
|
|
333
|
+
if not isinstance(hours, dict) or not hours:
|
|
334
|
+
raise ValueError("posting_hours must map weekdays (mon..sun) to HH:MM lists")
|
|
335
|
+
result: dict[int, list[time]] = {}
|
|
336
|
+
for key, values in hours.items():
|
|
337
|
+
if key not in WEEKDAYS:
|
|
338
|
+
raise ValueError(f"posting_hours has unknown weekday {key}")
|
|
339
|
+
slots = []
|
|
340
|
+
for value in values or []:
|
|
341
|
+
try:
|
|
342
|
+
slots.append(time.fromisoformat(value))
|
|
343
|
+
except (TypeError, ValueError) as error:
|
|
344
|
+
raise ValueError(f"posting_hours.{key} has invalid time {value!r}") from error
|
|
345
|
+
result[WEEKDAYS.index(key)] = sorted(set(slots))
|
|
346
|
+
if not any(result.values()):
|
|
347
|
+
raise ValueError("posting_hours must contain at least one time")
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def iter_slots(start: datetime, zone: ZoneInfo, hours: dict[int, list[time]], skipped: list[str]):
|
|
352
|
+
"""Yield aware slot instants >= start, in order. Non-existent DST wall times are skipped and reported."""
|
|
353
|
+
day = start.astimezone(zone).date()
|
|
354
|
+
while True:
|
|
355
|
+
for wall in hours.get(day.weekday(), []):
|
|
356
|
+
local = datetime.combine(day, wall, tzinfo=zone) # fold=0: first occurrence of an ambiguous time
|
|
357
|
+
if local.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None) != local.replace(tzinfo=None):
|
|
358
|
+
skipped.append(f"{day.isoformat()}T{wall.isoformat('minutes')} does not exist in {zone.key} (DST gap)")
|
|
359
|
+
continue
|
|
360
|
+
if local >= start:
|
|
361
|
+
yield local
|
|
362
|
+
day += timedelta(days=1)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def plan_queue(document: dict, start: datetime, slot_count: int) -> dict:
|
|
366
|
+
"""Assign queued and campaign posts to the next `slot_count` posting slots.
|
|
367
|
+
|
|
368
|
+
Rules: each slot posts one item; the standalone queue is newest-first (a new
|
|
369
|
+
post goes to the front); a stored time is only a not-before bound, so an
|
|
370
|
+
overdue item is simply eligible; each started campaign publishes its written
|
|
371
|
+
ideas in idea order from its start day, at most posts_per_day per local day;
|
|
372
|
+
the standalone queue and started campaigns take turns (round-robin), and a
|
|
373
|
+
source with nothing eligible is skipped for that slot.
|
|
374
|
+
"""
|
|
375
|
+
if slot_count < 1 or slot_count > 1000:
|
|
376
|
+
raise ValueError("slot count must be between 1 and 1000")
|
|
377
|
+
try:
|
|
378
|
+
zone = ZoneInfo(str(document.get("timezone")))
|
|
379
|
+
except (ZoneInfoNotFoundError, ValueError) as error:
|
|
380
|
+
raise ValueError("timezone must be an IANA time zone such as Europe/London") from error
|
|
381
|
+
hours = parse_posting_hours(document.get("posting_hours"))
|
|
382
|
+
warnings: list[str] = []
|
|
383
|
+
skipped: list[str] = []
|
|
384
|
+
|
|
385
|
+
queue = []
|
|
386
|
+
for item in document.get("queue") or []:
|
|
387
|
+
if not item.get("id") or not item.get("queued_at"):
|
|
388
|
+
raise ValueError("queue items need id and queued_at")
|
|
389
|
+
queue.append({"id": item["id"], "queued_at": parse_instant(item["queued_at"], f"queue {item['id']} queued_at"),
|
|
390
|
+
"not_before": parse_instant(item["not_before"], f"queue {item['id']} not_before") if item.get("not_before") else None})
|
|
391
|
+
# Newest first; id breaks ties so the plan is deterministic.
|
|
392
|
+
queue.sort(key=lambda item: (-item["queued_at"].timestamp(), item["id"]))
|
|
393
|
+
|
|
394
|
+
campaigns = []
|
|
395
|
+
for campaign in document.get("campaigns") or []:
|
|
396
|
+
cid = campaign.get("id") or "?"
|
|
397
|
+
if campaign.get("status") != "started":
|
|
398
|
+
warnings.append(f"campaign {cid} is {campaign.get('status', 'planned')}; its posts are not scheduled until it starts")
|
|
399
|
+
continue
|
|
400
|
+
blockers = campaign_readiness(campaign)
|
|
401
|
+
if blockers:
|
|
402
|
+
warnings.append(f"campaign {cid} is started but not startable and was skipped: {'; '.join(blockers)}")
|
|
403
|
+
continue
|
|
404
|
+
posted = set(campaign.get("posted_idea_ids") or [])
|
|
405
|
+
ideas = [idea for idea in sorted(campaign["ideas"], key=lambda idea: idea["order"]) if idea["id"] not in posted]
|
|
406
|
+
campaigns.append({"id": cid, "start_day": date.fromisoformat(campaign["start_day"]),
|
|
407
|
+
"per_day": campaign["posts_per_day"], "ideas": ideas, "used": {}})
|
|
408
|
+
|
|
409
|
+
sources = ["queue"] + [campaign["id"] for campaign in campaigns]
|
|
410
|
+
by_id = {campaign["id"]: campaign for campaign in campaigns}
|
|
411
|
+
pointer = 0
|
|
412
|
+
assignments = []
|
|
413
|
+
empty_slots = 0
|
|
414
|
+
slots = iter_slots(start, zone, hours, skipped)
|
|
415
|
+
for _ in range(slot_count):
|
|
416
|
+
slot = next(slots)
|
|
417
|
+
chosen = None
|
|
418
|
+
for offset in range(len(sources)):
|
|
419
|
+
source = sources[(pointer + offset) % len(sources)]
|
|
420
|
+
if source == "queue":
|
|
421
|
+
index = next((i for i, item in enumerate(queue) if item["not_before"] is None or item["not_before"] <= slot), None)
|
|
422
|
+
if index is not None:
|
|
423
|
+
item = queue.pop(index)
|
|
424
|
+
chosen = {"post_id": item["id"], "source": "queue",
|
|
425
|
+
"overdue": bool(item["not_before"] and item["not_before"] < start)}
|
|
426
|
+
else:
|
|
427
|
+
campaign = by_id[source]
|
|
428
|
+
local_day = slot.astimezone(zone).date()
|
|
429
|
+
if campaign["ideas"] and local_day >= campaign["start_day"] and campaign["used"].get(local_day, 0) < campaign["per_day"]:
|
|
430
|
+
idea = campaign["ideas"].pop(0)
|
|
431
|
+
campaign["used"][local_day] = campaign["used"].get(local_day, 0) + 1
|
|
432
|
+
chosen = {"post_id": idea["draft_id"], "idea_id": idea["id"], "source": f"campaign:{source}"}
|
|
433
|
+
if chosen:
|
|
434
|
+
pointer = (pointer + offset + 1) % len(sources)
|
|
435
|
+
break
|
|
436
|
+
if chosen is None:
|
|
437
|
+
empty_slots += 1
|
|
438
|
+
assignments.append({"slot": slot.isoformat(), "slot_utc": iso(slot.astimezone(timezone.utc)), **(chosen or {"post_id": None})})
|
|
439
|
+
remaining = [item["id"] for item in queue] + [idea["draft_id"] for campaign in campaigns for idea in campaign["ideas"]]
|
|
440
|
+
return {"schema_version": "maggie-social-queue-plan.v1", "timezone": zone.key, "from": iso(start),
|
|
441
|
+
"assignments": assignments, "empty_slots": empty_slots, "remaining": remaining,
|
|
442
|
+
"skipped_wall_times": skipped, "warnings": warnings}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Create and validate
|
|
2
|
+
"""Create and validate deterministic Maggie Social Share drafts, adapters, connections, campaigns, the slot queue, and publish reconciliation."""
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, timezone
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
9
11
|
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
from maggie_social_queue import check_campaigns, connection_health, parse_instant, plan_queue, reconcile, validate_adapters # noqa: E402
|
|
14
|
+
|
|
10
15
|
|
|
11
16
|
LIMITS = {"linkedin": 3000, "facebook": 63206, "x": 280}
|
|
12
17
|
REQUIRED = ("source_content_id", "title", "canonical_url")
|
|
@@ -64,23 +69,55 @@ def validate(value: dict) -> list[str]:
|
|
|
64
69
|
def main() -> int:
|
|
65
70
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
66
71
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
67
|
-
create = sub.add_parser("plan")
|
|
72
|
+
create = sub.add_parser("plan", help="create a draft channel contract from one content object")
|
|
68
73
|
create.add_argument("input")
|
|
69
74
|
create.add_argument("--channels", default="linkedin,facebook")
|
|
70
75
|
create.add_argument("--output")
|
|
71
|
-
check = sub.add_parser("validate")
|
|
76
|
+
check = sub.add_parser("validate", help="validate a draft channel contract")
|
|
72
77
|
check.add_argument("input")
|
|
78
|
+
adapters = sub.add_parser("adapters", help="check canonical OAuth scope sets and shared OAuth clients")
|
|
79
|
+
adapters.add_argument("input")
|
|
80
|
+
health = sub.add_parser("connections", help="derive connection status (needs_reconnect) and expiring avatar URLs")
|
|
81
|
+
health.add_argument("input")
|
|
82
|
+
health.add_argument("--now", help="ISO 8601 instant used for expiry checks (default: current time)")
|
|
83
|
+
campaigns = sub.add_parser("campaigns", help="check the campaign idea stage and Start gate")
|
|
84
|
+
campaigns.add_argument("input")
|
|
85
|
+
rec = sub.add_parser("reconcile", help="decide adopt/await/retry per queue item from attempts and provider readback")
|
|
86
|
+
rec.add_argument("input")
|
|
87
|
+
queue = sub.add_parser("queue", help="assign queued and campaign posts to the next posting slots")
|
|
88
|
+
queue.add_argument("input")
|
|
89
|
+
queue.add_argument("--from", dest="start", help="ISO 8601 instant to plan from (default: current time)")
|
|
90
|
+
queue.add_argument("--slots", type=int, default=14)
|
|
91
|
+
queue.add_argument("--output")
|
|
73
92
|
args = parser.parse_args()
|
|
74
93
|
try:
|
|
75
94
|
value = read(Path(args.input))
|
|
76
|
-
|
|
77
|
-
|
|
95
|
+
now = datetime.now(timezone.utc)
|
|
96
|
+
if args.command == "adapters":
|
|
97
|
+
result = validate_adapters(value)
|
|
98
|
+
elif args.command == "connections":
|
|
99
|
+
result = connection_health(value, parse_instant(args.now, "--now") if args.now else now)
|
|
100
|
+
elif args.command == "reconcile":
|
|
101
|
+
result = reconcile(value)
|
|
102
|
+
elif args.command == "campaigns":
|
|
103
|
+
result = check_campaigns(value)
|
|
104
|
+
elif args.command == "queue":
|
|
105
|
+
result = plan_queue(value, parse_instant(args.start, "--from") if args.start else now, args.slots)
|
|
78
106
|
if args.output:
|
|
79
107
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
-
Path(args.output).write_text(json.dumps(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
108
|
+
Path(args.output).write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
109
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
110
|
+
return 0
|
|
111
|
+
else:
|
|
112
|
+
if args.command == "plan":
|
|
113
|
+
value = plan(value, [item.strip() for item in args.channels.split(",") if item.strip()])
|
|
114
|
+
if args.output:
|
|
115
|
+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
Path(args.output).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
117
|
+
errors = validate(value) if args.command == "validate" else []
|
|
118
|
+
result = {"valid": not errors, "errors": errors, "draft": value}
|
|
119
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
120
|
+
return 0 if result["valid"] else 1
|
|
84
121
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
85
122
|
parser.error(str(error))
|
|
86
123
|
return 2
|