"""Briefing API: RSS feeds, weather, and briefing configuration.""" import asyncio import json import logging from quart import Blueprint, g, jsonify, request from sqlalchemy import select from fabledassistant.auth import login_required from fabledassistant.models import async_session from fabledassistant.models.conversation import Conversation, Message from fabledassistant.models.rss_feed import RssFeed from fabledassistant.services import rss as rss_svc from fabledassistant.services import weather as weather_svc from fabledassistant.services.briefing_conversations import ( get_or_create_today_conversation, list_briefing_conversations, post_message, ) from fabledassistant.services.settings import get_setting, set_settings_batch logger = logging.getLogger(__name__) briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing") _REQUIRE = login_required # ── Config ──────────────────────────────────────────────────────────────────── @briefing_bp.route("/config", methods=["GET"]) @_REQUIRE async def get_config(): raw = await get_setting(g.user.id, "briefing_config", "{}") try: config = json.loads(raw) if isinstance(raw, str) else (raw or {}) except Exception: config = {} return jsonify(config) @briefing_bp.route("/config", methods=["PUT"]) @_REQUIRE async def put_config(): data = await request.get_json() await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)}) # Live-patch the scheduler using the stored user_timezone. from fabledassistant.services.briefing_scheduler import update_user_schedule tz_override = await get_setting(g.user.id, "user_timezone") or None update_user_schedule(g.user.id, data, tz_override=tz_override) return jsonify({"ok": True}) # ── RSS Feeds ───────────────────────────────────────────────────────────────── @briefing_bp.route("/feeds", methods=["GET"]) @_REQUIRE async def list_feeds(): async with async_session() as session: result = await session.execute( select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id) ) feeds = list(result.scalars().all()) return jsonify([f.to_dict() for f in feeds]) @briefing_bp.route("/feeds", methods=["POST"]) @_REQUIRE async def add_feed(): data = await request.get_json() url = (data.get("url") or "").strip() if not url: return jsonify({"error": "url required"}), 400 scheme = url.split("://")[0].lower() if "://" in url else "" if scheme not in ("http", "https"): return jsonify({"error": "Feed URL must use http or https"}), 400 from fabledassistant.services.llm import _is_private_url if _is_private_url(url): return jsonify({"error": "Feed URL must not point to an internal address"}), 400 category = data.get("category") or None async with async_session() as session: # Prevent duplicates existing = await session.execute( select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url) ) if existing.scalars().first(): return jsonify({"error": "Feed already added"}), 409 feed = RssFeed(user_id=g.user.id, url=url, title="", category=category) session.add(feed) await session.commit() await session.refresh(feed) feed_id = feed.id # Fetch in background to auto-populate title and get initial items asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url)) return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201 @briefing_bp.route("/feeds/", methods=["DELETE"]) @_REQUIRE async def delete_feed(feed_id: int): async with async_session() as session: result = await session.execute( select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id) ) feed = result.scalars().first() if not feed: return jsonify({"error": "Not found"}), 404 await session.delete(feed) await session.commit() return jsonify({"ok": True}) @briefing_bp.route("/feeds/refresh", methods=["POST"]) @_REQUIRE async def refresh_feeds(): results = await rss_svc.refresh_all_feeds(g.user.id) total_new = sum(results.values()) return jsonify({"feeds_refreshed": len(results), "new_items": total_new}) @briefing_bp.route("/feeds/recent", methods=["GET"]) @_REQUIRE async def recent_items(): limit = min(int(request.args.get("limit", 20)), 100) items = await rss_svc.get_recent_items(g.user.id, limit=limit) return jsonify({"items": items}) # ── Weather ─────────────────────────────────────────────────────────────────── @briefing_bp.route("/weather", methods=["GET"]) @_REQUIRE async def get_weather(): rows = await weather_svc.get_cached_weather_rows(g.user.id) import json as _json raw = await get_setting(g.user.id, "briefing_config", "{}") try: _cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {}) temp_unit = _cfg.get("temp_unit", "C") if temp_unit not in ("C", "F"): temp_unit = "C" except Exception: temp_unit = "C" cards = [ card for row in rows if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None ] return jsonify({"locations": cards, "temp_unit": temp_unit}) @briefing_bp.route("/weather/geocode", methods=["POST"]) @_REQUIRE async def geocode_location(): data = await request.get_json() query = (data.get("query") or "").strip() if not query: return jsonify({"error": "query required"}), 400 try: lat, lon, label = await weather_svc.geocode(query) return jsonify({"lat": lat, "lon": lon, "label": label}) except ValueError as e: return jsonify({"error": str(e)}), 404 @briefing_bp.route("/weather/refresh", methods=["POST"]) @_REQUIRE async def refresh_weather(): raw = await get_setting(g.user.id, "briefing_config", "{}") try: config = json.loads(raw) if isinstance(raw, str) else {} except Exception: config = {} locations = config.get("locations", {}) refreshed = [] for key, loc in locations.items(): if not loc.get("lat") or not loc.get("lon"): continue try: await weather_svc.refresh_location_cache( user_id=g.user.id, location_key=key, location_label=loc.get("label", key), lat=loc["lat"], lon=loc["lon"], ) refreshed.append(key) except Exception: logger.warning("Failed to refresh weather for %s", key, exc_info=True) return jsonify({"refreshed": refreshed}) # ── Briefing Conversations ───────────────────────────────────────────────────── @briefing_bp.route("/conversations", methods=["GET"]) @_REQUIRE async def get_conversations(): convs = await list_briefing_conversations(g.user.id) return jsonify({"conversations": convs}) @briefing_bp.route("/conversations/today", methods=["GET"]) @_REQUIRE async def get_today_conversation(): model = await get_setting(g.user.id, "default_model", "") conv = await get_or_create_today_conversation(g.user.id, model) # Load messages async with async_session() as session: result = await session.execute( select(Message) .where(Message.conversation_id == conv.id) .order_by(Message.created_at) ) messages = [m.to_dict() for m in result.scalars().all()] data = conv.to_dict() data["messages"] = messages return jsonify(data) @briefing_bp.route("/conversations//messages", methods=["GET"]) @_REQUIRE async def get_conversation_messages(conv_id: int): # Verify ownership async with async_session() as session: conv = await session.get(Conversation, conv_id) if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing": return jsonify({"error": "Not found"}), 404 result = await session.execute( select(Message) .where(Message.conversation_id == conv_id) .order_by(Message.created_at) ) messages = [m.to_dict() for m in result.scalars().all()] return jsonify({"messages": messages}) @briefing_bp.route("/trigger", methods=["POST"]) @_REQUIRE async def manual_trigger(): """Manually trigger a briefing compilation, including a fresh data refresh.""" data = await request.get_json() or {} slot = data.get("slot", "compilation") if slot not in ("compilation", "morning", "midday", "afternoon"): return jsonify({"error": "invalid slot"}), 400 # Refresh external data first (mirrors what the scheduler does) try: from fabledassistant.services.rss import refresh_all_feeds config_raw = await get_setting(g.user.id, "briefing_config", "{}") config = json.loads(config_raw) if isinstance(config_raw, str) else {} await refresh_all_feeds(g.user.id) for key, loc in config.get("locations", {}).items(): if loc.get("lat") and loc.get("lon"): await weather_svc.refresh_location_cache( user_id=g.user.id, location_key=key, location_label=loc.get("label", key), lat=loc["lat"], lon=loc["lon"], ) except Exception: logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True) from fabledassistant.services.briefing_pipeline import run_compilation model = await get_setting(g.user.id, "default_model", "") conv = await get_or_create_today_conversation(g.user.id, model) text, metadata = await run_compilation(g.user.id, slot, model) msg = await post_message(conv.id, "assistant", text, metadata=metadata) return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot}) # ── RSS Reactions ────────────────────────────────────────────────────────────── @briefing_bp.route("/rss-reactions", methods=["POST"]) @_REQUIRE async def upsert_rss_reaction(): """Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips.""" data = await request.get_json() rss_item_id = data.get("rss_item_id") reaction = data.get("reaction") if not rss_item_id or reaction not in ("up", "down"): return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400 from sqlalchemy import text as _text async with async_session() as session: # Ownership check: verify item belongs to a feed owned by this user result = await session.execute( _text(""" SELECT i.id FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id WHERE i.id = :item_id AND f.user_id = :uid """).bindparams(item_id=rss_item_id, uid=g.user.id) ) if result.first() is None: return jsonify({"error": "Not found"}), 404 # Check existing reaction existing = await session.execute( _text(""" SELECT id, reaction FROM rss_item_reactions WHERE user_id = :uid AND rss_item_id = :item_id """).bindparams(uid=g.user.id, item_id=rss_item_id) ) row = existing.first() if row is None: await session.execute( _text(""" INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction) VALUES (:uid, :item_id, :reaction) """).bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction) ) action = "created" elif row.reaction == reaction: # Toggle off (same reaction clicked again) await session.execute( _text(""" DELETE FROM rss_item_reactions WHERE user_id = :uid AND rss_item_id = :item_id """).bindparams(uid=g.user.id, item_id=rss_item_id) ) action = "removed" else: # Flip to opposite reaction await session.execute( _text(""" UPDATE rss_item_reactions SET reaction = :reaction WHERE user_id = :uid AND rss_item_id = :item_id """).bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id) ) action = "updated" await session.commit() return jsonify({"ok": True, "action": action}) @briefing_bp.route("/rss-reactions/", methods=["DELETE"]) @_REQUIRE async def delete_rss_reaction(item_id: int): """Explicitly remove a reaction (useful for MCP/external API callers).""" from sqlalchemy import text as _text async with async_session() as session: await session.execute( _text(""" DELETE FROM rss_item_reactions WHERE user_id = :uid AND rss_item_id = :item_id """).bindparams(uid=g.user.id, item_id=item_id) ) await session.commit() return jsonify({"ok": True}) @briefing_bp.route("/news", methods=["GET"]) @_REQUIRE async def list_news(): """Return recent RSS articles with optional feed filter and pagination. Query params: days — lookback window (default 2, max 90) limit — items per page (default 40, max 100) offset — pagination offset (default 0) feed_id — optional integer filter by feed """ from sqlalchemy import text as _text days = min(int(request.args.get("days", 2)), 90) limit = min(int(request.args.get("limit", 40)), 100) offset = max(int(request.args.get("offset", 0)), 0) feed_id = request.args.get("feed_id", type=int) async with async_session() as session: result = await session.execute( _text(""" SELECT i.id, i.title, i.url, i.content, i.published_at, i.topics, f.title AS feed_title, r.reaction FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id LEFT JOIN rss_item_reactions r ON r.rss_item_id = i.id AND r.user_id = :uid WHERE f.user_id = :uid AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer)) AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days) ORDER BY COALESCE(i.published_at, i.fetched_at) DESC LIMIT :limit OFFSET :offset """).bindparams(uid=g.user.id, days=days, limit=limit, offset=offset, feed_id=feed_id) ) rows = result.mappings().all() items = [ { "id": r["id"], "title": r["title"], "url": r["url"], "snippet": (r["content"] or "")[:300], "content": r["content"] or "", "published_at": r["published_at"].isoformat() if r["published_at"] else None, "topics": r["topics"] or [], "source": r["feed_title"], "reaction": r["reaction"], } for r in rows ] return jsonify({"items": items, "offset": offset, "limit": limit})