From 9314d63aa0fe20212dedea12f9d4471cb61f5cfc Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 21 Apr 2026 05:45:02 +0000 Subject: [PATCH] feat(email): add digest and forward commands to WhatsApp Digest summarizes unread emails via Claude CLI; forward sends raw content (split to 4096 chars). Wired as /email digest and /email forward slash commands, plus instant per-guild sync on ready. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapters/discord_bot.py | 16 ++++ src/fast_commands.py | 66 ++++++++++++- tools/email_digest.py | 133 +++++++++++++++++++++++++++ tools/email_forward.py | 178 ++++++++++++++++++++++++++++++++++++ tools/email_process.py | 4 +- 5 files changed, 394 insertions(+), 3 deletions(-) create mode 100644 tools/email_digest.py create mode 100644 tools/email_forward.py diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index 2539179..0797cba 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -464,6 +464,18 @@ def create_bot(config: Config) -> discord.Client: result = await asyncio.to_thread(fast_dispatch, "email", ["save"]) await interaction.followup.send(result) + @email_group.command(name="digest", description="Procesează emailuri necitite și trimite rezumate pe WhatsApp") + async def email_digest(interaction: discord.Interaction) -> None: + await interaction.response.defer() + result = await asyncio.to_thread(fast_dispatch, "email", ["digest"]) + await interaction.followup.send(result) + + @email_group.command(name="forward", description="Forwardează emailuri necitite direct pe WhatsApp fără rezumat") + async def email_forward(interaction: discord.Interaction) -> None: + await interaction.response.defer() + result = await asyncio.to_thread(fast_dispatch, "email", ["forward"]) + await interaction.followup.send(result) + tree.add_command(email_group) # --- Calendar commands --- @@ -878,6 +890,10 @@ def create_bot(config: Config) -> discord.Client: @client.event async def on_ready() -> None: + # Sync to each guild instantly, then global (global can take up to 1h) + for guild in client.guilds: + tree.copy_global_to(guild=guild) + await tree.sync(guild=guild) await tree.sync() scheduler = getattr(client, "scheduler", None) if scheduler is not None: diff --git a/src/fast_commands.py b/src/fast_commands.py index 9e3cdd7..3aa9045 100644 --- a/src/fast_commands.py +++ b/src/fast_commands.py @@ -106,7 +106,7 @@ def cmd_test(args: list[str]) -> str: # --------------------------------------------------------------------------- def cmd_email(args: list[str]) -> str: - """Dispatch: /email, /email send ..., /email save.""" + """Dispatch: /email, /email send ..., /email save, /email digest.""" if not args: return _email_check() sub = args[0].lower() @@ -114,7 +114,11 @@ def cmd_email(args: list[str]) -> str: return _email_send(args[1:]) if sub == "save": return _email_save() - return f"Unknown email sub-command: {sub}. Use: /email, /email send, /email save" + if sub == "digest": + return _email_digest() + if sub == "forward": + return _email_forward() + return f"Unknown email sub-command: {sub}. Use: /email, /email send, /email save, /email digest, /email forward" def _email_check() -> str: @@ -185,6 +189,62 @@ def _email_send(args: list[str]) -> str: return f"Email send error: {e}" +def _email_digest() -> str: + """Run email digest: save unread emails, generate summaries, send on WhatsApp.""" + script = TOOLS_DIR / "email_digest.py" + if not script.exists(): + return "email_digest.py not found." + + import sys as _sys + + def _run(): + try: + result = subprocess.run( + [_sys.executable, str(script)], + timeout=120, + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + ) + if result.returncode != 0: + log.error("Email digest failed: %s", result.stderr.strip()[:300]) + else: + log.info("Email digest: %s", result.stdout.strip()[:300]) + except Exception as e: + log.error("Email digest error: %s", e) + + threading.Thread(target=_run, daemon=True).start() + return "Procesez emailurile... rezumatele vin pe WhatsApp." + + +def _email_forward() -> str: + """Forward unread emails directly to WhatsApp without summarizing.""" + script = TOOLS_DIR / "email_forward.py" + if not script.exists(): + return "email_forward.py not found." + + import sys as _sys + + def _run(): + try: + result = subprocess.run( + [_sys.executable, str(script)], + timeout=60, + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + ) + if result.returncode != 0: + log.error("Email forward failed: %s", result.stderr.strip()[:300]) + else: + log.info("Email forward: %s", result.stdout.strip()[:300]) + except Exception as e: + log.error("Email forward error: %s", e) + + threading.Thread(target=_run, daemon=True).start() + return "Forwardez emailurile pe WhatsApp..." + + def _email_save() -> str: """Save unread emails as KB notes.""" script = TOOLS_DIR / "email_process.py" @@ -593,6 +653,8 @@ Email: /email — Check unread emails /email send :: — Send email /email save — Save unread emails to KB + /email digest — Rezumate emailuri necitite → WhatsApp + /email forward — Forward emailuri necitite direct → WhatsApp (fără rezumat) Calendar: /calendar — Today + tomorrow events diff --git a/tools/email_digest.py b/tools/email_digest.py new file mode 100644 index 0000000..eccde6b --- /dev/null +++ b/tools/email_digest.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Email digest: procesează emailuri necitite și trimite rezumate pe WhatsApp. + +Usage: + python3 tools/email_digest.py # Run digest + python3 tools/email_digest.py --dry-run # Afișează rezumatele fără a trimite +""" + +import sys +import subprocess +import requests +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.email_process import save_unread_emails +from src.config import Config + +BRIDGE_URL = "http://127.0.0.1:8098" +DRY_RUN = "--dry-run" in sys.argv + + +def get_owner_jid() -> str: + config = Config(PROJECT_ROOT / "config.json") + owner = config.get("whatsapp.owner", "") + return f"{owner}@s.whatsapp.net" + + +def generate_summary(filepath: str, subject: str, from_full: str, date: str) -> str: + """Citește conținutul emailului și generează rezumat via Claude CLI.""" + try: + email_content = Path(filepath).read_text(encoding="utf-8") + except Exception as e: + return f"[Eroare la citirea fișierului: {e}]" + + prompt = f"""Mai jos este conținutul unui email. Generează un rezumat pentru WhatsApp. + +EMAIL: +{email_content} + +Format obligatoriu (plain text, fără markdown): + +SUBIECT: {subject} +De la: {from_full} +Primit: {date} +--- +[Rezumat compact dar complet — include TOATE detaliile importante: + - ce se întâmplă / despre ce e vorba + - cine organizează / cine trimite + - când (date, deadline-uri, perioade) + - unde (locație dacă există) + - ce trebuie să facă cititorul (acțiune clară) + - condiții de participare (vârstă, criterii etc.) dacă există + - ce este inclus/oferit dacă e relevant + Fiecare punct pe linie separată. Fără redundanță, fără filler.] + +LINKURI: +[Listează TOATE linkurile acționabile: formulare, înscrieri, site-uri, documente, social media] +[Format: "Descriere scurtă: https://..." — câte un link per linie] +[Dacă nu există linkuri, omite secțiunea] + +[Semnătura expeditorului din email pe ultima linie după —] + +Fără emoji dacă nu sunt în original. Răspunde DOAR cu rezumatul, nimic altceva.""" + + result = subprocess.run( + ["claude", "--print", prompt], + capture_output=True, + text=True, + timeout=60, + stdin=subprocess.DEVNULL, + ) + + if result.returncode != 0: + return f"[Eroare la generarea rezumatului pentru: {subject}]\n{result.stderr.strip()[:200]}" + + return result.stdout.strip() + + +def send_whatsapp(to: str, text: str) -> bool: + """Trimite mesaj pe WhatsApp prin bridge.""" + try: + resp = requests.post( + f"{BRIDGE_URL}/send", + json={"to": to, "text": text}, + timeout=15, + ) + data = resp.json() + return data.get("ok", False) + except Exception as e: + print(f"[eroare send] {e}", file=sys.stderr) + return False + + +def run_digest(): + print("📬 Verific emailuri necitite...") + saved = save_unread_emails() + + if not saved: + print("Niciun email nou de procesat.") + return + + owner_jid = get_owner_jid() + + for result in saved: + if not result.get("ok"): + print(f"⚠️ Sărit: {result.get('error')}") + continue + + filepath = result["file"] + subject = result["subject"] + from_full = result.get("from_full", result.get("from", "")) + date = result.get("date", "") + print(f"📧 Procesez: {subject}") + + summary = generate_summary(filepath, subject, from_full, date) + + if DRY_RUN: + print("\n--- REZUMAT (dry-run) ---") + print(summary) + print("------------------------\n") + else: + ok = send_whatsapp(owner_jid, summary) + if ok: + print(f"✅ Trimis pe WhatsApp: {subject}") + else: + print(f"❌ Trimitere eșuată: {subject}") + + +if __name__ == "__main__": + run_digest() diff --git a/tools/email_forward.py b/tools/email_forward.py new file mode 100644 index 0000000..eec6c93 --- /dev/null +++ b/tools/email_forward.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Email forward: forwardează emailuri necitite direct pe WhatsApp fără rezumat. + +Usage: + python3 tools/email_forward.py # Run forward + python3 tools/email_forward.py --dry-run # Afișează fără a trimite +""" + +import sys +import re +import requests +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +from tools.email_process import ( + IMAP_SERVER, IMAP_PORT, IMAP_USER, IMAP_PASS, + WHITELIST, decode_mime_header, extract_sender_email, get_email_body +) +from src.config import Config +import imaplib +import email as email_lib + +BRIDGE_URL = "http://127.0.0.1:8098" +DRY_RUN = "--dry-run" in sys.argv +MAX_WA_LENGTH = 4096 + + +def get_owner_jid() -> str: + config = Config(PROJECT_ROOT / "config.json") + owner = config.get("whatsapp.owner", "") + return f"{owner}@s.whatsapp.net" + + +def extract_original_sender(body: str, from_full: str) -> tuple[str, str]: + """Dacă e email forwarded, extrage expeditorul și data originale.""" + # Caută header-ul tipic de forward: "De la: X\nDate: Y" sau "From: X\nDate: Y" + match = re.search( + r'(?:De la|From):\s*(.+?)\n(?:Date|Data):\s*(.+?)(?:\n|$)', + body, re.IGNORECASE + ) + if match: + return match.group(1).strip(), match.group(2).strip() + return from_full, "" + + +def format_for_whatsapp(subject: str, from_full: str, date: str, body: str) -> str: + """Curăță corpul emailului și îl formatează pentru WhatsApp.""" + # Extrage expeditorul original dacă e forward + original_from, original_date = extract_original_sender(body, from_full) + display_from = original_from + display_date = original_date or date + + # Elimină header-ul markdown din fișierul KB (# Titlu, **De la:**, **Data:**, **Salvat:**) + body = re.sub(r'^#.+\n?', '', body, flags=re.MULTILINE) + body = re.sub(r'^\*\*(?:De la|Data|Salvat):\*\*.+\n?', '', body, flags=re.MULTILINE) + # Elimină header-ul forwarded (---------- Forwarded message ---------) + body = re.sub(r'-{5,}\s*Forwarded message\s*-{5,}.*?\n', '', body, flags=re.IGNORECASE) + # Elimină liniile De la/From/Date/Subject/To din header-ul forwarded + body = re.sub(r'^(?:De la|From|Date|Data|Subject|To):.+\n?', '', body, flags=re.IGNORECASE | re.MULTILINE) + # Elimină comentariile KB () + body = re.sub(r'', '', body, flags=re.DOTALL) + # Elimină separatoarele --- rămase + body = re.sub(r'^\s*---\s*$', '', body, flags=re.MULTILINE) + # Elimină linii goale consecutive (mai mult de 2) + body = re.sub(r'\n{3,}', '\n\n', body) + # Elimină spații trailing + body = '\n'.join(line.rstrip() for line in body.splitlines()) + body = body.strip() + + header = f"*{subject}*\nDe la: {display_from}\nPrimit: {display_date}\n---\n" + full = header + body + + if len(full) <= MAX_WA_LENGTH: + return [full] + + # Împarte în bucăți la limita WhatsApp, tăind pe linie întreagă + parts = [] + remaining = full + while remaining: + if len(remaining) <= MAX_WA_LENGTH: + parts.append(remaining) + break + cut = remaining[:MAX_WA_LENGTH].rfind('\n') + # Dacă nu găsește newline sau e prea aproape de început, taie la limită + if cut < MAX_WA_LENGTH // 2: + cut = MAX_WA_LENGTH + parts.append(remaining[:cut]) + remaining = remaining[cut:].lstrip('\n') + + total = len(parts) + if total > 1: + parts = [f"{p}\n\n[{i+1}/{total}]" for i, p in enumerate(parts)] + + return parts + + +def send_whatsapp(to: str, text: str) -> bool: + try: + resp = requests.post( + f"{BRIDGE_URL}/send", + json={"to": to, "text": text}, + timeout=15, + ) + return resp.json().get("ok", False) + except Exception as e: + print(f"[eroare send] {e}", file=sys.stderr) + return False + + +def fetch_unread_emails(): + """Preia emailurile necitite din inbox fără a le salva sau marca ca citite.""" + mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT) + mail.login(IMAP_USER, IMAP_PASS) + mail.select('INBOX') + + status, messages = mail.search(None, 'UNSEEN') + email_ids = messages[0].split() if messages[0] else [] + results = [] + + for eid in email_ids: + # BODY.PEEK nu marchează ca citit + status, data = mail.fetch(eid, "(BODY.PEEK[])") + if status != "OK": + continue + msg = email_lib.message_from_bytes(data[0][1]) + + from_addr = decode_mime_header(msg['From']) + sender_email = extract_sender_email(from_addr) + + if sender_email not in WHITELIST: + continue + + results.append({ + 'subject': decode_mime_header(msg['Subject']), + 'from_full': from_addr, + 'date': msg['Date'], + 'body': get_email_body(msg), + }) + + mail.logout() + return results + + +def run_forward(): + print("Verific emailuri necitite...") + emails = fetch_unread_emails() + + if not emails: + print("Niciun email nou de procesat.") + return + + owner_jid = get_owner_jid() + + for em in emails: + subject = em['subject'] + print(f"Trimit: {subject}") + parts = format_for_whatsapp(subject, em['from_full'], em['date'], em['body']) + + if DRY_RUN: + for i, part in enumerate(parts): + print(f"\n--- FORWARD {i+1}/{len(parts)} (dry-run) ---") + print(part) + print("------------------------\n") + else: + for part in parts: + ok = send_whatsapp(owner_jid, part) + if not ok: + print(f"Trimitere esuata: {subject}") + break + else: + print(f"Trimis pe WhatsApp ({len(parts)} mesaje): {subject}") + + +if __name__ == "__main__": + run_forward() diff --git a/tools/email_process.py b/tools/email_process.py index 46a2e13..b0e72ab 100755 --- a/tools/email_process.py +++ b/tools/email_process.py @@ -193,7 +193,9 @@ def save_email_as_note(eid: str) -> dict: 'ok': True, 'file': str(filepath), 'subject': subject, - 'from': sender_email + 'from': sender_email, + 'from_full': from_addr, + 'date': date_str, } def save_unread_emails():