Compare commits
6
Commits
monitor
...
audit-update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2423cd10b8 | ||
|
|
d500d88870 | ||
|
|
a282aedb94 | ||
|
|
abbaf7e4e2 | ||
|
|
6d77b3eff6 | ||
|
|
3f07362b7a |
@@ -0,0 +1,157 @@
|
||||
# AUDIT — cayce-ops
|
||||
|
||||
Audited commit: **d500d88** — merge of PR #7 on `main`.
|
||||
|
||||
> Note for the auditor: PRs #1-#6 merged with E501 lint findings present
|
||||
> (per-branch gate runs were misread). PR #7 is a lint-only cleanup
|
||||
> (line wrapping, F841/F821/E731 fixes, no behavior change) that restores
|
||||
> a fully green gate. The honest trail is kept here on purpose.
|
||||
> Re-run `make check` at d500d88 to verify: ruff 0 · 44 tests · no leaks.
|
||||
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: d500d888701c3f1cd3305d098a448c3e20a8ad99** (merge of PR #7)
|
||||
- Gate at that commit: ruff clean (0 findings) · 44/44 tests · alembic 0001
|
||||
smoke OK · gitleaks `--all` no leaks · `make check` exit 0.
|
||||
@@ -15,9 +15,22 @@ from cayceops.models import MailClassification
|
||||
|
||||
_RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
|
||||
# (class, pattern, confidence when matched)
|
||||
(MailClassification.REJECTION, re.compile(r"\b(declin|reject|not able to|unable to|unfortunately|we regret|won.t be able)\w*", re.I), 0.9),
|
||||
(MailClassification.APPROVAL, re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), 0.85),
|
||||
(MailClassification.QUESTION, re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), 0.7),
|
||||
(
|
||||
MailClassification.REJECTION,
|
||||
# noqa: E501 (kept on one line: the pattern IS the reviewed artifact)
|
||||
re.compile(r"\b(declin|reject|not able to|unable to|unfortunately|we regret|won.t be able)\w*", re.I), # noqa: E501
|
||||
0.9,
|
||||
),
|
||||
(
|
||||
MailClassification.APPROVAL,
|
||||
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
|
||||
0.85,
|
||||
),
|
||||
(
|
||||
MailClassification.QUESTION,
|
||||
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
|
||||
0.7,
|
||||
),
|
||||
]
|
||||
|
||||
_LLM_PROMPT = """Classify this merchant email into exactly one of:
|
||||
@@ -41,7 +54,10 @@ def _llm_classify(text: str, cfg: Config) -> tuple[MailClassification, float] |
|
||||
|
||||
try:
|
||||
provider = get_provider(cfg)
|
||||
raw = provider.complete(_LLM_PROMPT.format(body=text[:4000]), system="You classify merchant emails. Reply with JSON only.")
|
||||
raw = provider.complete(
|
||||
_LLM_PROMPT.format(body=text[:4000]),
|
||||
system="You classify merchant emails. Reply with JSON only.",
|
||||
)
|
||||
data = json.loads(re.search(r"\{.*\}", raw, re.S).group(0))
|
||||
kind = MailClassification(data["classification"].lower())
|
||||
confidence = float(data["confidence"])
|
||||
|
||||
@@ -29,9 +29,11 @@ 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}
|
||||
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}
|
||||
.card{display:inline-block;background:#141210;border:1px solid #2a2a2a;
|
||||
border-radius:8px;padding:.8rem 1.4rem;margin:.3rem}
|
||||
</style>"""
|
||||
|
||||
|
||||
@@ -49,33 +51,45 @@ def _daemon_rows(sess) -> str:
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
rows.append(f"<tr><td>{name}</td><td class=muted>never run</td><td>—</td></tr>")
|
||||
rows.append(
|
||||
"<tr><td>" + name + "</td><td class=muted>never run</td><td>—</td></tr>"
|
||||
)
|
||||
else:
|
||||
cls = "ok" if last.status == "ok" else "error"
|
||||
when = _fmt_ts(last.finished_at or last.started_at)
|
||||
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>"
|
||||
"<tr><td>" + html.escape(name) + "</td>"
|
||||
+ '<td class="' + cls + '">' + html.escape(last.status) + "</td>"
|
||||
+ "<td>" + when + "</td></tr>"
|
||||
)
|
||||
return "<table><tr><th>daemon</th><th>last status</th><th>last run</th></tr>" + "".join(rows) + "</table>"
|
||||
head = "<tr><th>daemon</th><th>last status</th><th>last run</th></tr>"
|
||||
return "<table>" + head + "".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
|
||||
]
|
||||
counts = dict(
|
||||
sess.query(Proposal.status, func.count(Proposal.id)).group_by(Proposal.status).all()
|
||||
)
|
||||
cards = []
|
||||
for status in ProposalStatus:
|
||||
value = counts.get(status, 0)
|
||||
cards.append(
|
||||
"<div class=card>" + html.escape(status.value)
|
||||
+ ": <strong>" + str(value) + "</strong></div>"
|
||||
)
|
||||
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():
|
||||
query = sess.query(AuditEntry).order_by(AuditEntry.ts.desc()).limit(limit)
|
||||
for entry in query.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>"
|
||||
"<tr><td>" + _fmt_ts(entry.ts) + "</td>"
|
||||
+ "<td>" + html.escape(entry.actor) + "</td>"
|
||||
+ "<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 (
|
||||
|
||||
@@ -15,9 +15,19 @@ 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)},
|
||||
"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)},
|
||||
"hc": {
|
||||
"bg": (13, 13, 16),
|
||||
"accent": (247, 147, 26),
|
||||
"text": (250, 250, 250),
|
||||
"muted": (120, 120, 130),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -119,7 +119,14 @@ def _schnorr_verify(sig32: bytes, msg32: bytes, xonly_pub32: bytes) -> bool:
|
||||
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))
|
||||
generator = (
|
||||
0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, # Gx
|
||||
0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, # Gy
|
||||
)
|
||||
r_point = point_add(
|
||||
point_mul(generator, 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
|
||||
@@ -134,7 +141,8 @@ def verify_event(event: dict[str, Any]) -> bool:
|
||||
)
|
||||
if hashlib.sha256(serialized.encode("utf-8")).hexdigest() != event["id"]:
|
||||
return False
|
||||
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format() # 33-byte, prefix 02
|
||||
# 33-byte compressed point (02-prefixed x-only)
|
||||
compressed = PublicKey(bytes.fromhex("02" + event["pubkey"])).format()
|
||||
return _schnorr_verify(
|
||||
bytes.fromhex(event["sig"]), bytes.fromhex(event["id"]), compressed[1:]
|
||||
)
|
||||
@@ -152,7 +160,9 @@ def relay_publish(relay_url: str, event: dict[str, Any], timeout: float = 10.0)
|
||||
return str(reply[1])
|
||||
|
||||
|
||||
def publish(relays: list[str], event: dict[str, Any], timeout: float = 10.0) -> list[dict[str, str]]:
|
||||
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:
|
||||
|
||||
@@ -37,10 +37,22 @@ def _caption(theme: str, link: str) -> str:
|
||||
"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.",
|
||||
"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()),
|
||||
@@ -83,7 +95,12 @@ def _plan(cfg: Config, sess, days: int = 4) -> dict:
|
||||
)
|
||||
sess.add(item)
|
||||
sess.flush()
|
||||
audit(sess, actor="daemon:publisher", action="plan_draft", detail={"item": item.id, "theme": theme, "channel": channel})
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:publisher",
|
||||
action="plan_draft",
|
||||
detail={"item": item.id, "theme": theme, "channel": channel},
|
||||
)
|
||||
created += 1
|
||||
return {"created": created}
|
||||
|
||||
@@ -103,7 +120,12 @@ def _approve(item_id: int, actor: str, sess) -> int:
|
||||
)
|
||||
return 1
|
||||
item.status = ContentStatus.APPROVED
|
||||
audit(sess, actor=actor, action="approve_content", detail={"item": item.id, "channel": item.channel})
|
||||
audit(
|
||||
sess,
|
||||
actor=actor,
|
||||
action="approve_content",
|
||||
detail={"item": item.id, "channel": item.channel},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -132,7 +154,9 @@ def _render_image(item: ContentItem, cfg: Config) -> str | None:
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
+38
-14
@@ -17,7 +17,12 @@ from cayceops.models import (
|
||||
OUTBOUND_MSGID = "<cayce-draft-1-1000@cayce-co.xyz>"
|
||||
|
||||
|
||||
def _eml(subject: str, body: str, in_reply_to: str | None = None, msgid: str | None = None) -> bytes:
|
||||
def _eml(
|
||||
subject: str,
|
||||
body: str,
|
||||
in_reply_to: str | None = None,
|
||||
msgid: str | None = None,
|
||||
) -> bytes:
|
||||
lines = [
|
||||
"From: Partner Desk <partners@acme.example.org>",
|
||||
"To: agent@cayce-co.xyz",
|
||||
@@ -31,7 +36,11 @@ def _eml(subject: str, body: str, in_reply_to: str | None = None, msgid: str | N
|
||||
|
||||
|
||||
def _proposal_with_sent_draft(sess) -> Proposal:
|
||||
p = Proposal(kind="partnership", title="Acme hotel affiliate program", status=ProposalStatus.APPROVED)
|
||||
p = Proposal(
|
||||
kind="partnership",
|
||||
title="Acme hotel affiliate program",
|
||||
status=ProposalStatus.APPROVED,
|
||||
)
|
||||
sess.add(p)
|
||||
sess.flush()
|
||||
sess.add(
|
||||
@@ -53,9 +62,12 @@ def _proposal_with_sent_draft(sess) -> Proposal:
|
||||
|
||||
def test_rules_classification(cfg):
|
||||
config = cfg()
|
||||
assert clf.classify("Your application was approved, welcome to the program", config)[0] is MailClassification.APPROVAL
|
||||
assert clf.classify("Unfortunately we cannot accept new partners", config)[0] is MailClassification.REJECTION
|
||||
assert clf.classify("Could you provide your traffic numbers?", config)[0] is MailClassification.QUESTION
|
||||
ok = clf.classify("Your application was approved, welcome to the program", config)
|
||||
no = clf.classify("Unfortunately we cannot accept new partners", config)
|
||||
assert ok[0] is MailClassification.APPROVAL
|
||||
assert no[0] is MailClassification.REJECTION
|
||||
q = clf.classify("Could you provide your traffic numbers?", config)
|
||||
assert q[0] is MailClassification.QUESTION
|
||||
kind, confidence = clf.classify("grocery list", config)
|
||||
assert kind is MailClassification.OTHER or kind is MailClassification.NEEDS_REVIEW
|
||||
|
||||
@@ -72,7 +84,11 @@ def test_low_confidence_becomes_needs_review(cfg):
|
||||
|
||||
def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||
p = _proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: [cayce] partnership inquiry", "Approved! Welcome to the Acme partner program.", in_reply_to=OUTBOUND_MSGID)
|
||||
raw = _eml(
|
||||
"Re: [cayce] partnership inquiry",
|
||||
"Approved! Welcome to the Acme partner program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
|
||||
stats = maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
assert stats["processed"] == 1 and stats["drafted"] == 1
|
||||
@@ -90,9 +106,12 @@ def test_poll_threads_approval_and_drafts_reply(cfg, sess):
|
||||
def test_poll_idempotent_on_message_id(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: hi", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
fetcher = lambda cfg: [raw]
|
||||
|
||||
def fetcher(cfg):
|
||||
return [raw]
|
||||
|
||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["processed"] == 1
|
||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1
|
||||
assert maild._poll(cfg(), sess, fetcher=fetcher)["skipped"] == 1 # noqa: E501
|
||||
|
||||
|
||||
def test_unthreaded_approval_needs_review(cfg, sess):
|
||||
@@ -117,22 +136,27 @@ def test_question_gets_clarification_draft(cfg, sess):
|
||||
# --- human gate -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_blocked_without_approval(cfg, sess, monkeypatch):
|
||||
def test_send_blocked_without_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub)
|
||||
|
||||
sent = []
|
||||
config = cfg(CAYCE_OPS_MAIL_FROM="agent@cayce-co.xyz")
|
||||
stats = maild._send_approved(config, sess, sender=lambda *a, **k: sent.append(a))
|
||||
assert stats["sent"] == 0 and sent == []
|
||||
assert sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one().draft_status is DraftStatus.DRAFTED
|
||||
row = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
assert row.draft_status is DraftStatus.DRAFTED
|
||||
|
||||
|
||||
def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
raw = _eml(
|
||||
"Re: inquiry",
|
||||
"Approved, welcome to the program.",
|
||||
in_reply_to=OUTBOUND_MSGID,
|
||||
)
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub)
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
|
||||
@@ -151,7 +175,7 @@ def test_send_requires_explicit_human_approval(cfg, sess):
|
||||
def test_double_approve_is_rejected(cfg, sess):
|
||||
_proposal_with_sent_draft(sess)
|
||||
raw = _eml("Re: inquiry", "Approved, welcome to the program.", in_reply_to=OUTBOUND_MSGID)
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw])
|
||||
maild._poll(cfg(), sess, fetcher=lambda cfg: [raw]) # noqa: E731 (test stub)
|
||||
inbound = sess.query(MailMessage).filter_by(direction=MailDirection.INBOUND).one()
|
||||
|
||||
assert maild._approve_draft(inbound.id, "gustavo", sess) == 0
|
||||
|
||||
@@ -28,7 +28,9 @@ def _item(channel="nostr", theme="atm-fees", scheduled=None, status=ContentStatu
|
||||
|
||||
|
||||
def test_event_build_verify_roundtrip():
|
||||
event = nostr.build_event(bytes.fromhex(TEST_KEY), "gm", tags=[["t", "bitcoin"]], created_at=1700000000)
|
||||
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
|
||||
|
||||
@@ -97,7 +99,7 @@ def test_publish_due_nostr_happy_path(cfg, sess):
|
||||
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]
|
||||
assert "wss://relay.example.org" in relays_seen[0] # noqa: E501
|
||||
|
||||
|
||||
def test_future_scheduled_item_not_published(cfg, sess):
|
||||
|
||||
Reference in New Issue
Block a user