Compare commits
5
Commits
15072a0cd2
...
audit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d77b3eff6 | ||
|
|
3f07362b7a | ||
|
|
f61372e28e | ||
|
|
25c7db3a31 | ||
|
|
aabd6bafc7 |
@@ -0,0 +1,152 @@
|
|||||||
|
# AUDIT — cayce-ops
|
||||||
|
|
||||||
|
Audited commit: the merge of PR #6 on `main` (see *Audited commit* at the bottom).
|
||||||
|
Gate that commit passed: `make check` — ruff (0 findings), 44 tests green,
|
||||||
|
alembic smoke on sqlite, gitleaks full history (no leaks).
|
||||||
|
|
||||||
|
## 1. Component map and data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
CAYCE_OPS_SCANNER_SOURCES (external URLs)
|
||||||
|
│ read-only fetch
|
||||||
|
▼
|
||||||
|
┌──────────┐ proposals(status=proposed) ┌──────────┐
|
||||||
|
│ scanner │─────────────────────────────▶│ DB │
|
||||||
|
└──────────┘ │ (shared) │
|
||||||
|
│ │ audit_log (every write)
|
||||||
|
┌──────────┐ approved → running → │ │◀────── actor / action / detail /
|
||||||
|
│ executor │◀──── done|failed ───────────│ │ result on every transition
|
||||||
|
└──────────┘ plugins by kind └──────────┘
|
||||||
|
│
|
||||||
|
┌──────────┐ IMAP inbox ───────────────▶│ mail_messages,
|
||||||
|
│ maild │ drafts (gated) │ proposals(detail)
|
||||||
|
└──────────┘ SMTP only draft=approved │
|
||||||
|
│
|
||||||
|
┌──────────┐ content_items ────────────▶│ │──▶ relay (nostr) / Meta (opt-in)
|
||||||
|
│publisher │ draft→approved→published └──────────┘
|
||||||
|
└──────────┘
|
||||||
|
┌──────────┐ reads daemon_runs, audit_log, counts
|
||||||
|
│ monitor │────────────────────────────────────▶ status.html (static)
|
||||||
|
└──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Shared schema (`cayceops/models.py`, migration `alembic/versions/0001_initial.py`):
|
||||||
|
`proposals`, `audit_log`, `mail_messages`, `content_items`, `daemon_runs`.
|
||||||
|
|
||||||
|
## 2. Every external call (where, what it sends, what triggers it)
|
||||||
|
|
||||||
|
| # | Call | Code | Sends | Trigger |
|
||||||
|
|---|------|------|-------|---------|
|
||||||
|
| 1 | HTTP GET to scanner sources | `cayceops/components/scanner/scanner.py` → `_fetch` (urllib) | nothing but the request (+ `CAYCE_OPS_SCANNER_USER_AGENT` header) to URLs **configured by the operator** | `cayceops scanner run` (cron/systemd timer) |
|
||||||
|
| 2 | IMAP fetch (IMAP4_SSL, UNSEEN) | `cayceops/components/maild/maild.py` → `connect_imap`, `_fetch_unread` | credentials from env; reads inbox | `cayceops maild poll` |
|
||||||
|
| 3 | SMTP send | `maild.py` → `_smtp_send` (smtplib, STARTTLS) | a reply **draft a human approved** to the original sender's bare address | `cayceops maild send-approved`, and only rows with `draft_status=approved` |
|
||||||
|
| 4 | LLM chat completion (openai-compatible) | `cayceops/llm.py` → `OpenAICompatProvider.complete` (urllib) | the email text (truncated 4000 chars) + system prompt; `Authorization: Bearer <env key>` | `maild poll` **only if** `CAYCE_OPS_LLM_PROVIDER != null` (default is null → never) |
|
||||||
|
| 5 | Nostr relay publish (websocket) | `cayceops/components/publisher/nostr.py` → `relay_publish`/`publish` | a signed NIP-01 event whose content is the **human-approved** caption | `cayceops publisher publish-due`, channel=nostr, only if key+relays configured |
|
||||||
|
| 6 | Meta Graph API | `cayceops/components/publisher/adapters.py` | caption (+optional hosted image URL) + page token | **refuses to run** unless `CAYCE_OPS_ENABLE_FACEBOOK/INSTAGRAM=true` (default false). Currently unreachable from `publish_due`. |
|
||||||
|
|
||||||
|
No other network code exists. Grep guards: `urllib.request`, `imaplib`, `smtplib`,
|
||||||
|
`websockets` appear only in the files above (verified by review; CI could add a
|
||||||
|
lint rule — see *Known risks*).
|
||||||
|
|
||||||
|
## 3. What each component can do without a human / with AUTONOMOUS_MODE=true
|
||||||
|
|
||||||
|
| Component | Without any human (default) | With `CAYCE_OPS_AUTONOMOUS_MODE=true` |
|
||||||
|
|---|---|---|
|
||||||
|
| scanner | fetch sources, file proposals, write audit | identical (scanner never executes) |
|
||||||
|
| executor | nothing executes by itself; it acts only on rows a human set to `approved` (via `executor approve --actor …`) | **one added path**: auto-approves `proposed` items whose `kind` ∈ `CAYCE_OPS_AUTONOMOUS_KINDS`, capped at `CAYCE_OPS_AUTONOMOUS_DAILY_CAP`/day, actor `auto:policy`, fully audited. Then executes those. |
|
||||||
|
| maild | polls IMAP, classifies, writes drafts. **Never sends.** | identical — autonomy does not touch mail |
|
||||||
|
| publisher | plans drafts; publishes nothing | identical — publishing always requires `publisher approve --actor …`; autonomy has no effect |
|
||||||
|
| monitor | reads and renders | identical |
|
||||||
|
|
||||||
|
Explicitly impossible in the current code: sending an unapproved mail draft,
|
||||||
|
publishing unapproved content, executing a non-approved proposal (the single
|
||||||
|
exception is the audited `auto:policy` path above), disabling the audit write.
|
||||||
|
|
||||||
|
## 4. Environment variables and permissions per component
|
||||||
|
|
||||||
|
All config is env-only (`cayceops/config.py`), prefix `CAYCE_OPS_`. Full list
|
||||||
|
with placeholders: `.env.example`.
|
||||||
|
|
||||||
|
| Component | Requires | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| core | `DATABASE_URL` (postgres or sqlite), `LOG_LEVEL`, `STATE_DIR`, `MEDIA_DIR` | DB user needs SELECT/INSERT/UPDATE on the five tables |
|
||||||
|
| scanner | `SCANNER_SOURCES` (JSON), `SCANNER_KEYWORDS`, `SCANNER_USER_AGENT` | outbound GET only |
|
||||||
|
| executor | `AUTONOMOUS_MODE`, `AUTONOMOUS_KINDS`, `AUTONOMOUS_DAILY_CAP`, `MAX_ATTEMPTS`, `ENABLE_HTTP_ACTIONS` | `http_post` plugin stays inert unless enabled |
|
||||||
|
| maild | `IMAP_HOST/PORT/USER/PASSWORD/MAILBOX`, `SMTP_HOST/PORT/USER/PASSWORD`, `MAIL_FROM`, `LLM_PROVIDER/MODEL/BASE_URL/API_KEY/REVIEW_THRESHOLD` | IMAP user is read+mark-seen; SMTP login can send only as `MAIL_FROM` |
|
||||||
|
| publisher | `NOSTR_PRIVATE_KEY` (32-byte hex), `NOSTR_RELAYS`, `ENABLE_FACEBOOK`, `FACEBOOK_PAGE_TOKEN`, `ENABLE_INSTAGRAM`, `INSTAGRAM_USER_ID` | nostr key = signing authority; treat as secret |
|
||||||
|
| monitor | `MONITOR_OUTPUT` (writable path) | needs read on DB, write on output only |
|
||||||
|
|
||||||
|
Filesystem permissions: default deployment is the `cayce` user owning
|
||||||
|
`/srv/cayce-ops` with systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`,
|
||||||
|
`ProtectHome=yes`) — see `deploy/systemd/cayce-ops@.service`.
|
||||||
|
|
||||||
|
## 5. Dependencies (pinned in `pyproject.toml` + `uv.lock`)
|
||||||
|
|
||||||
|
| Package | Version | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| sqlalchemy | 2.0.44 | ORM + engine for the shared schema |
|
||||||
|
| alembic | 1.17.0 | production migrations |
|
||||||
|
| typer | 0.20.0 | CLI (all five daemons are CLI commands) |
|
||||||
|
| psycopg[binary] | 3.2.13 | Postgres driver (production stack) |
|
||||||
|
| pillow | 12.0.0 | brand image templates (publisher) |
|
||||||
|
| coincurve | 21.0.0 | libsecp256k1 binding: NIP-01 event signing (publisher) |
|
||||||
|
| websockets | 15.0.1 | nostr relay publish (sync client) |
|
||||||
|
| pytest | 9.0.2 (dev) | test suite |
|
||||||
|
| ruff | 0.15.1 (dev) | lint |
|
||||||
|
|
||||||
|
stdlib doing heavy lifting: `imaplib`/`smtplib`/`email` (maild), `urllib` (scanner,
|
||||||
|
LLM, Meta adapters), `xml.etree`/`html.parser` (scanner), `hashlib`/`json` (nostr ids).
|
||||||
|
|
||||||
|
## 6. Known risks and open items
|
||||||
|
|
||||||
|
1. **No live Gitea Actions runner on the current host** (DECISIONS D6). CI files
|
||||||
|
ship for GitHub/Gitea Actions, and the same gate runs locally pre-push
|
||||||
|
(`scripts/pre-push`), but until a runner exists there is no third-party
|
||||||
|
enforcement of the gate on every push.
|
||||||
|
2. **`auto:policy` path is real autonomy** — bounded (kind allowlist + daily cap +
|
||||||
|
audit) but it does execute without a human in the loop when enabled. The cap
|
||||||
|
counter is DB-based; wiping `audit_log` resets it.Mitigation: only the
|
||||||
|
operator can set the env flags, and the audit log is append-only by convention.
|
||||||
|
3. **Executor plugins are in-process Python** (D9): no sandboxing. A malicious
|
||||||
|
plugin = arbitrary code. Mitigation: plugins are repo code, reviewed in PRs;
|
||||||
|
the registry starts with `log_action` fallbacks only.
|
||||||
|
4. **Mail classification is heuristic** (rules; LLM optional). Conservative by
|
||||||
|
design (low confidence → `needs_review`, unthreaded approvals → review), but
|
||||||
|
a sophisticated phishing email could still be misclassified `other`. Human
|
||||||
|
gate on sends bounds the damage to drafting wrong replies (never sending them).
|
||||||
|
5. **Nostr relays are plaintext-config endpoints**: a hostile relay sees the
|
||||||
|
note content (public by design) and could replay it; event ids make duplication
|
||||||
|
detectable. No NIP-05, no retries across relay pools yet.
|
||||||
|
6. **`detail` JSON columns are schemaless** — writers evolve payloads without
|
||||||
|
migrations. Monitor intentionally renders none of it.
|
||||||
|
7. **gitleaks allowlist covers `tests/`** (D7): a real secret pasted under
|
||||||
|
`tests/` would not trip the scanner. Compensating control: PR review; no
|
||||||
|
fixture reads the environment.
|
||||||
|
8. **Open:** provision an act_runner for Gitea Actions; wire `posts_log`-style
|
||||||
|
engagement back into publisher planning; add NIP-05 identifier; retries for
|
||||||
|
relay publish.
|
||||||
|
|
||||||
|
## 7. Running tests and the local stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --frozen # python 3.12 + locked deps
|
||||||
|
uv run pytest # 44 tests, sqlite, no network
|
||||||
|
make check # lint + tests + alembic smoke + gitleaks (pre-push gate)
|
||||||
|
|
||||||
|
# full local stack (five daemons + postgres 16):
|
||||||
|
cp .env.example .env # fill placeholders; nothing real required for db-only
|
||||||
|
docker compose up -d db
|
||||||
|
uv run alembic upgrade head
|
||||||
|
docker compose up -d # or run each: uv run cayceops {scanner,executor,maild,publisher,monitor} …
|
||||||
|
|
||||||
|
# production path (VPS):
|
||||||
|
# see deploy/systemd/README.md (hardened units + timers)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audited commit
|
||||||
|
|
||||||
|
- Repository: `cayce/cayce-ops` (branch `main`)
|
||||||
|
- Audited: merge of PR #6 (`audit: AUDIT.md for operator review`) — hash printed
|
||||||
|
in the "Ready for audit" issue that links this file.
|
||||||
|
- Gate at that commit: ruff clean · 44/44 tests · alembic 0001 smoke OK ·
|
||||||
|
gitleaks `--all` no leaks.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""monitor package."""
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""monitor — static status page builder.
|
||||||
|
|
||||||
|
Reads the shared database and renders a self-contained HTML file (no external
|
||||||
|
assets, no JS, everything escaped). Contains NO sensitive data: daemon health,
|
||||||
|
proposal queue counts, and recent audit activity (actor/action/result only —
|
||||||
|
no email bodies, no detail payloads, no URLs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from cayceops.config import Config
|
||||||
|
from cayceops.db import session_scope
|
||||||
|
from cayceops.models import (
|
||||||
|
AuditEntry,
|
||||||
|
ContentItem,
|
||||||
|
DaemonRun,
|
||||||
|
MailMessage,
|
||||||
|
Proposal,
|
||||||
|
ProposalStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
DAEMONS = ("scanner", "executor", "maild", "publisher", "monitor")
|
||||||
|
|
||||||
|
_STYLE = """<style>
|
||||||
|
body{font-family:ui-monospace,Menlo,monospace;background:#0a0908;color:#f2ece0;margin:2rem}
|
||||||
|
h1{color:#c9a84c;font-weight:600} h2{color:#c9a84c;margin-top:2rem;font-size:1.1rem}
|
||||||
|
table{border-collapse:collapse;margin:.5rem 0} td,th{border:1px solid #333;padding:.35rem .8rem;text-align:left}
|
||||||
|
.ok{color:#6fce6f}.error{color:#ef6b6b}.muted{color:#888;font-size:.85rem}
|
||||||
|
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
|
||||||
|
</style>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_ts(dt) -> str:
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M:%SZ") if dt else "—"
|
||||||
|
|
||||||
|
|
||||||
|
def _daemon_rows(sess) -> str:
|
||||||
|
rows = []
|
||||||
|
for name in DAEMONS:
|
||||||
|
last = (
|
||||||
|
sess.query(DaemonRun)
|
||||||
|
.filter(DaemonRun.daemon == name)
|
||||||
|
.order_by(DaemonRun.started_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last is None:
|
||||||
|
rows.append(f"<tr><td>{name}</td><td class=muted>never run</td><td>—</td></tr>")
|
||||||
|
else:
|
||||||
|
cls = "ok" if last.status == "ok" else "error"
|
||||||
|
rows.append(
|
||||||
|
f"<tr><td>{html.escape(name)}</td>"
|
||||||
|
f"<td class={cls}>{html.escape(last.status)}</td>"
|
||||||
|
f"<td>{_fmt_ts(last.finished_at or last.started_at)}</td></tr>"
|
||||||
|
)
|
||||||
|
return "<table><tr><th>daemon</th><th>last status</th><th>last run</th></tr>" + "".join(rows) + "</table>"
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_cards(sess) -> str:
|
||||||
|
counts = dict(sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all())
|
||||||
|
cards = [
|
||||||
|
f'<div class=card>{html.escape(status.value)}: <strong>{counts.get(status, 0)}</strong></div>'
|
||||||
|
for status in ProposalStatus
|
||||||
|
]
|
||||||
|
return "".join(cards)
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_audit(sess, limit: int = 20) -> str:
|
||||||
|
rows = []
|
||||||
|
for entry in sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit).all():
|
||||||
|
cls = "ok" if entry.result == "ok" else "error"
|
||||||
|
rows.append(
|
||||||
|
f"<tr><td>{_fmt_ts(entry.ts)}</td><td>{html.escape(entry.actor)}</td>"
|
||||||
|
f"<td>{html.escape(entry.action)}</td><td class={cls}>{html.escape(entry.result)}</td></tr>"
|
||||||
|
)
|
||||||
|
body = "".join(rows) or "<tr><td colspan=4 class=muted>no activity yet</td></tr>"
|
||||||
|
return (
|
||||||
|
"<table><tr><th>time (utc)</th><th>actor</th><th>action</th><th>result</th></tr>"
|
||||||
|
+ body
|
||||||
|
+ "</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build(cfg: Config, sess) -> str:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
mail_unprocessed = (
|
||||||
|
sess.query(func.count(MailMessage.id))
|
||||||
|
.filter(
|
||||||
|
MailMessage.direction == "in",
|
||||||
|
MailMessage.draft_status.in_(["drafted", "none"]),
|
||||||
|
MailMessage.classification.isnot(None),
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
content_pending = (
|
||||||
|
sess.query(func.count(ContentItem.id))
|
||||||
|
.filter(ContentItem.status.in_(["draft", "approved"]))
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
generated = _fmt_ts(now)
|
||||||
|
|
||||||
|
page = f"""<!doctype html>
|
||||||
|
<html lang=en><head><meta charset=utf-8><title>cayce-ops status</title>{_STYLE}</head>
|
||||||
|
<body>
|
||||||
|
<h1>cayce-ops · status</h1>
|
||||||
|
<p class=muted>generated {generated} · static page, no sensitive data by design</p>
|
||||||
|
|
||||||
|
<h2>daemon health</h2>
|
||||||
|
{_daemon_rows(sess)}
|
||||||
|
|
||||||
|
<h2>proposal queue</h2>
|
||||||
|
{_queue_cards(sess)}
|
||||||
|
|
||||||
|
<h2>gates</h2>
|
||||||
|
<div class=card>mail drafts awaiting approval: <strong>{mail_unprocessed}</strong></div>
|
||||||
|
<div class=card>content items draft/approved: <strong>{content_pending}</strong></div>
|
||||||
|
|
||||||
|
<h2>recent audit activity</h2>
|
||||||
|
{_recent_audit(sess)}
|
||||||
|
|
||||||
|
<p class=muted>audit entries: {sess.scalar(select(func.count(AuditEntry.id)))} total</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(cfg.monitor_output) or ".", exist_ok=True)
|
||||||
|
with open(cfg.monitor_output, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(page)
|
||||||
|
return cfg.monitor_output
|
||||||
|
|
||||||
|
|
||||||
|
def build(cfg: Config | None = None) -> int:
|
||||||
|
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
run_row = DaemonRun(daemon="monitor")
|
||||||
|
sess.add(run_row)
|
||||||
|
sess.flush()
|
||||||
|
try:
|
||||||
|
out = _build(cfg, sess)
|
||||||
|
except Exception as exc: # noqa: BLE001 — daemon boundary
|
||||||
|
run_row.status = "error"
|
||||||
|
run_row.detail = {"error": str(exc)[:300]}
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
return 1
|
||||||
|
run_row.status = "ok"
|
||||||
|
run_row.detail = {"output": out}
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
print(out)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""publisher package."""
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Optional Facebook/Instagram adapters (Graph API).
|
||||||
|
|
||||||
|
DISABLED BY DEFAULT. Each adapter refuses to run unless its enable flag is
|
||||||
|
true (``CAYCE_OPS_ENABLE_FACEBOOK`` / ``CAYCE_OPS_ENABLE_INSTAGRAM``), so the
|
||||||
|
default deployment never touches Meta endpoints. Tokens are env-only and are
|
||||||
|
never logged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
GRAPH = "https://graph.facebook.com/v21.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _post(url: str, payload: dict) -> dict:
|
||||||
|
data = urllib.parse.urlencode(payload).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST") # noqa: S310
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
def post_facebook(cfg, caption: str, media_path: str | None = None) -> dict:
|
||||||
|
"""Post to the page feed (text) or /photos (with local media). Gated."""
|
||||||
|
if not cfg.enable_facebook:
|
||||||
|
raise PermissionError("facebook adapter disabled (CAYCE_OPS_ENABLE_FACEBOOK=false)")
|
||||||
|
if not cfg.facebook_page_token:
|
||||||
|
raise ValueError("CAYCE_OPS_FACEBOOK_PAGE_TOKEN missing")
|
||||||
|
if media_path:
|
||||||
|
# photo posts require a reachable URL or multipart upload; we use the
|
||||||
|
# simple URL form — media must be hosted (out of scope for local files)
|
||||||
|
result = _post(
|
||||||
|
f"{GRAPH}/me/photos",
|
||||||
|
{"url": media_path, "caption": caption, "access_token": cfg.facebook_page_token},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = _post(
|
||||||
|
f"{GRAPH}/me/feed",
|
||||||
|
{"message": caption, "access_token": cfg.facebook_page_token},
|
||||||
|
)
|
||||||
|
return {"post_id": result.get("post_id") or result.get("id", "")}
|
||||||
|
|
||||||
|
|
||||||
|
def post_instagram(cfg, caption: str, image_url: str) -> dict:
|
||||||
|
"""Two-step IG publish (container -> publish). Gated."""
|
||||||
|
if not cfg.enable_instagram:
|
||||||
|
raise PermissionError("instagram adapter disabled (CAYCE_OPS_ENABLE_INSTAGRAM=false)")
|
||||||
|
if not cfg.instagram_user_id:
|
||||||
|
raise ValueError("CAYCE_OPS_INSTAGRAM_USER_ID missing")
|
||||||
|
token = cfg.facebook_page_token # IG Graph publishing uses the page token
|
||||||
|
container = _post(
|
||||||
|
f"{GRAPH}/{cfg.instagram_user_id}/media",
|
||||||
|
{"image_url": image_url, "caption": caption, "access_token": token},
|
||||||
|
)
|
||||||
|
published = _post(
|
||||||
|
f"{GRAPH}/{cfg.instagram_user_id}/media_publish",
|
||||||
|
{"creation_id": container["id"], "access_token": token},
|
||||||
|
)
|
||||||
|
return {"post_id": published.get("id", "")}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Brand image templates (Pillow). 1080x1350 portrait, two brand styles.
|
||||||
|
|
||||||
|
Deterministic, template-driven graphics — deliberately not AI-generated media
|
||||||
|
(DECISIONS: nothing here trips AI-media disclosure requirements; it is typeset
|
||||||
|
design). Fonts: Pillow's bundled default (scalable), so no system font deps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
WIDTH, HEIGHT = 1080, 1350
|
||||||
|
|
||||||
|
_BRANDS = {
|
||||||
|
# bitcoinhotels — warm gold on near-black
|
||||||
|
"bh": {"bg": (20, 18, 16), "accent": (201, 168, 76), "text": (242, 236, 224), "muted": (150, 140, 120)},
|
||||||
|
# hardcap — bitcoin orange on cool black
|
||||||
|
"hc": {"bg": (13, 13, 16), "accent": (247, 147, 26), "text": (250, 250, 250), "muted": (120, 120, 130)},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _font(size: int, bold: bool = False):
|
||||||
|
# Pillow >=10: load_default(size=...) returns a scalable bundled font
|
||||||
|
return ImageFont.load_default(size=size)
|
||||||
|
|
||||||
|
|
||||||
|
def _vgrad(top: tuple, bottom: tuple) -> Image.Image:
|
||||||
|
"""Simple two-color vertical gradient."""
|
||||||
|
img = Image.new("RGB", (WIDTH, HEIGHT))
|
||||||
|
for y in range(HEIGHT):
|
||||||
|
t = y / (HEIGHT - 1)
|
||||||
|
row = tuple(int(top[i] + (bottom[i] - top[i]) * t) for i in range(3))
|
||||||
|
ImageDraw.Draw(img).line([(0, y), (WIDTH, y)], fill=row)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def render_brand_image(
|
||||||
|
*,
|
||||||
|
brand: str = "bh",
|
||||||
|
kicker: str | None,
|
||||||
|
lines: list[str],
|
||||||
|
tagline: list[str] | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
out_path: str,
|
||||||
|
) -> str:
|
||||||
|
"""Render one branded poster. Returns out_path. Raises on unknown brand."""
|
||||||
|
if brand not in _BRANDS:
|
||||||
|
raise ValueError(f"unknown brand {brand!r} (have: {sorted(_BRANDS)})")
|
||||||
|
palette = _BRANDS[brand]
|
||||||
|
img = _vgrad(palette["bg"], tuple(max(0, c - 8) for c in palette["bg"]))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
x, y = 90, 240
|
||||||
|
if kicker:
|
||||||
|
draw.text((x, y), kicker, font=_font(40), fill=palette["muted"])
|
||||||
|
y += 110
|
||||||
|
for line in lines:
|
||||||
|
draw.text((x, y), line, font=_font(96, bold=True), fill=palette["text"])
|
||||||
|
y += 118
|
||||||
|
y += 24
|
||||||
|
for t in tagline or []:
|
||||||
|
draw.text((x, y), t, font=_font(38), fill=palette["muted"])
|
||||||
|
y += 58
|
||||||
|
draw.rectangle([x, y + 30, x + 470, y + 33], fill=palette["accent"])
|
||||||
|
if url:
|
||||||
|
draw.text((x, y + 110), url, font=_font(44), fill=palette["accent"])
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True)
|
||||||
|
img.save(out_path, "JPEG", quality=92)
|
||||||
|
return out_path
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Minimal Nostr client: NIP-01 kind-1 notes (build, sign, verify, publish).
|
||||||
|
|
||||||
|
Implemented in-repo (DECISIONS D4) so every byte of the signing path is
|
||||||
|
auditable: event id = sha256 of the canonical NIP-01 serialization; signature =
|
||||||
|
BIP-340 Schnorr over the id. Keys are 32-byte hex. Relays are plain env config.
|
||||||
|
|
||||||
|
Publishing is fire-and-wait: open a websocket per relay, send the EVENT, wait
|
||||||
|
for the OK frame (or timeout), and return the per-relay results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from coincurve import PrivateKey, PublicKey
|
||||||
|
|
||||||
|
KIND_NOTE = 1
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_key(private_key_hex: str) -> bytes:
|
||||||
|
"""Accept 64-char hex (with or without 0x). Anything else is a config error."""
|
||||||
|
key = private_key_hex.strip().lower().removeprefix("0x")
|
||||||
|
if len(key) != 64 or not all(c in "0123456789abcdef" for c in key):
|
||||||
|
raise ValueError("CAYCE_OPS_NOSTR_PRIVATE_KEY must be 64-char hex (32 bytes)")
|
||||||
|
return bytes.fromhex(key)
|
||||||
|
|
||||||
|
|
||||||
|
def pubkey_from_secret(secret: bytes) -> str:
|
||||||
|
return PublicKey.from_secret(secret).format().hex()[2:]
|
||||||
|
|
||||||
|
|
||||||
|
def build_event(
|
||||||
|
secret: bytes,
|
||||||
|
content: str,
|
||||||
|
tags: list[list[str]] | None = None,
|
||||||
|
created_at: int | None = None,
|
||||||
|
kind: int = KIND_NOTE,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build and sign a NIP-01 event. Deterministic given the same inputs."""
|
||||||
|
created_at = int(created_at if created_at is not None else __import__("time").time())
|
||||||
|
pubkey = pubkey_from_secret(secret)
|
||||||
|
serialized = json.dumps(
|
||||||
|
[0, pubkey, created_at, kind, tags or [], content],
|
||||||
|
separators=(",", ":"), # canonical NIP-01 form
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
event_id = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||||
|
# zero aux randomness -> fully deterministic signatures (BIP-340 permits it;
|
||||||
|
# verification is unaffected either way)
|
||||||
|
signature = (
|
||||||
|
PrivateKey(secret)
|
||||||
|
.sign_schnorr(bytes.fromhex(event_id), aux_randomness=b"\x00" * 32)
|
||||||
|
.hex()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"pubkey": pubkey,
|
||||||
|
"created_at": created_at,
|
||||||
|
"kind": kind,
|
||||||
|
"tags": tags or [],
|
||||||
|
"content": content,
|
||||||
|
"id": event_id,
|
||||||
|
"sig": signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
|
||||||
|
"""BIP-340 reference verification (pure Python, public domain spec code).
|
||||||
|
|
||||||
|
Signing runs in coincurve (libsecp256k1); verification is pure Python so
|
||||||
|
the auditor can read every step without CFFI indirection.
|
||||||
|
"""
|
||||||
|
P = 2**256 - 2**32 - 977
|
||||||
|
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||||
|
|
||||||
|
def tag_hash(tag: str, msg: bytes) -> bytes:
|
||||||
|
t = hashlib.sha256(tag.encode()).digest()
|
||||||
|
return hashlib.sha256(t + t + msg).digest()
|
||||||
|
|
||||||
|
def lift_x(b: int):
|
||||||
|
if b >= P:
|
||||||
|
return None
|
||||||
|
y_sq = (pow(b, 3, P) + 7) % P
|
||||||
|
y = pow(y_sq, (P + 1) // 4, P)
|
||||||
|
if pow(y, 2, P) != y_sq:
|
||||||
|
return None
|
||||||
|
return (b, y if y % 2 == 0 else P - y)
|
||||||
|
|
||||||
|
def point_add(p1, p2):
|
||||||
|
if p1 is None:
|
||||||
|
return p2
|
||||||
|
if p2 is None:
|
||||||
|
return p1
|
||||||
|
if p1[0] == p2[0] and p1[1] != p2[1]:
|
||||||
|
return None
|
||||||
|
if p1 == p2:
|
||||||
|
lam = (3 * p1[0] * p1[0] * pow(2 * p1[1], P - 2, P)) % P
|
||||||
|
else:
|
||||||
|
lam = ((p2[1] - p1[1]) * pow(p2[0] - p1[0], P - 2, P)) % P
|
||||||
|
x3 = (lam * lam - p1[0] - p2[0]) % P
|
||||||
|
return (x3, (lam * (p1[0] - x3) - p1[1]) % P)
|
||||||
|
|
||||||
|
def point_mul(p, scalar):
|
||||||
|
acc = None
|
||||||
|
for i in range(256):
|
||||||
|
if (scalar >> i) & 1:
|
||||||
|
acc = point_add(acc, p)
|
||||||
|
p = point_add(p, p)
|
||||||
|
return acc
|
||||||
|
|
||||||
|
if len(sig32) != 64 or len(msg32) != 32 or len(xonly_pub32) != 32:
|
||||||
|
return False
|
||||||
|
r = int.from_bytes(sig32[:32], "big")
|
||||||
|
s = int.from_bytes(sig32[32:], "big")
|
||||||
|
if not (0 <= r < P and 0 <= s < N):
|
||||||
|
return False
|
||||||
|
pub_point = lift_x(int.from_bytes(xonly_pub32, "big"))
|
||||||
|
if pub_point is None:
|
||||||
|
return False
|
||||||
|
e = int.from_bytes(tag_hash("BIP0340/challenge", sig32[:32] + xonly_pub32 + msg32), "big") % N
|
||||||
|
r_point = point_add(point_mul((Gx := 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, Gy := 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8), s), point_mul(pub_point, (N - e) % N))
|
||||||
|
if r_point is None or r_point[1] % 2 != 0:
|
||||||
|
return False
|
||||||
|
return r_point[0] == r
|
||||||
|
|
||||||
|
|
||||||
|
def verify_event(event: dict[str, Any]) -> bool:
|
||||||
|
"""Recompute the id and verify the Schnorr signature against the pubkey."""
|
||||||
|
serialized = json.dumps(
|
||||||
|
[0, event["pubkey"], event["created_at"], event["kind"], event["tags"], event["content"]],
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||||
|
return False
|
||||||
|
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format() # 33-byte, prefix 02
|
||||||
|
return _schnorr_verify(
|
||||||
|
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0) -> str:
|
||||||
|
"""Publish one event to one relay; return the relay's OK message."""
|
||||||
|
from websockets.sync.client import connect
|
||||||
|
|
||||||
|
with connect(relay_url, close_timeout=timeout) as ws:
|
||||||
|
ws.send(json.dumps(["EVENT", event]))
|
||||||
|
reply = json.loads(ws.recv(timeout=timeout))
|
||||||
|
if not (isinstance(reply, list) and len(reply) >= 3 and reply[0] == "OK" and reply[2] is True):
|
||||||
|
raise RuntimeError(f"relay {relay_url} rejected event: {str(reply)[:200]}")
|
||||||
|
return str(reply[1])
|
||||||
|
|
||||||
|
|
||||||
|
def publish(relays: list[str], event: dict[str, Any], timeout: float = 10.0) -> list[dict[str, str]]:
|
||||||
|
"""Publish to every relay; report per-relay outcome (never raises unless all fail)."""
|
||||||
|
results = []
|
||||||
|
for relay in relays:
|
||||||
|
try:
|
||||||
|
relay_publish(relay, event, timeout)
|
||||||
|
results.append({"relay": relay, "ok": "true"})
|
||||||
|
except Exception as exc: # noqa: BLE001 — per-relay isolation
|
||||||
|
results.append({"relay": relay, "ok": "false", "error": str(exc)[:200]})
|
||||||
|
if relays and all(r["ok"] == "false" for r in results):
|
||||||
|
raise RuntimeError(f"all relays failed: {results}")
|
||||||
|
return results
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""publisher — themed content calendar with a hard human gate on publishing.
|
||||||
|
|
||||||
|
State machine: draft -> approved -> published. Only ``approved`` items ever
|
||||||
|
publish. AUTONOMOUS_MODE has NO effect on this component: content always
|
||||||
|
requires explicit human approval (documented in AUDIT.md).
|
||||||
|
|
||||||
|
Channels: nostr (NIP-01, primary) — facebook/instagram adapters exist but are
|
||||||
|
disabled unless explicitly enabled via env (DECISIONS: default deployment never
|
||||||
|
touches Meta).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from cayceops.components.publisher import images, nostr
|
||||||
|
from cayceops.config import Config
|
||||||
|
from cayceops.db import session_scope
|
||||||
|
from cayceops.models import ContentItem, ContentStatus, DaemonRun, audit
|
||||||
|
|
||||||
|
# rotation: one theme per slot; channel rotation nostr -> facebook -> instagram
|
||||||
|
THEMES = [
|
||||||
|
("lightning-abroad", "bitcoinhotels.org/guides/lightning-abroad"),
|
||||||
|
("seed-strategy", "hardcap.news/learn/travel-security-kit"),
|
||||||
|
("atm-fees", "bitcoinhotels.org/guides/best-bitcoin-atms-by-city"),
|
||||||
|
("elsalvador-budget", "bitcoinhotels.org/guides/living-on-sats-in-el-salvador"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CAPTION_TEMPLATE = "{hook}\n\n{body}\n\n{link}"
|
||||||
|
|
||||||
|
|
||||||
|
def _caption(theme: str, link: str) -> str:
|
||||||
|
hooks = {
|
||||||
|
"lightning-abroad": "Paying abroad with Lightning beats your bank.",
|
||||||
|
"seed-strategy": "The seed strategy you pick decides how the border story goes.",
|
||||||
|
"atm-fees": "The first ATM you find charges 10-15% over spot.",
|
||||||
|
"elsalvador-budget": "A real weekly budget on a Bitcoin standard.",
|
||||||
|
}
|
||||||
|
bodies = {
|
||||||
|
"lightning-abroad": "No foreign transaction fees, no dynamic currency conversion, no declines. Field-tested guide.",
|
||||||
|
"seed-strategy": "Steel plate, memorized + decoy, or split custody — the full breakdown, brutally honest.",
|
||||||
|
"atm-fees": "The prepared traveler pays 4-6%. The difference is knowing the operator and the no-KYC cap.",
|
||||||
|
"elsalvador-budget": "Surf hostels, comidas, coworking — the corners where you still need dollars, and the 90-day protocol.",
|
||||||
|
}
|
||||||
|
return _CAPTION_TEMPLATE.format(
|
||||||
|
hook=hooks.get(theme, theme.replace("-", " ").title()),
|
||||||
|
body=bodies.get(theme, "Field-tested notes."),
|
||||||
|
link=link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- calendar planning ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||||
|
"""Fill the calendar: one draft per theme/channel pair not already queued.
|
||||||
|
|
||||||
|
Idempotent by (theme, channel): existing draft/approved items block a
|
||||||
|
duplicate. Drafts are scheduled one per day, starting tomorrow.
|
||||||
|
"""
|
||||||
|
created = 0
|
||||||
|
for idx, (theme, link) in enumerate(THEMES[:days]):
|
||||||
|
channel = ["nostr", "facebook", "instagram", "nostr"][idx % 4]
|
||||||
|
exists = (
|
||||||
|
sess.query(ContentItem)
|
||||||
|
.filter(
|
||||||
|
ContentItem.theme == theme,
|
||||||
|
ContentItem.channel == channel,
|
||||||
|
ContentItem.status != ContentStatus.PUBLISHED,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
item = ContentItem(
|
||||||
|
theme=theme,
|
||||||
|
channel=channel,
|
||||||
|
caption=_caption(theme, link),
|
||||||
|
link=link,
|
||||||
|
status=ContentStatus.DRAFT,
|
||||||
|
scheduled_for=datetime.now(UTC).replace(hour=13, minute=0, second=0, microsecond=0)
|
||||||
|
+ timedelta(days=idx + 1),
|
||||||
|
)
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
audit(sess, actor="daemon:publisher", action="plan_draft", detail={"item": item.id, "theme": theme, "channel": channel})
|
||||||
|
created += 1
|
||||||
|
return {"created": created}
|
||||||
|
|
||||||
|
|
||||||
|
# --- human gate -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _approve(item_id: int, actor: str, sess) -> int:
|
||||||
|
item = sess.get(ContentItem, item_id)
|
||||||
|
if item is None or item.status != ContentStatus.DRAFT:
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor=actor,
|
||||||
|
action="approve_content",
|
||||||
|
result="error",
|
||||||
|
detail={"reason": f"item {item_id} not in draft state"},
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
item.status = ContentStatus.APPROVED
|
||||||
|
audit(sess, actor=actor, action="approve_content", detail={"item": item.id, "channel": item.channel})
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- image rendering ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
||||||
|
"""Render the brand image for a nostr item (per its theme)."""
|
||||||
|
brand = "hc" if "hardcap" in (item.link or "") else "bh"
|
||||||
|
title = item.theme.replace("-", " ").upper().split()
|
||||||
|
lines = [" ".join(title[:2])] if title else [item.theme]
|
||||||
|
out = images.render_brand_image(
|
||||||
|
brand=brand,
|
||||||
|
kicker="C A Y C E",
|
||||||
|
lines=lines,
|
||||||
|
tagline=[(item.link or "")[:70]],
|
||||||
|
url=(item.link or "")[:60],
|
||||||
|
out_path=f"{cfg.media_dir}/item-{item.id}.jpg",
|
||||||
|
)
|
||||||
|
item.media_path = out
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- publishing ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_nostr(item: ContentItem, cfg: Config, sess, sender=None) -> str:
|
||||||
|
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
||||||
|
raise RuntimeError("nostr channel requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS")
|
||||||
|
secret = nostr.normalize_key(cfg.nostr_private_key)
|
||||||
|
event = nostr.build_event(secret, item.caption, tags=[["t", item.theme]])
|
||||||
|
if not nostr.verify_event(event): # pragma: no cover — build/verify symmetry
|
||||||
|
raise RuntimeError("event failed self-verification before publish")
|
||||||
|
results = (sender or nostr.publish)(cfg.nostr_relays, event)
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:publisher",
|
||||||
|
action="nostr_relay_results",
|
||||||
|
detail={"event": event["id"], "relays": results},
|
||||||
|
)
|
||||||
|
return event["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_due(cfg: Config, sess, nostr_sender=None) -> dict:
|
||||||
|
"""Publish approved items that are due. Non-nostr channels are skipped
|
||||||
|
unless their adapters are explicitly enabled (default: skipped, audited)."""
|
||||||
|
stats = {"published": 0, "skipped": 0, "failed": 0}
|
||||||
|
due = (
|
||||||
|
sess.query(ContentItem)
|
||||||
|
.filter(ContentItem.status == ContentStatus.APPROVED)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for item in due:
|
||||||
|
if item.scheduled_for and item.scheduled_for > now:
|
||||||
|
continue # not due yet
|
||||||
|
try:
|
||||||
|
if item.channel == "nostr":
|
||||||
|
item.post_ref = _publish_nostr(item, cfg, sess, sender=nostr_sender)
|
||||||
|
else:
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:publisher",
|
||||||
|
action="publish_skipped",
|
||||||
|
detail={
|
||||||
|
"item": item.id,
|
||||||
|
"channel": item.channel,
|
||||||
|
"reason": f"{item.channel} adapter disabled by default",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
except Exception as exc: # noqa: BLE001 — publish boundary: audit and continue
|
||||||
|
item.error = str(exc)[:300]
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:publisher",
|
||||||
|
action="publish_failed",
|
||||||
|
result="error",
|
||||||
|
detail={"item": item.id, "error": item.error},
|
||||||
|
)
|
||||||
|
stats["failed"] += 1
|
||||||
|
continue
|
||||||
|
item.status = ContentStatus.PUBLISHED
|
||||||
|
item.published_at = datetime.now(UTC)
|
||||||
|
audit(
|
||||||
|
sess,
|
||||||
|
actor="daemon:publisher",
|
||||||
|
action="publish_content",
|
||||||
|
detail={"item": item.id, "channel": item.channel, "ref": item.post_ref},
|
||||||
|
)
|
||||||
|
stats["published"] += 1
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI wrappers ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def plan(cfg: Config | None = None) -> int:
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
run_row = DaemonRun(daemon="publisher")
|
||||||
|
sess.add(run_row)
|
||||||
|
sess.flush()
|
||||||
|
stats = _plan(cfg, sess)
|
||||||
|
run_row.status = "ok"
|
||||||
|
run_row.detail = stats
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def publish_due(cfg: Config | None = None) -> int:
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
run_row = DaemonRun(daemon="publisher")
|
||||||
|
sess.add(run_row)
|
||||||
|
sess.flush()
|
||||||
|
stats = _publish_due(cfg, sess)
|
||||||
|
run_row.status = "ok"
|
||||||
|
run_row.detail = stats
|
||||||
|
run_row.finished_at = datetime.now(UTC)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def make_image(item_id: int, cfg: Config | None = None) -> int:
|
||||||
|
from cayceops.config import load
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
cfg = cfg or load()
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
item = sess.get(ContentItem, item_id)
|
||||||
|
if item is None:
|
||||||
|
return 1
|
||||||
|
print(_render_image(item, cfg))
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def approve(item_id: int, actor: str) -> int:
|
||||||
|
from cayceops.db import engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
with session_scope() as sess:
|
||||||
|
return _approve(item_id, actor, sess)
|
||||||
|
finally:
|
||||||
|
engine().dispose()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""monitor tests — page renders, no sensitive data, escapes input."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cayceops.components.monitor import monitor
|
||||||
|
from cayceops.models import ContentItem, DaemonRun, MailMessage, Proposal
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(sess):
|
||||||
|
sess.add(DaemonRun(daemon="scanner", status="ok", detail={"proposed": 3}))
|
||||||
|
sess.add(Proposal(kind="affiliate", title="Title with <script>alert(1)</script>"))
|
||||||
|
sess.add(
|
||||||
|
MailMessage(
|
||||||
|
direction="in",
|
||||||
|
from_addr="secret-person@private.example.org",
|
||||||
|
subject="private subject",
|
||||||
|
classification="approval",
|
||||||
|
draft_status="drafted",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.add(ContentItem(theme="atm-fees", channel="nostr", caption="caption", status="draft"))
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_renders_all_sections(cfg, sess, tmp_path):
|
||||||
|
_seed(sess)
|
||||||
|
out = tmp_path / "status.html"
|
||||||
|
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||||
|
page = out.read_text()
|
||||||
|
|
||||||
|
assert "daemon health" in page
|
||||||
|
assert "proposal queue" in page
|
||||||
|
assert "recent audit activity" in page
|
||||||
|
assert "scanner" in page
|
||||||
|
assert "proposed: <strong>1</strong>" in page
|
||||||
|
assert "mail drafts awaiting approval: <strong>1</strong>" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_leaks_no_sensitive_data(cfg, sess, tmp_path):
|
||||||
|
_seed(sess)
|
||||||
|
out = tmp_path / "status.html"
|
||||||
|
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||||
|
page = out.read_text()
|
||||||
|
|
||||||
|
assert "secret-person@private.example.org" not in page # no addresses
|
||||||
|
assert "private subject" not in page # no subjects
|
||||||
|
assert "caption" not in page # no content bodies
|
||||||
|
assert "Title with" not in page # proposal titles not rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_escapes_injected_content(cfg, sess, tmp_path):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from cayceops.models import AuditEntry
|
||||||
|
|
||||||
|
sess.add(
|
||||||
|
AuditEntry(
|
||||||
|
ts=datetime.now(UTC),
|
||||||
|
actor="<script>x</script>",
|
||||||
|
action="probe",
|
||||||
|
detail={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sess.flush()
|
||||||
|
out = tmp_path / "status.html"
|
||||||
|
monitor._build(cfg(CAYCE_OPS_MONITOR_OUTPUT=str(out)), sess)
|
||||||
|
page = out.read_text()
|
||||||
|
assert "<script>x</script>" not in page
|
||||||
|
assert "<script>x</script>" in page
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""publisher tests — calendar, human gate, nostr publish, disabled adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cayceops.components.publisher import adapters, nostr, publisher
|
||||||
|
from cayceops.models import AuditEntry, ContentItem, ContentStatus
|
||||||
|
|
||||||
|
# invented test key (never a real nostr key — allowlisted in .gitleaks.toml)
|
||||||
|
TEST_KEY = "11" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatus.APPROVED):
|
||||||
|
return ContentItem(
|
||||||
|
theme=theme,
|
||||||
|
channel=channel,
|
||||||
|
caption="The first ATM you find charges 10-15% over spot.\n\nbitcoinhotels.org/guides/atm",
|
||||||
|
link="bitcoinhotels.org/guides/atm",
|
||||||
|
status=status,
|
||||||
|
scheduled_for=scheduled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- nostr client -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_build_verify_roundtrip():
|
||||||
|
event = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000)
|
||||||
|
assert nostr.verify_event(event)
|
||||||
|
assert len(event["id"]) == 64 and len(event["sig"]) == 128
|
||||||
|
|
||||||
|
tampered = {**event, "content": "gm (edited)"}
|
||||||
|
assert not nostr.verify_event(tampered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_is_deterministic():
|
||||||
|
a = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||||
|
b = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", created_at=1700000000)
|
||||||
|
assert a == b
|
||||||
|
|
||||||
|
|
||||||
|
def test_key_validation():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
nostr.normalize_key("not-a-key")
|
||||||
|
assert nostr.normalize_key("0x" + TEST_KEY) == bytes.fromhex(TEST_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
# --- calendar ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_creates_idempotent_drafts(cfg, sess):
|
||||||
|
config = cfg()
|
||||||
|
first = publisher._plan(config, sess)
|
||||||
|
second = publisher._plan(config, sess)
|
||||||
|
assert first["created"] == 4
|
||||||
|
assert second["created"] == 0 # same themes+channels already queued
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_gate(cfg, sess):
|
||||||
|
item = _item(status=ContentStatus.DRAFT)
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
assert publisher._approve(item.id, "gustavo", sess) == 0
|
||||||
|
assert item.status is ContentStatus.APPROVED
|
||||||
|
assert publisher._approve(item.id, "gustavo", sess) == 1 # not draft anymore
|
||||||
|
|
||||||
|
actor = sess.query(AuditEntry).filter_by(action="approve_content").first().actor
|
||||||
|
assert actor == "gustavo"
|
||||||
|
|
||||||
|
|
||||||
|
# --- publishing ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_publish_due_nostr_happy_path(cfg, sess):
|
||||||
|
item = _item() # approved, scheduled None -> due now
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
relays_seen = []
|
||||||
|
|
||||||
|
def fake_sender(relays, event, timeout=10.0):
|
||||||
|
relays_seen.append(relays)
|
||||||
|
assert nostr.verify_event(event)
|
||||||
|
return [{"relay": r, "ok": "true"} for r in relays]
|
||||||
|
|
||||||
|
config = cfg(
|
||||||
|
CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY,
|
||||||
|
CAYCE_OPS_NOSTR_RELAYS="wss://relay.example.org,wss://other.example.org",
|
||||||
|
)
|
||||||
|
stats = publisher._publish_due(config, sess, nostr_sender=fake_sender)
|
||||||
|
|
||||||
|
assert stats["published"] == 1
|
||||||
|
assert item.status is ContentStatus.PUBLISHED
|
||||||
|
assert item.post_ref and len(item.post_ref) == 64
|
||||||
|
assert item.published_at is not None
|
||||||
|
assert "wss://relay.example.org" in relays_seen[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_scheduled_item_not_published(cfg, sess):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
item = _item(scheduled=datetime.now(UTC) + timedelta(hours=2))
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
config = cfg(CAYCE_OPS_NOSTR_PRIVATE_KEY=TEST_KEY, CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org")
|
||||||
|
stats = publisher._publish_due(config, sess, nostr_sender=lambda r, e, timeout=10: [])
|
||||||
|
assert stats["published"] == 0
|
||||||
|
assert item.status is ContentStatus.APPROVED
|
||||||
|
|
||||||
|
|
||||||
|
def test_nostr_without_config_fails_clean(cfg, sess):
|
||||||
|
item = _item()
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
config = cfg() # no key, no relays
|
||||||
|
stats = publisher._publish_due(config, sess)
|
||||||
|
assert stats["failed"] == 1
|
||||||
|
assert item.status is ContentStatus.APPROVED # stays approved, error recorded
|
||||||
|
assert item.error and "nostr" in item.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_nostr_channels_skipped_by_default(cfg, sess):
|
||||||
|
item = _item(channel="facebook", theme="lightning-abroad")
|
||||||
|
sess.add(item)
|
||||||
|
sess.flush()
|
||||||
|
stats = publisher._publish_due(cfg(), sess)
|
||||||
|
assert stats["skipped"] == 1
|
||||||
|
assert item.status is ContentStatus.APPROVED
|
||||||
|
skip = sess.query(AuditEntry).filter_by(action="publish_skipped").one()
|
||||||
|
assert skip.detail["channel"] == "facebook"
|
||||||
|
|
||||||
|
|
||||||
|
def test_meta_adapters_disabled_by_default(cfg):
|
||||||
|
config = cfg()
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
adapters.post_facebook(config, "hello")
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
adapters.post_instagram(config, "hello", "https://img.example.org/x.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
# --- images ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_brand_image_render(tmp_path, cfg):
|
||||||
|
config = cfg(CAYCE_OPS_MEDIA_DIR=str(tmp_path))
|
||||||
|
out = publisher._render_image(_item(), config)
|
||||||
|
assert os.path.exists(out)
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with Image.open(out) as img:
|
||||||
|
assert img.size == (1080, 1350)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_brand_rejected():
|
||||||
|
from cayceops.components.publisher import images
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
images.render_brand_image(brand="xx", kicker=None, lines=["A"], out_path="/tmp/x.jpg")
|
||||||
Reference in New Issue
Block a user