Files
reyna-cli/backup/macmini-automation-baseline-2026-08-03/hermes-scripts/reyna_morning_briefing_whatsapp.py
T
2026-08-03 11:47:09 -04:00

175 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Generate and send the Reyna family morning briefing to WhatsApp without cron wrappers."""
from __future__ import annotations
import json
import os
import pathlib
import subprocess
import sys
import urllib.parse
import urllib.request
CHAT_ID = "120363424746547296@g.us"
BRIDGE_URL = "http://127.0.0.1:3000/send"
BRIDGE_MEDIA_URL = "http://127.0.0.1:3000/send-media"
TENOR_API_KEY = os.environ.get("TENOR_API_KEY", "[REDACTED]")
GIF_DIR = pathlib.Path("/tmp/reyna_morning_briefing_gifs")
PROMPT = r"""
You are the Reyna family morning assistant. Return ONLY compact JSON with exactly two keys:
{"message":"...","gif_query":"..."}
The `message` value is natural WhatsApp text for Adolfo and Alicia, like a friendly person starting the family conversation. Do not include a cron header, job ID, divider, footer, computation hints, tool narration, “I checked…”, “based on the files…”, or any explanation of your process.
The `gif_query` value is a short, family-friendly Tenor GIF search query that matches the tone/content of the briefing. Examples: "good morning coffee", "busy morning", "family teamwork", "calendar reminder", "clean house", "happy dance", "relaxing morning". Avoid anything political, romantic, scary, crude, or celebrity-specific. Keep it under 5 words.
Before writing the briefing, gather real context from the local family brain at /home/adolforeyna/brain. Treat it as the source of truth. Read /home/adolforeyna/brain/index.md and /home/adolforeyna/brain/memory.md if present, then look for today’s journal at /home/adolforeyna/brain/journals/YYYY-MM-DD.md, active projects under /home/adolforeyna/brain/projects/, and relevant areas/reminder notes under /home/adolforeyna/brain/areas/. Use file/search tools to find dated items, reminders, TODO/action markers, missed/overdue items, appointments, errands, family habits, maintenance tasks, and any open actions that appear relevant for today. Pay special attention to things that should already have been done: dates before today, stale open tasks, appointments that passed but remain unchecked, and Apple Reminders that are overdue.
Also use the Apple Calendar and shared Apple Reminders ecosystem as first-class sources because the Family list/calendar are shared with Alicia. Before composing, list existing Hermes cron jobs to avoid duplicating reminders and to mention scheduled reminders that will occur today. If the reyna-cli Mac mini Calendar integration is available, run `cd /home/adolforeyna/reyna-cli && env -u VIRTUAL_ENV uv run reyna-cli macmini calendar calendars --json`, then list today's events from likely family/home calendars (especially the writable `Family` calendar if present, and `Home` calendars when relevant) with `env -u VIRTUAL_ENV uv run reyna-cli macmini calendar events <today-00:00-local-ISO> <tomorrow-00:00-local-ISO> --calendar-index <index> --json --limit 50`. Include real calendar events for today and soon only; do not invent events. If the reyna-cli Mac mini Reminders integration is available, run `cd /home/adolforeyna/reyna-cli && env -u VIRTUAL_ENV uv run reyna-cli macmini reminders list --list Family --json --limit 100` and use the Apple Reminders `Family` list for shared pending items. The reminders output may include an `assignment` object such as `assignment.assignee`, `assignment.source`, and `assignment.available`; use it to phrase ownership naturally when helpful (for example, Alicia-specific tasks can be addressed to Alicia, Adolfo-specific tasks to Adolfo), but do not expose implementation details like "source" or "available" in the WhatsApp text. Include incomplete Family reminders that are overdue, due today, due soon, or clearly relevant undated household/family items. If you find pending items in the family brain that are missing from the Family Reminders list, add concise reminders to the `Family` list with `env -u VIRTUAL_ENV uv run reyna-cli macmini reminders create ... --list Family ...`, but first compare titles/notes to avoid duplicates. If Calendar or Reminder sources are unavailable or error, silently fall back to the family brain and cron jobs; do not mention the integration failure.
The natural WhatsApp message should sound conversational, not like a report. You may use 2–4 short paragraphs or mini messages separated by blank lines. Avoid technical language and avoid rigid labels unless they help readability. Include the actual useful content:
1. A short morning greeting to Adolfo and Alicia.
2. What seems to be on deck today: appointments/events/deadlines/habits expected today, if any were found.
3. Reminders or actions: due or missing actions for today, including overdue actions if clearly found.
4. If nothing specific is found, say so casually and ask a simple check-in question for the family.
Do not invent events or reminders. If the brain has no clear entry for a section, state that there’s nothing obvious in the family brain. Keep the message concise enough for WhatsApp. Return valid JSON only, with no markdown fences.
""".strip()
def _extract_json_object(text: str) -> dict:
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return json.loads(text[start : end + 1])
raise
def generate_briefing() -> tuple[str, str]:
cmd = [
"hermes",
"chat",
"-Q",
"-t",
"file,terminal,cronjob",
"-q",
PROMPT,
]
# Cron allows 240s for this script; give the nested Hermes run enough room
# to read the family brain + Apple Reminders without being killed first.
proc = subprocess.run(cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=220)
if proc.returncode != 0:
raise RuntimeError(f"hermes chat failed with exit {proc.returncode}: {proc.stderr[-2000:]}")
output = proc.stdout.strip()
if not output:
raise RuntimeError("hermes chat returned an empty morning briefing")
try:
briefing = _extract_json_object(output)
message = str(briefing.get("message") or "").strip()
gif_query = str(briefing.get("gif_query") or "good morning").strip()
except Exception:
# Backward-compatible fallback if the nested agent returns text despite
# the JSON instruction: preserve the briefing and use a safe generic GIF.
message = output
gif_query = "good morning"
if not message:
raise RuntimeError("hermes chat returned a briefing without message text")
forbidden = ("Cronjob Response:", "(job_id:", "-------------", "To stop or manage this job")
for marker in forbidden:
message = message.replace(marker, "")
return message.strip(), gif_query[:80] or "good morning"
def download_context_gif(query: str) -> pathlib.Path | None:
GIF_DIR.mkdir(parents=True, exist_ok=True)
params = urllib.parse.urlencode(
{
"q": query,
"key": TENOR_API_KEY,
"limit": 6,
"media_filter": "minimal",
"contentfilter": "medium",
}
)
url = f"https://g.tenor.com/v1/search?{params}"
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8", errors="replace"))
for result in data.get("results", []):
media = (result.get("media") or [{}])[0]
gif_url = (media.get("gif") or {}).get("url") or (media.get("tinygif") or {}).get("url")
if not gif_url:
continue
safe_query = "".join(ch if ch.isalnum() else "_" for ch in query.lower()).strip("_")[:40] or "morning"
dest = GIF_DIR / f"{safe_query}_{result.get('id', 'tenor')}.gif"
gif_req = urllib.request.Request(gif_url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(gif_req, timeout=30) as gif_resp:
dest.write_bytes(gif_resp.read())
if dest.stat().st_size > 0:
return dest
return None
def send_whatsapp(message: str) -> None:
payload = json.dumps({"chatId": CHAT_ID, "message": message}).encode("utf-8")
req = urllib.request.Request(
BRIDGE_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", errors="replace")
if resp.status != 200:
raise RuntimeError(f"WhatsApp bridge returned HTTP {resp.status}: {body}")
def send_whatsapp_gif(path: pathlib.Path, caption: str = "") -> None:
payload = json.dumps(
{
"chatId": CHAT_ID,
"filePath": str(path),
"mediaType": "image",
"caption": caption,
}
).encode("utf-8")
req = urllib.request.Request(
BRIDGE_MEDIA_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read().decode("utf-8", errors="replace")
if resp.status != 200:
raise RuntimeError(f"WhatsApp bridge media returned HTTP {resp.status}: {body}")
def main() -> int:
try:
message, gif_query = generate_briefing()
send_whatsapp(message)
try:
gif_path = download_context_gif(gif_query)
if gif_path:
send_whatsapp_gif(gif_path)
else:
print(f"Morning briefing GIF search returned no results for: {gif_query}", file=sys.stderr)
except Exception as gif_exc:
# Do not fail the morning briefing if the reaction GIF service or
# media send is temporarily unavailable.
print(f"Morning briefing GIF failed: {gif_exc}", file=sys.stderr)
return 0
except Exception as exc:
print(f"Morning briefing failed: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())