973 lines
36 KiB
Python
973 lines
36 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate a daily briefing markdown file and print the ObsiGate link.
|
|
|
|
Usage:
|
|
python3 generate_briefing.py # today's briefing
|
|
python3 generate_briefing.py 20260520 # specific date (YYYYMMDD)
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from urllib.request import urlopen, Request
|
|
from urllib.error import URLError
|
|
|
|
# ----------------------------------------------------------------
|
|
# Config
|
|
# ----------------------------------------------------------------
|
|
HERMES_HOME = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes")))
|
|
GAPI = HERMES_HOME / "skills/productivity/google-workspace/scripts/google_api.py"
|
|
VAULT_BASE = Path("/NFS/OBSIDIAN_DOC/Obsidian_IT")
|
|
BRIEFING_DIR = VAULT_BASE / "05_Journal_Quotidien/5.1_Quotidien/HermesBerriPi/briefing-matin"
|
|
OBSIGATE_BASE = "https://og.dracodev.net/popout/IT"
|
|
|
|
# Beloeil, QC
|
|
LAT, LON = "45.5667", "-73.2"
|
|
|
|
# Calendar emoji map (by summary keyword)
|
|
CAL_EMOJI = {
|
|
"famille": "👪",
|
|
"travail": "💼",
|
|
"desjardins": "💼",
|
|
"finances": "💰",
|
|
"paie": "💰",
|
|
}
|
|
|
|
# Email action prefixes (from GitHub notification subjects)
|
|
ACTION_PREFIXES = {
|
|
"PR #": "🔀",
|
|
"Issue #": "💬",
|
|
"Merged #": "✅",
|
|
"pushé": "📌",
|
|
"commit": "📌",
|
|
}
|
|
|
|
# Weather WMO codes
|
|
WMO_EMOJI = {
|
|
0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️",
|
|
45: "🌫️", 48: "🌫️",
|
|
51: "🌧️", 53: "🌧️", 55: "🌧️",
|
|
61: "🌧️", 63: "🌧️", 65: "🌧️",
|
|
71: "❄️", 73: "❄️", 75: "❄️",
|
|
80: "🌧️", 81: "🌧️", 82: "🌧️",
|
|
85: "❄️", 86: "❄️",
|
|
95: "⛈️", 96: "⛈️", 99: "⛈️",
|
|
}
|
|
WMO_LABELS = {
|
|
0: "Ciel clair", 1: "Peu nuageux", 2: "Partiellement nuageux",
|
|
3: "Nuageux", 45: "Brouillard", 48: "Brouillard givrant",
|
|
51: "Bruine légère", 53: "Bruine modérée", 55: "Bruine dense",
|
|
61: "Pluie légère", 63: "Pluie modérée", 65: "Pluie forte",
|
|
71: "Neige légère", 73: "Neige modérée", 75: "Neige forte",
|
|
80: "Averses légères", 81: "Averses modérées", 82: "Averses violentes",
|
|
85: "Neige légère", 86: "Neige forte",
|
|
95: "Orage", 96: "Orage + grêle légère", 99: "Orage + grêle forte",
|
|
}
|
|
|
|
|
|
def shell(cmd, **kwargs):
|
|
"""Run a shell command and return stdout, exit_code."""
|
|
result = subprocess.run(
|
|
cmd, shell=True, capture_output=True, text=True,
|
|
timeout=kwargs.get("timeout", 30)
|
|
)
|
|
return result.stdout.strip(), result.returncode
|
|
|
|
|
|
# ================================================================
|
|
# Section generators
|
|
# ================================================================
|
|
|
|
def get_date(date_id=None):
|
|
"""Return (date_id_yyyymmdd, day_fr, date_fr, jour_complet)."""
|
|
if date_id:
|
|
dt = datetime.strptime(date_id, "%Y%m%d")
|
|
else:
|
|
dt = datetime.now()
|
|
|
|
jours_fr = ["lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche"]
|
|
mois_fr = ["janvier", "février", "mars", "avril", "mai", "juin",
|
|
"juillet", "août", "septembre", "octobre", "novembre", "décembre"]
|
|
|
|
date_id_str = dt.strftime("%Y%m%d")
|
|
jour = jours_fr[dt.weekday()]
|
|
date_str = f"{jour} {dt.day} {mois_fr[dt.month-1]} {dt.year}"
|
|
return date_id_str, jour.capitalize(), date_str, f"{jour.capitalize()} {dt.day} {mois_fr[dt.month-1]} {dt.year}"
|
|
|
|
|
|
def get_citation():
|
|
"""Fetch random quote from zenquotes.io."""
|
|
try:
|
|
out, _ = shell("curl -s --max-time 5 'https://zenquotes.io/api/random'")
|
|
data = json.loads(out)
|
|
if data and isinstance(data, list):
|
|
q = data[0]["q"]
|
|
a = data[0]["a"]
|
|
return q, a
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback
|
|
return (
|
|
"La meilleure façon de prédire l'avenir est de le créer.",
|
|
"Peter Drucker"
|
|
)
|
|
|
|
|
|
def get_emails(max_count=5):
|
|
"""Fetch unread emails and format as markdown table rows."""
|
|
try:
|
|
out, _ = shell(
|
|
f"cd {GAPI.parent} && python3 {GAPI.name} gmail search 'is:unread' --max {max_count} 2>&1"
|
|
)
|
|
data = json.loads(out)
|
|
if not data:
|
|
return None, 0
|
|
|
|
rows = []
|
|
for i, email in enumerate(data, 1):
|
|
sender = email.get("from", "Inconnu")
|
|
# Clean sender: remove email address, keep name
|
|
if "<" in sender:
|
|
sender = sender.split("<")[0].strip().strip('"')
|
|
|
|
subject = email.get("subject", "(Sans objet)")
|
|
snippet = email.get("snippet", "")[:100]
|
|
|
|
# Action prefix
|
|
prefix = ""
|
|
subj_lower = subject.lower()
|
|
snippet_lower = snippet.lower()
|
|
if "pr #" in subj_lower:
|
|
if "merged" in snippet_lower:
|
|
prefix = "✅ "
|
|
else:
|
|
prefix = "🔀 "
|
|
elif "issue #" in subj_lower:
|
|
prefix = "💬 "
|
|
elif "push" in snippet_lower or "commit" in snippet_lower:
|
|
prefix = "📌 "
|
|
|
|
rows.append(f"| {i} | **{sender[:30]}** | `{subject[:50]}` | {prefix}{snippet[:80]} |")
|
|
|
|
return rows, len(data)
|
|
except Exception as e:
|
|
return None, 0
|
|
|
|
|
|
def get_agenda(date_id_str):
|
|
"""Fetch today's calendar events from ALL calendars."""
|
|
try:
|
|
# Use the dedicated script
|
|
script = HERMES_HOME / "skills/productivity/briefing-quotidien/scripts/agenda_all_calendars.py"
|
|
if script.exists():
|
|
out, _ = shell(f"python3 {script} {date_id_str} 2>&1")
|
|
else:
|
|
# Fallback: use google_api.py with primary + common calendars
|
|
out, _ = shell(
|
|
f"cd {GAPI.parent} && python3 {GAPI.name} calendar list --max 20 2>&1"
|
|
)
|
|
|
|
data = json.loads(out)
|
|
if not data:
|
|
return None, 0
|
|
|
|
# Filter today's events (date_id_str = YYYYMMDD)
|
|
today_events = []
|
|
for ev in data:
|
|
start = ev.get("start", "")
|
|
if date_id_str in start.replace("-", ""):
|
|
today_events.append(ev)
|
|
|
|
if not today_events:
|
|
return None, 0
|
|
|
|
rows = []
|
|
for ev in today_events:
|
|
start = ev.get("start", "")
|
|
summary = ev.get("summary", "(Sans titre)")
|
|
|
|
# Format time
|
|
if "T" in start:
|
|
time_part = start.split("T")[1][:5]
|
|
time_str = f"🕐 **{time_part}**"
|
|
else:
|
|
time_str = "📆 Journée"
|
|
|
|
# Determine category emoji
|
|
summary_lower = summary.lower()
|
|
cat_emoji = "📅" # default
|
|
for keyword, emoji in CAL_EMOJI.items():
|
|
if keyword in summary_lower:
|
|
cat_emoji = emoji
|
|
break
|
|
|
|
# Also check calendar name if available
|
|
cal_name = ev.get("calendar", "").lower()
|
|
for keyword, emoji in CAL_EMOJI.items():
|
|
if keyword in cal_name:
|
|
cat_emoji = emoji
|
|
break
|
|
|
|
rows.append(f"| {time_str} | {summary} | {cat_emoji} |")
|
|
|
|
return rows, len(today_events)
|
|
except Exception as e:
|
|
# If agenda_all_calendars.py doesn't exist, try direct approach
|
|
return None, 0
|
|
|
|
|
|
def get_meteo():
|
|
"""Fetch weather for Beloeil from Open-Meteo."""
|
|
try:
|
|
out, _ = shell(
|
|
f"curl -s --max-time 5 "
|
|
f"'https://api.open-meteo.com/v1/forecast?"
|
|
f"latitude={LAT}&longitude={LON}"
|
|
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m,weather_code"
|
|
f"&daily=temperature_2m_max,temperature_2m_min"
|
|
f"&timezone=America%2FToronto&forecast_days=3'"
|
|
)
|
|
data = json.loads(out)
|
|
|
|
current = data.get("current", {})
|
|
daily = data.get("daily", {})
|
|
|
|
wmo = current.get("weather_code", 0)
|
|
emoji = WMO_EMOJI.get(wmo, "❓")
|
|
label = WMO_LABELS.get(wmo, "Inconnu")
|
|
temp = current.get("temperature_2m", "?")
|
|
feels = current.get("apparent_temperature", "?")
|
|
wind = current.get("wind_speed_10m", "?")
|
|
hum = current.get("relative_humidity_2m", "?")
|
|
|
|
# Current table
|
|
current_row = f"| **Aujourd'hui** | {emoji} {label} | **{temp}°C** | {feels}°C | {wind} km/h | {hum}% |"
|
|
|
|
# Forecast table
|
|
jours = ["lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim."]
|
|
forecast_rows = []
|
|
for i in range(1, min(3, len(daily.get("time", [])))):
|
|
date_str = daily["time"][i]
|
|
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
|
jour = jours[dt.weekday()]
|
|
tmax = daily["temperature_2m_max"][i]
|
|
tmin = daily["temperature_2m_min"][i]
|
|
label_jour = "Demain" if i == 1 else "Après-demain"
|
|
forecast_rows.append(f"| 📅 **{label_jour}** ({jour}) | {tmin}°C | {tmax}°C |")
|
|
|
|
return current_row, forecast_rows
|
|
except Exception as e:
|
|
return None, None
|
|
|
|
|
|
def get_system():
|
|
"""Check Hermes services and system resources."""
|
|
# Services
|
|
services = ["hermes-gateway", "hermes-dashboard", "hermes-workspace"]
|
|
svc_rows = []
|
|
svc_ok = 0
|
|
for svc in services:
|
|
out, rc = shell(f"systemctl --user is-active {svc} 2>/dev/null")
|
|
if out == "active":
|
|
svc_rows.append(f"| {svc.replace('hermes-', '').capitalize()} | 🟢 |")
|
|
svc_ok += 1
|
|
else:
|
|
svc_rows.append(f"| {svc.replace('hermes-', '').capitalize()} | 🔴 |")
|
|
|
|
svc_status = f"🟢 {svc_ok}/3 en ligne" if svc_ok == 3 else f"⚠️ {svc_ok}/3 en ligne"
|
|
|
|
# Resources
|
|
try:
|
|
# CPU
|
|
out, _ = shell("top -bn1 | grep 'Cpu(s)' | awk '{print $2}'")
|
|
cpu = out.replace("%", "").strip() if out else "?"
|
|
cpu = f"{cpu}% `us`" if cpu and cpu != "?" else "?"
|
|
|
|
# RAM
|
|
out, _ = shell("free -m | awk 'NR==2{printf \"%.0f\", $3*100/$2}'")
|
|
ram = f"{out}%" if out else "?"
|
|
|
|
# Disk
|
|
out, _ = shell("df -h / | awk 'NR==2{print $5}' | sed 's/%//'")
|
|
disk = f"{out}%" if out else "?"
|
|
|
|
# Uptime
|
|
out, _ = shell("uptime -p | sed 's/up //'")
|
|
uptime = out if out else "?"
|
|
except Exception:
|
|
cpu = ram = disk = uptime = "?"
|
|
|
|
return svc_status, svc_rows, cpu, ram, disk, uptime
|
|
|
|
|
|
def get_docker_services():
|
|
"""List running Docker containers with status and uptime."""
|
|
try:
|
|
out, rc = shell("docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}' 2>/dev/null")
|
|
if rc != 0 or not out:
|
|
return None, 0
|
|
|
|
containers = []
|
|
for line in out.split("\n"):
|
|
parts = line.split("\t")
|
|
if len(parts) >= 2:
|
|
name = parts[0]
|
|
status = parts[1]
|
|
# Parse status: "Up 2 hours" or "Up 3 days (healthy)"
|
|
is_healthy = "(healthy)" in status or "(unhealthy)" not in status
|
|
is_up = status.startswith("Up")
|
|
emoji = "🟢" if (is_up and is_healthy) else ("🟡" if is_up else "🔴")
|
|
containers.append((name, emoji, status))
|
|
|
|
return containers, len(containers)
|
|
except Exception:
|
|
return None, 0
|
|
|
|
|
|
def get_docker_stats():
|
|
"""Get resource usage stats for Docker containers."""
|
|
try:
|
|
out, rc = shell(
|
|
"docker stats --no-stream --format "
|
|
"'{{.Name}}\t{{.CPUPerc}}\t{{.MemPerc}}\t{{.MemUsage}}\t{{.NetIO}}' 2>/dev/null"
|
|
)
|
|
if rc != 0 or not out:
|
|
return None
|
|
|
|
stats = []
|
|
for line in out.split("\n"):
|
|
parts = line.split("\t")
|
|
if len(parts) >= 4:
|
|
# Clean up: remove spaces in percentages, trim
|
|
name = parts[0]
|
|
cpu = parts[1].strip()
|
|
mem_pct = parts[2].strip()
|
|
# NetIO: "1.2MB / 3.4MB" → take IN only
|
|
net = parts[4].strip() if len(parts) >= 5 else "—"
|
|
net_in = net.split(" / ")[0] if " / " in net else net
|
|
stats.append((name, cpu, mem_pct, net_in))
|
|
|
|
return stats
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_honcho_stats():
|
|
"""Query Honcho API for workspace statistics."""
|
|
try:
|
|
import urllib.request
|
|
|
|
BASE = "http://localhost:8000/v3"
|
|
WS = "hermes"
|
|
|
|
def post_json(path, data=None):
|
|
body = json.dumps(data or {}).encode()
|
|
req = urllib.request.Request(f"{BASE}{path}", data=body, method="POST",
|
|
headers={"Content-Type": "application/json"})
|
|
resp = urllib.request.urlopen(req, timeout=3)
|
|
return json.loads(resp.read())
|
|
|
|
# Health check
|
|
try:
|
|
req = urllib.request.urlopen("http://localhost:8000/health", timeout=3)
|
|
health = json.loads(req.read())
|
|
api_ok = health.get("status") == "ok"
|
|
except Exception:
|
|
api_ok = False
|
|
|
|
# Peers
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/peers/list")
|
|
items = data.get("items", data)
|
|
nb_peers = len(items) if isinstance(items, list) else "?"
|
|
except Exception:
|
|
nb_peers = "?"
|
|
|
|
# Sessions
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/sessions/list")
|
|
items = data.get("items", data)
|
|
nb_sessions = len(items) if isinstance(items, list) else "?"
|
|
except Exception:
|
|
nb_sessions = "?"
|
|
|
|
# Conclusions (observations)
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/conclusions/list")
|
|
items = data.get("items", data)
|
|
nb_conclusions = len(items) if isinstance(items, list) else "?"
|
|
except Exception:
|
|
nb_conclusions = "?"
|
|
|
|
return {
|
|
"api": api_ok,
|
|
"peers": nb_peers,
|
|
"sessions": nb_sessions,
|
|
"conclusions": nb_conclusions,
|
|
}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_priorities():
|
|
"""Read priorities file if it exists."""
|
|
priorities_file = Path.home() / ".hermes" / "priorities.txt"
|
|
if priorities_file.exists():
|
|
return priorities_file.read_text().strip()
|
|
return None
|
|
|
|
|
|
def get_news_freshrss():
|
|
"""Collect news from FreshRSS via Fever API, grouped by FreshRSS categories.
|
|
Returns dict: {category_name: [(title, url, source, cat_emoji), ...]}
|
|
Falls back to None if FreshRSS is unavailable."""
|
|
try:
|
|
FRESHRSS_API = "http://localhost:8088/api/fever.php"
|
|
API_KEY = "c2cc2566601a7b5c6f28fcc8268786d4" # md5("admin:freshrss2026")
|
|
|
|
# 1. Get all unread item IDs
|
|
data = f"api&email=admin&api_key={API_KEY}&unread_item_ids".encode()
|
|
req = Request(FRESHRSS_API, data=data)
|
|
with urlopen(req, timeout=10) as resp:
|
|
result = json.loads(resp.read())
|
|
unread_ids = result.get("unread_item_ids", "")
|
|
if not unread_ids:
|
|
return None
|
|
all_ids = unread_ids.split(",")
|
|
|
|
# Sample evenly across the full ID range to hit all feeds/categories.
|
|
# FreshRSS returns IDs sorted by date (newest first), so taking only
|
|
# the first N would capture only the most active feed (usually International).
|
|
# By sampling every Nth ID we span the full time range and get all categories.
|
|
MAX_SAMPLE = 200
|
|
step = max(1, len(all_ids) // MAX_SAMPLE)
|
|
sampled_ids = all_ids[::step][:MAX_SAMPLE]
|
|
|
|
# 2. Get feeds and groups first (to know which feeds exist)
|
|
data = f"api&email=admin&api_key={API_KEY}&feeds&groups".encode()
|
|
req = Request(FRESHRSS_API, data=data)
|
|
with urlopen(req, timeout=10) as resp:
|
|
result = json.loads(resp.read())
|
|
|
|
feeds = {f["id"]: f for f in result.get("feeds", [])}
|
|
groups = {g["id"]: g["title"] for g in result.get("groups", [])}
|
|
feeds_groups = {}
|
|
for fg in result.get("feeds_groups", []):
|
|
for fid in fg["feed_ids"].split(","):
|
|
fid_int = int(fid) if fid.isdigit() else fid
|
|
feeds_groups[fid_int] = fg["group_id"]
|
|
|
|
# 3. Get items for sampled IDs
|
|
data = f"api&email=admin&api_key={API_KEY}&items&with_ids={','.join(sampled_ids)}".encode()
|
|
req = Request(FRESHRSS_API, data=data)
|
|
with urlopen(req, timeout=10) as resp:
|
|
result = json.loads(resp.read())
|
|
items = result.get("items", [])
|
|
|
|
# Category emoji mapping
|
|
CAT_EMOJI = {
|
|
"International": "🌍",
|
|
"Regional": "🇨🇦",
|
|
"Sciences": "🔬",
|
|
"Technologies": "💻",
|
|
"Développement": "⚙️",
|
|
"Intelligence Artificielle": "🤖",
|
|
}
|
|
MAX_PER_CATEGORY = 5 # Keep briefing concise but diverse
|
|
|
|
# Group items by category
|
|
cat_items = {}
|
|
for item in items:
|
|
fid = item.get("feed_id")
|
|
if fid not in feeds:
|
|
continue
|
|
feed = feeds[fid]
|
|
gid = feeds_groups.get(fid, 1)
|
|
cat_name = groups.get(gid, "Sans catégorie")
|
|
if cat_name == "Sans catégorie":
|
|
continue # Skip uncategorized items
|
|
emoji = CAT_EMOJI.get(cat_name, "📰")
|
|
|
|
if cat_name not in cat_items:
|
|
cat_items[cat_name] = []
|
|
if len(cat_items[cat_name]) >= MAX_PER_CATEGORY:
|
|
continue # Limit per category
|
|
|
|
cat_items[cat_name].append((
|
|
item.get("title", "")[:120],
|
|
item.get("html_url", item.get("url", "")),
|
|
feed.get("title", ""),
|
|
emoji,
|
|
))
|
|
|
|
return cat_items if cat_items else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_news_rss():
|
|
"""Collect tech/AI news from RSS feeds (fallback when FreshRSS is unavailable).
|
|
Returns list of (title, url, source, category)."""
|
|
FEEDS = [
|
|
("https://feeds.arstechnica.com/arstechnica/index", "Ars Technica", "🔬"),
|
|
("https://www.theverge.com/rss/index.xml", "The Verge", "💻"),
|
|
("https://news.mit.edu/topic/mitartificial-intelligence2-rss.xml", "MIT News", "🔬"),
|
|
("https://techcrunch.com/feed/", "TechCrunch", "🏛️"),
|
|
("https://www.wired.com/feed/category/security/latest/rss", "Wired Security", "🛡️"),
|
|
]
|
|
|
|
items = []
|
|
for url, source, default_cat in FEEDS:
|
|
try:
|
|
req = Request(url, headers={"User-Agent": "HermesBerryPi/1.0"})
|
|
with urlopen(req, timeout=10) as resp:
|
|
tree = ET.fromstring(resp.read())
|
|
|
|
# RSS 2.0
|
|
for item_elem in tree.iter("item"):
|
|
title = item_elem.find("title")
|
|
link = item_elem.find("link")
|
|
title_text = title.text.strip() if title is not None and title.text else ""
|
|
link_text = link.text.strip() if link is not None and link.text else ""
|
|
if title_text and link_text:
|
|
# Catégorie basée sur mots-clés dans le titre
|
|
title_lower = title_text.lower()
|
|
if any(w in title_lower for w in ["ai ", "gpt", "llm", "model", "machine learning", "deep learning", "neural"]):
|
|
cat = "🔬"
|
|
elif any(w in title_lower for w in ["security", "hack", "vuln", "cyber", "breach", "ransom"]):
|
|
cat = "🛡️"
|
|
elif any(w in title_lower for w in ["lawsuit", "regulation", "eu ", "ftc", "privacy", "copyright"]):
|
|
cat = "🏛️"
|
|
else:
|
|
cat = default_cat
|
|
items.append((title_text[:120], link_text, source, cat))
|
|
|
|
# Atom feed
|
|
if not items or all(i[2] != source for i in items[-5:]):
|
|
for entry in tree.iter("{http://www.w3.org/2005/Atom}entry"):
|
|
title = entry.find("{http://www.w3.org/2005/Atom}title")
|
|
link = entry.find("{http://www.w3.org/2005/Atom}link")
|
|
title_text = title.text.strip() if title is not None and title.text else ""
|
|
link_text = link.get("href", "").strip() if link is not None else ""
|
|
if title_text and link_text:
|
|
title_lower = title_text.lower()
|
|
if any(w in title_lower for w in ["ai ", "gpt", "llm", "model"]):
|
|
cat = "🔬"
|
|
elif any(w in title_lower for w in ["security", "hack", "vuln"]):
|
|
cat = "🛡️"
|
|
else:
|
|
cat = default_cat
|
|
items.append((title_text[:120], link_text, source, cat))
|
|
except Exception:
|
|
continue
|
|
|
|
# Dédupliquer par titre (similarité simple)
|
|
seen = set()
|
|
unique = []
|
|
for title, url, source, cat in items:
|
|
key = title[:50].lower()
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique.append((title, url, source, cat))
|
|
|
|
return unique[:10] # Max 10 articles
|
|
|
|
|
|
def get_honcho_advanced():
|
|
"""Query Honcho API for detailed workspace statistics."""
|
|
try:
|
|
import urllib.request as ur
|
|
BASE = "http://localhost:8000/v3"
|
|
WS = "hermes"
|
|
|
|
def post_json(path, data=None):
|
|
body = json.dumps(data or {}).encode()
|
|
req = ur.Request(f"{BASE}{path}", data=body, method="POST",
|
|
headers={"Content-Type": "application/json"})
|
|
resp = ur.urlopen(req, timeout=5)
|
|
return json.loads(resp.read())
|
|
|
|
stats = {}
|
|
|
|
# Health
|
|
try:
|
|
req = ur.urlopen("http://localhost:8000/health", timeout=3)
|
|
health = json.loads(req.read())
|
|
stats["api"] = health.get("status") == "ok"
|
|
ver = health.get("version", "")
|
|
if not ver:
|
|
# Try /v3/workspaces/{WS} for version info
|
|
try:
|
|
ws_data = post_json(f"/workspaces/{WS}")
|
|
ver = ws_data.get("version", "")
|
|
except Exception:
|
|
pass
|
|
stats["version"] = ver if ver else "?"
|
|
except Exception:
|
|
stats["api"] = False
|
|
stats["version"] = "?"
|
|
|
|
# Peers
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/peers/list")
|
|
items = data.get("items", data)
|
|
stats["peers"] = len(items) if isinstance(items, list) else "?"
|
|
except Exception:
|
|
stats["peers"] = "?"
|
|
|
|
# Sessions
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/sessions/list")
|
|
items = data.get("items", data)
|
|
stats["sessions"] = len(items) if isinstance(items, list) else "?"
|
|
|
|
# Sessions actives (7 derniers jours)
|
|
cutoff = (datetime.now() - timedelta(days=7)).isoformat()
|
|
recent = 0
|
|
if isinstance(items, list):
|
|
for s in items:
|
|
created = s.get("created_at", s.get("created", ""))
|
|
if created and created > cutoff:
|
|
recent += 1
|
|
stats["sessions_recent"] = recent
|
|
except Exception:
|
|
stats["sessions"] = "?"
|
|
stats["sessions_recent"] = "?"
|
|
|
|
# Conclusions
|
|
try:
|
|
data = post_json(f"/workspaces/{WS}/conclusions/list")
|
|
items = data.get("items", data)
|
|
stats["conclusions"] = len(items) if isinstance(items, list) else "?"
|
|
except Exception:
|
|
stats["conclusions"] = "?"
|
|
|
|
# DB size
|
|
try:
|
|
out, _ = shell("du -sh /home/bruno/workspace/honcho/ 2>/dev/null | cut -f1")
|
|
stats["db_size"] = out if out else "?"
|
|
except Exception:
|
|
stats["db_size"] = "?"
|
|
|
|
return stats
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def get_system_advanced():
|
|
"""Get advanced system metrics (temperature, load, I/O)."""
|
|
metrics = {}
|
|
|
|
# Température CPU (Raspberry Pi)
|
|
try:
|
|
out, rc = shell("vcgencmd measure_temp 2>/dev/null")
|
|
if rc == 0 and out:
|
|
metrics["cpu_temp"] = out.replace("temp=", "").replace("'C", "°C")
|
|
else:
|
|
metrics["cpu_temp"] = "?"
|
|
except Exception:
|
|
metrics["cpu_temp"] = "?"
|
|
|
|
# Load average
|
|
try:
|
|
out, rc = shell("cat /proc/loadavg 2>/dev/null | awk '{print $1, $2, $3}'")
|
|
metrics["load"] = out if rc == 0 and out else "?"
|
|
except Exception:
|
|
metrics["load"] = "?"
|
|
|
|
# Disk I/O
|
|
try:
|
|
out, rc = shell("iostat -d sda 2>/dev/null | awk 'NR==4{print $3, $4}' || iostat -d mmcblk0 2>/dev/null | awk 'NR==4{print $3, $4}'")
|
|
if rc == 0 and out:
|
|
metrics["disk_io"] = out
|
|
else:
|
|
# Fallback: /proc/diskstats
|
|
out2, _ = shell("awk '{if($3==\"mmcblk0\"||$3==\"sda\") printf \"%.1f MB/s\", $7*512/1024/1024}' /proc/diskstats 2>/dev/null")
|
|
metrics["disk_io"] = out2 if out2 else "?"
|
|
except Exception:
|
|
metrics["disk_io"] = "?"
|
|
|
|
# Swap
|
|
try:
|
|
out, rc = shell("free -m | awk 'NR==3{printf \"%.0f/%.0fMB\", $3, $2}'")
|
|
metrics["swap"] = out if rc == 0 and out else "?"
|
|
except Exception:
|
|
metrics["swap"] = "?"
|
|
|
|
# Uptime
|
|
try:
|
|
out, rc = shell("uptime -p | sed 's/up //'")
|
|
metrics["uptime"] = out if rc == 0 and out else "?"
|
|
except Exception:
|
|
metrics["uptime"] = "?"
|
|
|
|
return metrics
|
|
|
|
|
|
def get_docker_advanced():
|
|
"""Get detailed Docker container info with ports and health."""
|
|
try:
|
|
out, rc = shell("docker ps --format '{{.Names}}\\t{{.Status}}\\t{{.Image}}\\t{{.Ports}}' 2>/dev/null")
|
|
if rc != 0 or not out:
|
|
return None, 0
|
|
|
|
containers = []
|
|
for line in out.split("\n"):
|
|
parts = line.split("\t")
|
|
if len(parts) >= 2:
|
|
name = parts[0]
|
|
status = parts[1]
|
|
image = parts[2] if len(parts) > 2 else "?"
|
|
ports = parts[3] if len(parts) > 3 else "—"
|
|
|
|
# Health status
|
|
is_healthy = "(healthy)" in status
|
|
is_unhealthy = "(unhealthy)" in status
|
|
is_up = status.startswith("Up")
|
|
|
|
if is_up and is_healthy:
|
|
emoji = "🟢"
|
|
elif is_up and is_unhealthy:
|
|
emoji = "🟡"
|
|
elif is_up:
|
|
emoji = "🟢" # No healthcheck = assume OK
|
|
else:
|
|
emoji = "🔴"
|
|
|
|
# Clean ports display
|
|
if ports and ports != "—" and len(ports) > 20:
|
|
# Take first port mapping only
|
|
ports = ports.split(",")[0].strip()
|
|
|
|
containers.append((name, emoji, status, image, ports))
|
|
|
|
return containers, len(containers)
|
|
except Exception:
|
|
return None, 0
|
|
|
|
|
|
def build_markdown(date_id_str, jour, date_complete):
|
|
"""Build the complete briefing markdown."""
|
|
|
|
parts = []
|
|
|
|
# ---- Header ----
|
|
parts.append(f"# 📅 Briefing Matinal\n")
|
|
parts.append(f"> **{date_complete}** · 🕖 07:00 · `America/Toronto`\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Citation ----
|
|
quote, author = get_citation()
|
|
parts.append(f"# 💡 Motivation du jour\n")
|
|
parts.append(f"> *\"{quote}\"*\n")
|
|
parts.append(f"> — **{author}**\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Emails ----
|
|
parts.append(f"# 📧 Emails récents\n")
|
|
emails, nb_emails = get_emails()
|
|
if emails:
|
|
parts.append(f"| # | Expéditeur | Sujet | Résumé |")
|
|
parts.append(f"|:-:|:-----------|:------|:-------|")
|
|
parts.extend(emails)
|
|
parts.append("")
|
|
else:
|
|
parts.append("📭 Aucun email non lu.\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Agenda ----
|
|
parts.append(f"# 📅 Agenda du jour\n")
|
|
agenda, nb_events = get_agenda(date_id_str)
|
|
if agenda:
|
|
parts.append(f"| Heure | Événement | Catégorie |")
|
|
parts.append(f"|:-----:|:----------|:---------:|")
|
|
parts.extend(agenda)
|
|
parts.append("")
|
|
else:
|
|
parts.append("📅 Aucun événement aujourd'hui.\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Météo ----
|
|
parts.append(f"# 🌤️ Météo — Beloeil\n")
|
|
current_row, forecast_rows = get_meteo()
|
|
if current_row:
|
|
parts.append(f"| | Condition | Temp. | Ressenti | Vent | Humidité |")
|
|
parts.append(f"|:-:|:---------:|:-----:|:--------:|:----:|:--------:|")
|
|
parts.append(current_row)
|
|
parts.append("")
|
|
|
|
if forecast_rows:
|
|
parts.append(f"| | 🌡️ Min | 🌡️ Max |")
|
|
parts.append(f"|:-:|:------:|:------:|")
|
|
parts.extend(forecast_rows)
|
|
parts.append("")
|
|
else:
|
|
parts.append("🌤️ Météo non disponible.\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- System ----
|
|
parts.append(f"# 🖥️ HermesBerryPi\n")
|
|
svc_status, svc_rows, cpu, ram, disk, uptime = get_system()
|
|
sys_adv = get_system_advanced()
|
|
parts.append(f"Services — {svc_status}\n")
|
|
parts.append(f"| Service | Statut |")
|
|
parts.append(f"|:--------|:------:|")
|
|
parts.extend(svc_rows)
|
|
parts.append("")
|
|
parts.append(f"Ressources système\n")
|
|
parts.append(f"| CPU | RAM | Disque | Swap | Uptime |")
|
|
parts.append(f"|:---:|:---:|:------:|:----:|:------:|")
|
|
parts.append(f"| {cpu} | {ram} | {disk} | {sys_adv.get('swap', '?')} | {sys_adv.get('uptime', uptime)} |")
|
|
parts.append("")
|
|
parts.append(f"| 🌡️ Temp. CPU | 📊 Load (1/5/15) | 💾 I/O Disque |")
|
|
parts.append(f"|:-------------:|:-----------------:|:-------------:|")
|
|
parts.append(f"| {sys_adv.get('cpu_temp', '?')} | {sys_adv.get('load', '?')} | {sys_adv.get('disk_io', '?')} |")
|
|
parts.append("")
|
|
parts.append("---\n")
|
|
|
|
# ---- Docker ----
|
|
parts.append(f"# 🐳 Docker\n")
|
|
containers, nb_containers = get_docker_advanced()
|
|
if containers:
|
|
stats = get_docker_stats() or []
|
|
stat_map = {s[0]: s for s in stats}
|
|
|
|
up_count = sum(1 for _, e, _, _, _ in containers if e == "🟢")
|
|
parts.append(f"Services — 🟢 {up_count}/{nb_containers} conteneurs actifs\n")
|
|
parts.append(f"| Conteneur | Image | Statut | CPU | RAM | Réseau IN | Ports |")
|
|
parts.append(f"|:----------|:------|:------:|:---:|:---:|:----------:|:------|")
|
|
for name, emoji, status, image, ports in containers:
|
|
cpu = mem = net = "—"
|
|
if name in stat_map:
|
|
_, cpu, mem, net = stat_map[name]
|
|
# Shorten image name
|
|
img_short = image.split("/")[-1] if "/" in image else image
|
|
if ":" in img_short:
|
|
img_short = img_short.split(":")[0]
|
|
parts.append(f"| {name} | {img_short} | {emoji} {status} | {cpu} | {mem} | {net} | {ports} |")
|
|
parts.append("")
|
|
else:
|
|
parts.append("🐳 Docker non disponible.\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Honcho ----
|
|
parts.append(f"# 🧠 Honcho (mémoire IA)\n")
|
|
honcho = get_honcho_advanced()
|
|
if honcho:
|
|
api_emoji = "🟢" if honcho["api"] else "🔴"
|
|
parts.append(f"| Métrique | Valeur |")
|
|
parts.append(f"|:---------|:------:|")
|
|
parts.append(f"| API | {api_emoji} {'OK' if honcho['api'] else 'HORS-LIGNE'} |")
|
|
parts.append(f"| Peers | {honcho['peers']} |")
|
|
parts.append(f"| Sessions (total) | {honcho['sessions']} |")
|
|
parts.append(f"| Sessions (7j) | {honcho.get('sessions_recent', '?')} |")
|
|
parts.append(f"| Conclusions | {honcho['conclusions']} |")
|
|
parts.append(f"| Stockage DB | {honcho.get('db_size', '?')} |")
|
|
parts.append("")
|
|
else:
|
|
parts.append("🧠 Honcho non disponible.\n")
|
|
parts.append("---\n")
|
|
|
|
# ---- Priorities ----
|
|
parts.append(f"# 🎯 Priorités du jour\n")
|
|
priorities = get_priorities()
|
|
if priorities:
|
|
parts.append(priorities)
|
|
else:
|
|
parts.append("")
|
|
parts.append("⚠️ Aucune priorité définie — pensez à planifier votre journée !")
|
|
parts.append("")
|
|
parts.append("---\n")
|
|
|
|
# ---- Actualités RSS (FreshRSS prioritaire, fallback flux directs) ----
|
|
parts.append(f"# 📡 Actualités RSS\n")
|
|
parts.append("")
|
|
|
|
# Try FreshRSS first
|
|
freshrss_news = get_news_freshrss()
|
|
if freshrss_news:
|
|
total_articles = sum(len(v) for v in freshrss_news.values())
|
|
for cat_name, articles in freshrss_news.items():
|
|
if not articles:
|
|
continue
|
|
emoji = articles[0][3] if articles else "📰"
|
|
parts.append(f"{emoji} {cat_name}\n")
|
|
parts.append(f"| | Titre | Source |")
|
|
parts.append(f"|:-:|:------|:------:|")
|
|
for title, url, source, _ in articles[:4]:
|
|
parts.append(f"| 📰 | [**{title}**]({url}) | {source} |")
|
|
parts.append("")
|
|
parts.append(f"_📡 {total_articles} articles via FreshRSS ({len(freshrss_news)} catégories)_")
|
|
else:
|
|
# Fallback to direct RSS feeds
|
|
news = get_news_rss()
|
|
if news:
|
|
# Grouper par catégorie
|
|
categories = {}
|
|
for title, url, source, cat in news:
|
|
if cat not in categories:
|
|
categories[cat] = []
|
|
categories[cat].append((title, url, source))
|
|
|
|
cat_labels = {"🔬": "Recherche & Produits", "🏛️": "Juridique & Business", "🛡️": "Sécurité & Alignement", "💻": "Tech & Industrie"}
|
|
for cat, cat_label in cat_labels.items():
|
|
if cat in categories:
|
|
parts.append(f"{cat} {cat_label}\n")
|
|
parts.append(f"| | Titre | Source |")
|
|
parts.append(f"|:-:|:------|:------:|")
|
|
for title, url, source in categories[cat][:4]:
|
|
parts.append(f"| 📰 | [**{title}**]({url}) | {source} |")
|
|
parts.append("")
|
|
|
|
parts.append(f"_📡 {len(news)} articles collectés via flux RSS directs_")
|
|
else:
|
|
parts.append("📡 Aucune actualité disponible (FreshRSS et flux RSS inaccessibles).\n")
|
|
parts.append("")
|
|
parts.append("---\n")
|
|
|
|
# ---- Footer ----
|
|
parts.append("Bonne journée! ☀️\n")
|
|
|
|
return "\n".join(parts), nb_emails, nb_events
|
|
|
|
|
|
def main():
|
|
date_id_str = sys.argv[1] if len(sys.argv) > 1 else datetime.now().strftime("%Y%m%d")
|
|
date_id_str, jour, _, date_complete = get_date(date_id_str)
|
|
|
|
year = date_id_str[:4]
|
|
month = date_id_str[4:6]
|
|
|
|
# Build markdown
|
|
md, nb_emails, nb_events = build_markdown(date_id_str, jour, date_complete)
|
|
|
|
# Save
|
|
target_dir = BRIEFING_DIR / year / month
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
filepath = target_dir / f"briefing_{date_id_str}.md"
|
|
filepath.write_text(md)
|
|
|
|
# ObsiGate link
|
|
rel_path = f"05_Journal_Quotidien/5.1_Quotidien/HermesBerriPi/briefing-matin/{year}/{month}/briefing_{date_id_str}.md"
|
|
encoded = rel_path.replace("/", "%2F")
|
|
link = f"{OBSIGATE_BASE}/{encoded}"
|
|
|
|
# Output JSON for cron
|
|
result = {
|
|
"date_id": date_id_str,
|
|
"file": str(filepath),
|
|
"link": link,
|
|
"emails": nb_emails,
|
|
"events": nb_events,
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|