From d9450ce70d5684b3fb443dfb28308f3425e88a3e Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Thu, 2 Apr 2026 17:43:13 +0000 Subject: [PATCH] chore: auto-commit from dashboard --- config.json | 5 + cron/newsletter-cercetasi-state.json | 4 + src/main.py | 21 +++++ src/newsletter_cercetasi.py | 134 +++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 cron/newsletter-cercetasi-state.json create mode 100644 src/newsletter_cercetasi.py diff --git a/config.json b/config.json index f5b83d4..4b75b12 100644 --- a/config.json +++ b/config.json @@ -43,6 +43,11 @@ "git": 14400 } }, + "newsletter_cercetasi": { + "enabled": true, + "cron": "0 17 * * 4,5,1", + "channel": "echo-core" + }, "allowed_tools": [ "Read", "Edit", "Write", "Glob", "Grep", "WebFetch", "WebSearch", diff --git a/cron/newsletter-cercetasi-state.json b/cron/newsletter-cercetasi-state.json new file mode 100644 index 0000000..d519bc2 --- /dev/null +++ b/cron/newsletter-cercetasi-state.json @@ -0,0 +1,4 @@ +{ + "last_sent": 12, + "year": 2026 +} diff --git a/src/main.py b/src/main.py index 015e112..a435e7c 100644 --- a/src/main.py +++ b/src/main.py @@ -112,6 +112,27 @@ def main(): interval_min, hb_channel, ) + # Newsletter Cercetași checker (optional) + newsletter_config = config.get("newsletter_cercetasi", {}) + if newsletter_config.get("enabled"): + from src.newsletter_cercetasi import check_and_send as check_newsletter + from apscheduler.triggers.cron import CronTrigger as _CronTrigger + + async def _newsletter_tick() -> None: + try: + await check_newsletter(config, _send_to_channel) + except Exception as exc: + logger.error("Newsletter checker failed: %s", exc) + + nl_cron = newsletter_config.get("cron", "0 9 * * *") + scheduler._scheduler.add_job( + _newsletter_tick, + trigger=_CronTrigger.from_crontab(nl_cron), + id="__newsletter_cercetasi__", + max_instances=1, + ) + logger.info("Newsletter Cercetasi checker registered (cron: %s)", nl_cron) + # Telegram bot (optional — only if telegram_token exists) telegram_token = get_secret("telegram_token") telegram_app = None diff --git a/src/newsletter_cercetasi.py b/src/newsletter_cercetasi.py new file mode 100644 index 0000000..62ca1f1 --- /dev/null +++ b/src/newsletter_cercetasi.py @@ -0,0 +1,134 @@ +"""Newsletter Cercetași checker — detects new editions and sends WhatsApp summaries.""" + +import asyncio +import json +import logging +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Awaitable, Callable + +import httpx + +from src.claude_session import CLAUDE_BIN, PROJECT_ROOT, _safe_env, build_system_prompt + +log = logging.getLogger("echo-core.newsletter-cercetasi") + +NEWSLETTER_BASE_URL = "https://cercetaiis-newsletter.beehiiv.com/p/newsletter-{n}-din-{year}" +STATE_FILE = PROJECT_ROOT / "cron" / "newsletter-cercetasi-state.json" +KB_PROMPT_FILE = ( + PROJECT_ROOT / "memory" / "kb" / "projects" / "grup-sprijin" / "prompt-newsletter-cercetasi.md" +) +CLAUDE_TIMEOUT = 120 + + +def _read_state() -> dict: + try: + return json.loads(STATE_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError): + return {"last_sent": 0, "year": datetime.now(timezone.utc).year} + + +def _write_state(state: dict) -> None: + STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n") + + +async def _newsletter_exists(n: int, year: int) -> bool: + """Return True if newsletter #{n}/{year} returns HTTP 200.""" + url = NEWSLETTER_BASE_URL.format(n=n, year=year) + try: + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.get(url, timeout=10) + return resp.status_code == 200 + except Exception as e: + log.debug("Newsletter #%d/%d check failed: %s", n, year, e) + return False + + +def _generate_summary(n: int, year: int) -> str | None: + """Run Claude CLI to generate summary for newsletter #{n}/{year}. Returns text or None.""" + url = NEWSLETTER_BASE_URL.format(n=n, year=year) + + try: + kb_prompt = KB_PROMPT_FILE.read_text() + except FileNotFoundError: + log.error("KB prompt file not found: %s", KB_PROMPT_FILE) + return None + + prompt = ( + f"Newsletter-ul Cercetașilor #{n}/{year} este disponibil la: {url}\n\n" + f"Urmează instrucțiunile de mai jos pentru a genera rezumatul:\n\n" + f"{kb_prompt}" + ) + + cmd = [ + CLAUDE_BIN, "-p", prompt, + "--model", "sonnet", + "--output-format", "json", + "--allowedTools", "WebFetch", + ] + + try: + system_prompt = build_system_prompt() + cmd += ["--system-prompt", system_prompt] + except FileNotFoundError: + pass + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=CLAUDE_TIMEOUT, + env=_safe_env(), + cwd=PROJECT_ROOT, + ) + if proc.returncode != 0: + log.error("Claude CLI error (exit %d): %s", proc.returncode, proc.stderr[:300]) + return None + data = json.loads(proc.stdout) + return data.get("result", "").strip() or None + except subprocess.TimeoutExpired: + log.error("Claude CLI timed out for newsletter #%d", n) + return None + except (json.JSONDecodeError, Exception) as e: + log.error("Failed to generate newsletter summary: %s", e) + return None + + + +async def check_and_send(config, send_callback) -> None: + """Check for new newsletter; if found, generate summary and send via callback.""" + state = _read_state() + current_year = datetime.now(timezone.utc).year + + # New year → reset counter + if state.get("year", current_year) != current_year: + log.info("New year detected (%d → %d), resetting newsletter counter", state["year"], current_year) + state = {"last_sent": 0, "year": current_year} + + next_n = state["last_sent"] + 1 + log.info("Checking for Cercetasi newsletter #%d/%d...", next_n, current_year) + + if not await _newsletter_exists(next_n, current_year): + log.info("Newsletter #%d/%d not yet available", next_n, current_year) + return + + log.info("Newsletter #%d/%d found! Generating summary...", next_n, current_year) + + summary = await asyncio.to_thread(_generate_summary, next_n, current_year) + if not summary: + log.error("Failed to generate summary for newsletter #%d/%d", next_n, current_year) + return + + channel = config.get("newsletter_cercetasi.channel", "echo-core") + try: + await send_callback(channel, summary) + state["last_sent"] = next_n + state["year"] = current_year + state["last_sent_at"] = datetime.now(timezone.utc).isoformat() + _write_state(state) + log.info("Newsletter #%d/%d summary sent to channel '%s'", next_n, current_year, channel) + except Exception as e: + log.error("Send failed for newsletter #%d/%d — will retry next check: %s", next_n, current_year, e)