#!/usr/bin/env python3 """ Standalone cron script: check for new Cercetași newsletter and send summary to Discord. Usage: python3 /home/moltbot/echo-core/tools/check_newsletter_cercetasi.py Crontab: 0 17 * * 4,5,1 /home/moltbot/echo-core/.venv/bin/python3 /home/moltbot/echo-core/tools/check_newsletter_cercetasi.py >> /home/moltbot/echo-core/logs/newsletter-cercetasi.log 2>&1 """ import json import subprocess import sys import urllib.request from datetime import datetime, timezone from pathlib import Path import httpx PROJECT_ROOT = Path(__file__).resolve().parent.parent STATE_FILE = PROJECT_ROOT / "cron" / "newsletter-cercetasi-state.json" KB_PROMPT_FILE = PROJECT_ROOT / "memory" / "kb" / "projects" / "grup-sprijin" / "prompt-newsletter-cercetasi.md" CONFIG_FILE = PROJECT_ROOT / "config.json" CLAUDE_BIN = "/home/moltbot/.local/bin/claude" CLAUDE_TIMEOUT = 300 NEWSLETTER_BASE_URL = "https://cercetaiis-newsletter.beehiiv.com/p/newsletter-{n}-din-{year}" def log(msg): print(f"[{datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S')} UTC] {msg}", flush=True) 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): STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n") def newsletter_exists(n: int, year: int) -> bool: url = NEWSLETTER_BASE_URL.format(n=n, year=year) try: with httpx.Client(follow_redirects=False, timeout=10) as client: resp = client.get(url, headers={"User-Agent": "Mozilla/5.0"}) return resp.status_code == 200 except Exception as e: log(f"HTTP check failed: {e}") return False def generate_summary(n: int, year: int) -> str | None: url = NEWSLETTER_BASE_URL.format(n=n, year=year) try: kb_prompt = KB_PROMPT_FILE.read_text() except FileNotFoundError: log(f"KB prompt file not found: {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}" ) # Strip Claude Code env vars to allow nested execution import os env = {k: v for k, v in os.environ.items() if k not in {"CLAUDECODE", "CLAUDE_CODE_SSE_PORT", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"}} cmd = [ CLAUDE_BIN, "-p", prompt, "--model", "sonnet", "--output-format", "json", "--allowedTools", "WebFetch", ] try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=CLAUDE_TIMEOUT, env=env, cwd=PROJECT_ROOT, ) if proc.returncode != 0: log(f"Claude CLI error (exit {proc.returncode}): {proc.stderr[:300]}") return None data = json.loads(proc.stdout) return data.get("result", "").strip() or None except subprocess.TimeoutExpired: log(f"Claude CLI timed out after {CLAUDE_TIMEOUT}s") return None except Exception as e: log(f"Failed to generate summary: {e}") return None def send_discord(channel_id: str, token: str, text: str) -> bool: limit = 2000 chunks = [] while text: if len(text) <= limit: chunks.append(text) break split_at = text.rfind("\n", 0, limit) if split_at == -1: split_at = limit chunks.append(text[:split_at]) text = text[split_at:].lstrip("\n") try: with httpx.Client(timeout=15) as client: for chunk in chunks: resp = client.post( f"https://discord.com/api/v10/channels/{channel_id}/messages", headers={"Authorization": f"Bot {token}"}, json={"content": chunk}, ) if resp.status_code not in (200, 201): log(f"Discord send failed: {resp.status_code} {resp.text[:200]}") return False except Exception as e: log(f"Discord send error: {e}") return False return True def get_discord_token() -> str | None: """Read Discord token from keyring.""" try: import keyring return keyring.get_password("echo-core", "discord_token") except Exception as e: log(f"Keyring error: {e}") return None def get_discord_channel_id() -> str | None: try: config = json.loads(CONFIG_FILE.read_text()) return config.get("channels", {}).get("echo-core", {}).get("id") except Exception as e: log(f"Config read error: {e}") return None def get_telegram_token() -> str | None: try: import keyring return keyring.get_password("echo-core", "telegram_token") except Exception as e: log(f"Keyring error (telegram): {e}") return None def send_telegram(bot_token: str, chat_id: str, text: str) -> bool: limit = 4096 chunks = [] while text: if len(text) <= limit: chunks.append(text) break split_at = text.rfind("\n", 0, limit) if split_at == -1: split_at = limit chunks.append(text[:split_at]) text = text[split_at:].lstrip("\n") try: with httpx.Client(timeout=15) as client: for chunk in chunks: resp = client.post( f"https://api.telegram.org/bot{bot_token}/sendMessage", json={"chat_id": chat_id, "text": chunk}, ) if resp.status_code != 200: log(f"Telegram send failed: {resp.status_code} {resp.text[:200]}") return False except Exception as e: log(f"Telegram send error: {e}") return False return True def main(): state = read_state() current_year = datetime.now(timezone.utc).year # New year → reset counter if state.get("year", current_year) != current_year: log(f"New year detected ({state['year']} → {current_year}), resetting counter") state = {"last_sent": 0, "year": current_year} config = json.loads(CONFIG_FILE.read_text()) discord_token = get_discord_token() channel_id = get_discord_channel_id() telegram_token = get_telegram_token() telegram_chat_id = config.get("newsletter_cercetasi", {}).get("telegram_chat_id", "5040014994") bridge_url = config.get("whatsapp", {}).get("bridge_url", "http://127.0.0.1:8098") owner_phone = config.get("whatsapp", {}).get("owner", "") any_error = False next_n = state["last_sent"] + 1 # Scan forward: process all available newsletters, not just the next one while True: log(f"Checking for newsletter #{next_n}/{current_year}...") if not newsletter_exists(next_n, current_year): log(f"Newsletter #{next_n}/{current_year} not yet available. Stopping.") break log(f"Newsletter #{next_n}/{current_year} found! Generating summary...") summary = generate_summary(next_n, current_year) if not summary: log(f"Summary generation failed for #{next_n}. Will retry next run.") any_error = True break success = False # Send to Discord if discord_token and channel_id: log(f"Sending #{next_n} to Discord channel {channel_id}...") if send_discord(channel_id, discord_token, summary): log("Discord: sent successfully.") success = True else: log("Discord send failed.") else: log("Discord token or channel ID missing, skipping.") # Send to Telegram if telegram_token: log(f"Sending #{next_n} to Telegram chat {telegram_chat_id}...") if send_telegram(telegram_token, telegram_chat_id, summary): log("Telegram: sent successfully.") success = True else: log("Telegram send failed.") else: log("Telegram token missing, skipping.") # Send to WhatsApp if owner_phone: wa_to = f"{owner_phone}@s.whatsapp.net" log(f"Sending #{next_n} to WhatsApp {owner_phone}...") try: with httpx.Client(timeout=15) as client: resp = client.post(f"{bridge_url}/send", json={"to": wa_to, "text": summary}) if resp.status_code == 200 and resp.json().get("ok"): log("WhatsApp: sent successfully.") success = True else: log(f"WhatsApp send failed: {resp.text[:200]}") except Exception as e: log(f"WhatsApp send error: {e}") else: log("WhatsApp owner not configured, skipping.") if success: state["last_sent"] = next_n state["year"] = current_year state["last_sent_at"] = datetime.now(timezone.utc).isoformat() write_state(state) log(f"Newsletter #{next_n}/{current_year} done. State saved.") next_n += 1 else: log(f"All sends failed for #{next_n} — will retry next run.") any_error = True break if any_error: sys.exit(1) if __name__ == "__main__": main()