From a274afd8a86c7e610579f9d9c7b16d63f2adf441 Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Fri, 20 Feb 2026 13:01:15 +0000 Subject: [PATCH 1/8] Add outlook daily brief --- community/outlook-daily-brief/README.md | 54 ++ community/outlook-daily-brief/__init__.py | 3 + community/outlook-daily-brief/main.py | 710 ++++++++++++++++++ .../refresh_token/README.md | 158 ++++ .../refresh_token/__init__.py | 0 .../refresh_token/get_refresh_token.py | 60 ++ 6 files changed, 985 insertions(+) create mode 100644 community/outlook-daily-brief/README.md create mode 100644 community/outlook-daily-brief/__init__.py create mode 100644 community/outlook-daily-brief/main.py create mode 100644 community/outlook-daily-brief/refresh_token/README.md create mode 100644 community/outlook-daily-brief/refresh_token/__init__.py create mode 100644 community/outlook-daily-brief/refresh_token/get_refresh_token.py diff --git a/community/outlook-daily-brief/README.md b/community/outlook-daily-brief/README.md new file mode 100644 index 00000000..528986a6 --- /dev/null +++ b/community/outlook-daily-brief/README.md @@ -0,0 +1,54 @@ +# Outlook Daily Brief + +OpenHome Ability that acts as a **daily orchestrator**: when you say a trigger phrase, it pulls your Outlook calendar, unread email, and local weather **in parallel**, then uses an LLM to turn everything into **one** natural-sounding ~60-second spoken summary. It’s not for one-off queries like “what’s on my calendar” — it’s the morning briefing that combines all three sources into a single cohesive readout. + +Works with **Outlook.com** (consumer) and **Office 365** (enterprise). Uses Microsoft Graph (OAuth) for calendar and mail, and Open-Meteo (free, no key) for weather. + +--- + +## What the code does + +**1. Trigger and intent** +The ability is invoked by phrases like “give me my brief”, “good morning”, “start my day”, or “what did I miss”. The code reads `self.worker.agent_memory.full_message_history` to get the user’s last message(s), then classifies intent: + +- **Full brief** — “good morning”, “brief me”, “daily brief”, “start my day” → standard ~60s morning briefing. +- **Urgent / catch-up** — “what did I miss”, “catch me up” → same data but a shorter, urgency-focused script (~30–40s) that leads with what needs attention. + +**2. Parallel fetch** +Calendar, email, and weather are requested **at the same time** (each in a thread via `asyncio.to_thread`), with a per-call timeout (~5–6s). If one source fails, the others still run; the briefing uses whatever came back. + +- **Calendar** — `GET /me/calendarview` for today (start/end in UTC). Returns subject, start, end, location. Scope: `Calendars.Read`. +- **Email** — `GET /me/mailFolders/inbox/messages` with `isRead eq false`, top 5, subject/from/receivedDateTime. Scope: `Mail.Read`. +- **Weather** — Open-Meteo forecast by lat/lon. Location comes from saved prefs or auto-detect (e.g. ip-api.com); result is stored so the next run reuses it. + +**3. LLM synthesis** +All fetched data (calendar, email, weather) is passed in one payload to `text_to_text_response()` with a **system prompt**. The LLM returns a single script to be read aloud — not three separate readouts. Different prompts are used for full brief vs “what did I miss” so the tone and length match the intent. + +**4. Speak and follow-up loop** +The script is spoken via the capability worker. Then the code waits for the user: + +- **Repeat** — re-fetches data, re-synthesizes, and speaks again (or replays last script if fetch fails). +- **Change my city to [city]** — updates `location` in prefs, then “say repeat to hear your brief again”. +- **What did I miss / catch me up** — re-fetches and speaks the urgent-style brief. +- **Stop / done / quit / bye / etc.** — says goodbye and calls `resume_normal_flow()`. + +Idle timeouts (no input for several cycles) trigger a short “I’m still here…” message and then exit. Every exit path calls `resume_normal_flow()`. + +**5. Persistence** +Preferences are stored in a namespaced file (e.g. `outlook_daily_brief_prefs.json`): `location`, `calendar_connected`, `email_connected`, `enabled_sections` (weather, calendar, email). Sections can be turned off; missing or failed sections are skipped in the brief without being announced. + +**6. Errors** +Each section fails on its own. If every fetch fails, the user hears: “I’m having trouble reaching some services right now. Let me try again in a moment.” No crash; the ability resumes normal flow. + +--- + +## Setup: Client ID and Refresh Token + +Configure `CLIENT_ID` and `REFRESH_TOKEN` in `main.py` (and keep `TENANT_ID = "consumers"` for personal Microsoft accounts). + +**Use the `refresh_token` folder** for step-by-step setup: + +1. **`refresh_token/README.md`** — Create an Entra app, add delegated permissions (`Calendars.Read`, `Mail.Read`), enable public client flow. +2. **`refresh_token/get_refresh_token.py`** — Set the same `CLIENT_ID`, run it, sign in at microsoft.com/devicelogin, then copy the printed **refresh token** into `main.py` as `REFRESH_TOKEN`. + +Same `CLIENT_ID` goes in both the script and `main.py`; the script gives you the `REFRESH_TOKEN` to paste into `main.py`. diff --git a/community/outlook-daily-brief/__init__.py b/community/outlook-daily-brief/__init__.py new file mode 100644 index 00000000..92406215 --- /dev/null +++ b/community/outlook-daily-brief/__init__.py @@ -0,0 +1,3 @@ +from .main import OutlookDailyBriefCapability + +__all__ = ["OutlookDailyBriefCapability"] diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py new file mode 100644 index 00000000..fd7793c2 --- /dev/null +++ b/community/outlook-daily-brief/main.py @@ -0,0 +1,710 @@ +""" +Outlook Daily Brief — OpenHome Ability. +Fetches calendar, email, and weather in parallel, synthesizes one ~60s spoken briefing. +One file, one class. Microsoft Graph (OAuth) + Open-Meteo (free) API. +""" + +import asyncio +import json +import os +from datetime import datetime, timezone +from typing import Dict, List, Optional + +import requests + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# CONFIG — use env/secrets in production + +GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" +CLIENT_ID = "your_client_id" +TENANT_ID = "consumers" +REFRESH_TOKEN = "your_refresh_token" + +# Prevents collision with other abilities +PREFS_FILE = "outlook_daily_brief_prefs.json" +API_TIMEOUT = 5 +PARALLEL_TIMEOUT = 6 + +# Idle timeouts before sign-off; high count so follow-ups are still in scope +FOLLOW_UP_IDLE_TIMEOUT_SEC = 20.0 +FOLLOW_UP_IDLE_COUNT_BEFORE_SIGNOFF = 5 + +EXIT_WORDS = [ + "stop", + "done", + "quit", + "exit", + "bye", + "goodbye", + "nothing else", + "all good", + "nope", + "no thanks", + "i'm good", + "im good", +] +CANCEL_PHRASES = ["never mind", "nevermind", "cancel", "forget it"] +REPEAT_WORDS = ["repeat", "again", "say that again", "replay"] +CATCH_UP_PHRASES = ["what did i miss", "what i miss", "catch me up", "anything urgent"] + +BRIEFING_SYSTEM_PROMPT = ( + "You are a warm, professional morning briefing host. Synthesize the following " + "data into a concise ~60-second spoken morning briefing. Be conversational but " + "efficient. Transition smoothly between sections. If a section has no data, skip " + "it without mentioning it's missing. Output only the script to be read aloud, " + "no meta-commentary." +) + +BRIEFING_SYSTEM_PROMPT_URGENT = ( + "The user asked 'What did I miss?' — give a short catch-up, not a full morning brief. " + "Lead with what needs attention: upcoming calendar (times, what's next), then unread email " + "that matters (, important senders). Use phrases like 'heads up', 'worth checking', " + "'came in recently'. Skip or one-line non-urgent items (e.g. promos). Weather: one sentence only " + "if at all. Keep it to ~30–40 seconds. Do NOT open with 'Good morning' or a full date — open with " + "'Here's what's new' or 'Quick catch-up'. End with 'That's what's new' or similar. " + "Output only the script to be read aloud." +) + +TRIGGER_INTENT_PROMPT = """Classify the user's trigger for a daily brief. Return ONLY JSON: +{"mode": "full" or "urgent"} + +- "full" = standard morning brief (good morning, brief me, start my day, give me my brief, daily brief). +- "urgent" = what did I miss / catch-up emphasis. + +User's recent message(s): +{trigger_context} +""" + +# ============================================================================= +# MAIN CLASS +# ============================================================================= + + +class OutlookBriefCapability(MatchingCapability): + + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + prefs: Dict = {} + last_script: Optional[str] = None + last_brief_mode: str = "full" # full | urgent, for repeat + + # ------------------------------------------------------------------------- + # REGISTRATION & ENTRY + # ------------------------------------------------------------------------- + + @classmethod + def register_capability(cls) -> "MatchingCapability": + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") + ) as file: + data = json.load(file) + return cls( + unique_name=data["unique_name"], + matching_hotwords=data["matching_hotwords"], + ) + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self.worker) + self.worker.session_tasks.create(self.run()) + + def log(self, msg: str): + self.worker.editor_logging_handler.info(f"[OutlookDailyBrief] {msg}") + + def log_err(self, msg: str): + self.worker.editor_logging_handler.error(f"[OutlookDailyBrief] {msg}") + + # ------------------------------------------------------------------------- + # MAIN RUN + # ------------------------------------------------------------------------- + + async def run(self): + try: + await self.capability_worker.speak("One sec, pulling your brief together.") + + self.prefs = await self.load_preferences() + trigger_context = self.get_trigger_context() + intent = self.classify_trigger_intent(trigger_context) + mode = intent.get("mode", "full") + + enabled = self.prefs.get("enabled_sections") or [ + "weather", + "calendar", + "email", + ] + calendar_data: Optional[Dict] = None + email_data: Optional[Dict] = None + weather_data: Optional[Dict] = None + + try: + calendar_data, email_data, weather_data = ( + await self._fetch_all_parallel(enabled) + ) + except Exception as e: + self.log_err(f"Parallel fetch error: {e}") + + if not any([calendar_data, email_data, weather_data]): + await self.capability_worker.speak( + "I'm having trouble reaching some services right now. Let me try again in a moment." + ) + self.capability_worker.resume_normal_flow() + return + + await self._save_preferences() + + system_prompt = ( + BRIEFING_SYSTEM_PROMPT_URGENT + if mode == "urgent" + else BRIEFING_SYSTEM_PROMPT + ) + script = self._synthesize_briefing( + calendar_data, email_data, weather_data, system_prompt, enabled + ) + if not (script and script.strip()): + await self.capability_worker.speak( + "I couldn't put together a briefing right now. Try again in a moment." + ) + self.capability_worker.resume_normal_flow() + return + + self.last_script = script.strip() + self.last_brief_mode = mode + await self.capability_worker.speak(self.last_script) + await self._follow_up_loop() + + except Exception as e: + self.log_err(str(e)) + await self.capability_worker.speak( + "I'm having trouble reaching some services right now. Let me try again in a moment." + ) + finally: + self.capability_worker.resume_normal_flow() + + # ------------------------------------------------------------------------- + # TRIGGER CONTEXT + # ------------------------------------------------------------------------- + + def get_trigger_context(self) -> Dict: + recent: List[str] = [] + trigger = "" + try: + history = self.worker.agent_memory.full_message_history + for msg in reversed(history or []): + if hasattr(msg, "role") and "user" in str(msg.role).lower(): + content = str(msg.content).strip() + if content: + recent.append(content) + if not trigger: + trigger = content + if len(recent) >= 5: + break + except Exception: + pass + recent_text = ( + "\n".join(reversed(recent)) if recent else (trigger or "good morning") + ) + return {"trigger": trigger, "trigger_context": recent_text} + + def classify_trigger_intent(self, trigger_context: Dict) -> Dict: + # Classify from current utterance only so history doesn't override + current = trigger_context.get("trigger", "") or "" + if not isinstance(current, str): + current = str(current) + current_lower = current.lower().strip() + if "what did i miss" in current_lower or "what i miss" in current_lower: + return {"mode": "urgent"} + if any( + p in current_lower + for p in [ + "good morning", + "brief me", + "give me my brief", + "daily brief", + "start my day", + ] + ): + return {"mode": "full"} + text = trigger_context.get("trigger_context", "") or current + if not isinstance(text, str): + text = str(text) + try: + prompt = TRIGGER_INTENT_PROMPT.format(trigger_context=text) + raw = self.capability_worker.text_to_text_response(prompt) + clean = (raw or "").replace("```json", "").replace("```", "").strip() + start, end = clean.find("{"), clean.rfind("}") + if start != -1 and end > start: + clean = clean[start : end + 1] + out = json.loads(clean) + if isinstance(out, dict) and out.get("mode") in ("full", "urgent"): + return out + except Exception as e: + self.log_err(f"Trigger classify: {e}") + return {"mode": "full"} + + # ------------------------------------------------------------------------- + # PARALLEL FETCH + # ------------------------------------------------------------------------- + + async def _fetch_all_parallel( + self, enabled_sections: Optional[List[str]] = None + ) -> tuple: + enabled = enabled_sections or ["weather", "calendar", "email"] + do_cal = "calendar" in enabled + do_mail = "email" in enabled + do_weather = "weather" in enabled + + async def _none() -> None: + return None + + tasks = [] + if do_cal: + tasks.append( + asyncio.wait_for( + asyncio.to_thread(self._fetch_calendar_sync), + timeout=PARALLEL_TIMEOUT, + ) + ) + else: + tasks.append(_none()) + if do_mail: + tasks.append( + asyncio.wait_for( + asyncio.to_thread(self._fetch_email_sync), + timeout=PARALLEL_TIMEOUT, + ) + ) + else: + tasks.append(_none()) + if do_weather: + tasks.append( + asyncio.wait_for( + asyncio.to_thread(self._fetch_weather_sync), + timeout=PARALLEL_TIMEOUT, + ) + ) + else: + tasks.append(_none()) + + results = await asyncio.gather(*tasks, return_exceptions=True) + cal = results[0] if not isinstance(results[0], BaseException) else None + mail = results[1] if not isinstance(results[1], BaseException) else None + weather = results[2] if not isinstance(results[2], BaseException) else None + for i, r in enumerate(results): + if isinstance(r, BaseException): + self.log_err(f"Fetch error ({['cal', 'mail', 'weather'][i]}): {r}") + return (cal, mail, weather) + + def _fetch_calendar_sync(self) -> Optional[Dict]: + if not REFRESH_TOKEN or REFRESH_TOKEN == "YOUR_REFRESH_TOKEN": + return None + token, err = self._refresh_access_token() + if err or not token: + return None + now = datetime.now(timezone.utc) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start.replace(hour=23, minute=59, second=59, microsecond=999999) + start_iso = start.isoformat().replace("+00:00", "Z") + end_iso = end.isoformat().replace("+00:00", "Z") + url = ( + f"{GRAPH_BASE_URL}/me/calendarview" + f"?startDateTime={start_iso}&endDateTime={end_iso}" + ) + try: + r = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=API_TIMEOUT, + ) + if r.status_code != 200: + return None + data = r.json() + events = data.get("value", []) + out = [] + for ev in events: + start_dt = (ev.get("start") or {}).get("dateTime") + end_dt = (ev.get("end") or {}).get("dateTime") + loc = (ev.get("location") or {}).get("displayName") or "" + out.append( + { + "subject": ev.get("subject") or "(No title)", + "start": start_dt, + "end": end_dt, + "location": loc, + } + ) + return {"events": out} + except Exception as e: + self.log_err(f"Calendar API: {e}") + return None + + def _fetch_email_sync(self) -> Optional[Dict]: + if not REFRESH_TOKEN or REFRESH_TOKEN == "YOUR_REFRESH_TOKEN": + return None + token, err = self._refresh_access_token() + if err or not token: + return None + url = ( + f"{GRAPH_BASE_URL}/me/mailFolders/inbox/messages" + "?$filter=isRead eq false&$top=5&$select=subject,from,receivedDateTime" + ) + try: + r = requests.get( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + timeout=API_TIMEOUT, + ) + if r.status_code != 200: + return None + data = r.json() + items = data.get("value", []) + out = [] + for m in items: + from_addr = (m.get("from") or {}).get("emailAddress", {}) + name = from_addr.get("name") or from_addr.get("address") or "Unknown" + out.append( + { + "subject": m.get("subject") or "(No subject)", + "from": name, + "received": m.get("receivedDateTime"), + } + ) + return {"unread_count": len(out), "messages": out} + except Exception as e: + self.log_err(f"Mail API: {e}") + return None + + def _fetch_weather_sync(self) -> Optional[Dict]: + lat, lon, city = self._resolve_weather_location_sync() + if lat is None or lon is None: + return None + try: + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lon}" + "¤t=temperature_2m,weather_code,wind_speed_10m,relative_humidity_2m" + "&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max,weather_code" + "&timezone=auto" + ) + r = requests.get(url, timeout=API_TIMEOUT) + if r.status_code != 200: + return None + data = r.json() + cur = data.get("current", {}) + daily = data.get("daily") or {} + daily_max = daily.get("temperature_2m_max") or [] + daily_min = daily.get("temperature_2m_min") or [] + daily_pop = daily.get("precipitation_probability_max") or [] + today_max = daily_max[0] if daily_max else cur.get("temperature_2m") + today_min = daily_min[0] if daily_min else None + pop_today = daily_pop[0] if daily_pop else None + return { + "location": city or f"{lat:.1f}, {lon:.1f}", + "current_temp": cur.get("temperature_2m"), + "weather_code": cur.get("weather_code"), + "wind_speed": cur.get("wind_speed_10m"), + "humidity": cur.get("relative_humidity_2m"), + "today_high": today_max, + "today_low": today_min, + "precipitation_chance": pop_today, + } + except Exception as e: + self.log_err(f"Weather API: {e}") + return None + + def _resolve_weather_location_sync(self) -> tuple: + location = self.prefs.get("location") + if location and isinstance(location, str) and location.strip(): + lat, lon = self._geocode_city_sync(location.strip()) + if lat is not None and lon is not None: + return (lat, lon, location.strip()) + lat, lon, city = self._ip_geo_sync() + if lat is not None and lon is not None and city: + self.prefs["location"] = city + return (lat, lon, city or (self.prefs.get("location") or "your area")) + + def _geocode_city_sync(self, city: str) -> tuple: + try: + r = requests.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={"name": city, "count": 1}, + timeout=API_TIMEOUT, + ) + if r.status_code != 200: + return (None, None) + data = r.json() + results = data.get("results", []) + if not results: + return (None, None) + first = results[0] + return (first.get("latitude"), first.get("longitude")) + except Exception: + return (None, None) + + def _ip_geo_sync(self) -> tuple: + try: + r = requests.get( + "http://ip-api.com/json/?fields=lat,lon,city", + timeout=API_TIMEOUT, + ) + if r.status_code != 200: + return (None, None, None) + data = r.json() + return ( + data.get("lat"), + data.get("lon"), + data.get("city") or None, + ) + except Exception: + return (None, None, None) + + def _refresh_access_token(self) -> tuple: + url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" + payload = { + "client_id": CLIENT_ID, + "refresh_token": REFRESH_TOKEN, + "grant_type": "refresh_token", + "scope": ( + "https://graph.microsoft.com/Calendars.Read " + "https://graph.microsoft.com/Mail.Read" + ), + } + try: + r = requests.post(url, data=payload, timeout=API_TIMEOUT) + if r.status_code == 200: + data = r.json() + return (data.get("access_token"), None) + self.log_err(f"Token refresh: {r.status_code} {r.text}") + return (None, "refresh_failed") + except Exception as e: + self.log_err(f"Token refresh: {e}") + return (None, str(e)) + + # ------------------------------------------------------------------------- + # LLM SYNTHESIS + # ------------------------------------------------------------------------- + + def _synthesize_briefing( + self, + calendar_data: Optional[Dict], + email_data: Optional[Dict], + weather_data: Optional[Dict], + system_prompt: str, + enabled_sections: Optional[List[str]] = None, + ) -> str: + enabled = enabled_sections or ["weather", "calendar", "email"] + payload = {} + if "calendar" in enabled and calendar_data: + payload["calendar"] = calendar_data + if "email" in enabled and email_data: + payload["email"] = email_data + if "weather" in enabled and weather_data: + payload["weather"] = weather_data + prompt = "Data for the briefing:\n" + json.dumps(payload, indent=2) + try: + return self.capability_worker.text_to_text_response( + prompt, history=[], system_prompt=system_prompt + ) + except Exception as e: + self.log_err(f"LLM synthesis: {e}") + return "" + + # ------------------------------------------------------------------------- + # FOLLOW-UP LOOP + # ------------------------------------------------------------------------- + + async def _follow_up_loop(self): + idle_count = 0 + while True: + try: + user = await asyncio.wait_for( + self.capability_worker.user_response(), + timeout=FOLLOW_UP_IDLE_TIMEOUT_SEC, + ) + except Exception: + idle_count += 1 + if idle_count >= FOLLOW_UP_IDLE_COUNT_BEFORE_SIGNOFF: + await self.capability_worker.speak( + "I'm still here if you need anything. Otherwise I'll sign off." + ) + break + continue + if not user or not user.strip(): + idle_count += 1 + if idle_count >= FOLLOW_UP_IDLE_COUNT_BEFORE_SIGNOFF: + await self.capability_worker.speak( + "I'm still here if you need anything. Otherwise I'll sign off." + ) + break + continue + idle_count = 0 + lower = user.strip().lower() + if any(w in lower for w in EXIT_WORDS): + await self.capability_worker.speak( + "Done. Let me know when you want your next brief." + ) + break + if any(p in lower for p in CANCEL_PHRASES): + await self.capability_worker.speak("Okay.") + break + if any(w in lower for w in REPEAT_WORDS): + # Re-fetch so repeat uses current prefs + await self.capability_worker.speak("One sec.") + enabled = self.prefs.get("enabled_sections") or [ + "weather", + "calendar", + "email", + ] + try: + calendar_data, email_data, weather_data = ( + await self._fetch_all_parallel(enabled) + ) + except Exception as e: + self.log_err(f"Repeat fetch: {e}") + if self.last_script: + await self.capability_worker.speak(self.last_script) + continue + if not any([calendar_data, email_data, weather_data]): + if self.last_script: + await self.capability_worker.speak(self.last_script) + continue + system_prompt = ( + BRIEFING_SYSTEM_PROMPT_URGENT + if self.last_brief_mode == "urgent" + else BRIEFING_SYSTEM_PROMPT + ) + script = self._synthesize_briefing( + calendar_data, + email_data, + weather_data, + system_prompt, + enabled, + ) + if script and script.strip(): + self.last_script = script.strip() + await self.capability_worker.speak(self.last_script) + elif self.last_script: + await self.capability_worker.speak(self.last_script) + continue + if "change my city" in lower or "change city" in lower: + city = self._extract_city_from_change(user) + if city: + self.prefs["location"] = city + await self._save_preferences() + await self.capability_worker.speak( + f"Updated to {city}. Say repeat to hear your brief again." + ) + else: + await self.capability_worker.speak( + "Which city? Say 'change my city to' and the city name." + ) + continue + if any(p in lower for p in CATCH_UP_PHRASES): + await self.capability_worker.speak("One sec, checking what's new.") + enabled = self.prefs.get("enabled_sections") or [ + "weather", + "calendar", + "email", + ] + try: + calendar_data, email_data, weather_data = ( + await self._fetch_all_parallel(enabled) + ) + except Exception as e: + self.log_err(f"Catch-up fetch: {e}") + await self.capability_worker.speak( + "I'm having trouble reaching some services right now. Try again in a moment." + ) + continue + if not any([calendar_data, email_data, weather_data]): + await self.capability_worker.speak( + "I'm having trouble reaching some services right now. Try again in a moment." + ) + continue + script = self._synthesize_briefing( + calendar_data, + email_data, + weather_data, + BRIEFING_SYSTEM_PROMPT_URGENT, + enabled, + ) + if not (script and script.strip()): + await self.capability_worker.speak( + "I couldn't put together an update right now. Try again in a moment." + ) + continue + self.last_script = script.strip() + self.last_brief_mode = "urgent" + await self.capability_worker.speak(self.last_script) + continue + break + + def _extract_city_from_change(self, text: str) -> Optional[str]: + lower = text.lower().strip() + out = None + for prefix in ( + "change my city to ", + "change city to ", + "set city to ", + "change my location to ", + ): + if lower.startswith(prefix): + out = text[len(prefix) :].strip() + break + if out is None and " to " in lower: + out = text.split(" to ", 1)[-1].strip() + if not out: + return None + # Trailing punctuation breaks geocoding + return out.rstrip(".,?!").strip() or None + + # ------------------------------------------------------------------------- + # PREFERENCES (platform file APIs) + # ------------------------------------------------------------------------- + + async def load_preferences(self) -> Dict: + try: + exists = await self.capability_worker.check_if_file_exists( + PREFS_FILE, False + ) + if not exists: + return { + "location": None, + "calendar_connected": False, + "email_connected": False, + "enabled_sections": ["weather", "calendar", "email"], + } + raw = await self.capability_worker.read_file(PREFS_FILE, False) + data = json.loads(raw) if raw else {} + data.setdefault("location", None) + data.setdefault("calendar_connected", False) + data.setdefault("email_connected", False) + data.setdefault("enabled_sections", ["weather", "calendar", "email"]) + return data + except Exception as e: + self.log_err(f"Load prefs: {e}") + return { + "location": None, + "calendar_connected": False, + "email_connected": False, + "enabled_sections": ["weather", "calendar", "email"], + } + + async def _save_preferences(self): + try: + if await self.capability_worker.check_if_file_exists(PREFS_FILE, False): + await self.capability_worker.delete_file(PREFS_FILE, False) + await self.capability_worker.write_file( + PREFS_FILE, json.dumps(self.prefs), False + ) + except Exception as e: + self.log_err(f"Save prefs: {e}") diff --git a/community/outlook-daily-brief/refresh_token/README.md b/community/outlook-daily-brief/refresh_token/README.md new file mode 100644 index 00000000..375dee9a --- /dev/null +++ b/community/outlook-daily-brief/refresh_token/README.md @@ -0,0 +1,158 @@ +# Get a Refresh Token for the Outlook Connector + +## Why We Need This + +The Outlook Connector (e.g. `main.py`) talks to Microsoft Graph to read and send email. Microsoft requires your app to prove who the user is and that they’ve granted permission. + +- **Access token** – Short-lived; used for each API request. Expires after about an hour. +- **Refresh token** – Long-lived; used to get new access tokens without asking the user to sign in again. + +This folder’s script, **`get_refresh_token.py`**, uses Microsoft’s **device flow** (no browser in the script): you run the script, open a URL in your browser, enter a code, sign in once, and the script prints a **refresh token**. You paste that token (and the same `CLIENT_ID`) into your Outlook Connector so it can get access tokens automatically. + +Without a valid refresh token (or another way to get tokens), the connector cannot call Microsoft Graph. + +--- + +## Prerequisites + +- **Python** – [Download](https://www.python.org/downloads/). After installing, in a terminal run: + ```bash + python --version + ``` + You should see a version number. + +- **MSAL** – Microsoft Authentication Library: + ```bash + pip install msal + ``` + +--- + +## Step 1 – Create an App in Microsoft Entra + +1. Go to [https://entra.microsoft.com](https://entra.microsoft.com). +2. Sign in with your Microsoft account. +3. Open **Applications** → **App registrations**. +4. Click **New registration**. + +--- + +## Step 2 – Register the App + +- **Name:** Any name (e.g. “Outlook Connector”). +- **Supported account types:** + **Accounts in any organizational directory and personal Microsoft accounts** + (needed for outlook.com / live.com / hotmail.com). +- **Redirect URI:** Leave blank (device flow doesn’t use it). + +Click **Register**. + +--- + +## Step 3 – Copy the Client ID + +On the app’s **Overview** page, copy the **Application (client) ID** (a long GUID). You’ll put this in `get_refresh_token.py` and in your Outlook Connector (`main.py`). + +--- + +## Step 4 – Allow Public Client Flows + +Device flow is a “public client” flow, so it must be enabled: + +1. In the app registration, go to **Authentication**. +2. Under **Advanced settings**, set **Allow public client flows** to **Yes**. +3. Click **Save**. + +--- + +## Step 5 – Add API Permissions (Mail + User) + +The script requests Mail and User scopes so the refresh token can be used for reading/sending mail: + +1. Go to **API permissions** → **Add a permission**. +2. Choose **Microsoft Graph** → **Delegated permissions**. +3. Add: + - `Calendars.Read` + - `Mail.Read` + +4. Click **Add permissions**. + +*(You do **not** add `offline_access` yourself; MSAL requests it when a refresh token is needed.)* + +--- + +## Step 6 – Put Your Client ID in the Script + +Open `get_refresh_token.py` and set: + +```python +CLIENT_ID = "your-application-client-id-from-entra" +``` + +Use the same Client ID you copied in Step 3. Save the file. + +--- + +## Step 7 – Run the Script + +In a terminal, from the folder that contains `get_refresh_token.py`: + +```bash +python get_refresh_token.py +``` + +--- + +## Step 8 – Complete Device Login in the Browser + +The script will print something like: + +``` +To sign in, use a web browser to open https://microsoft.com/devicelogin and enter the code XXXX-XXXX to authenticate. +``` + +Do this: + +1. Open [https://microsoft.com/devicelogin](https://microsoft.com/devicelogin) in your browser. +2. Enter the code shown in the terminal. +3. Sign in with your **personal** Microsoft account (outlook.com, live.com, hotmail.com). +4. When asked for permissions, click **Accept**. + +--- + +## Step 9 – Copy the Refresh Token into Your Outlook Connector + +After you sign in, the script prints: + +- A short preview of the **access token** (short-lived; you don’t need to copy this). +- The full **refresh token** – copy this entire value. + +In your Outlook Connector (`main.py` or equivalent), set: + +- **CLIENT_ID** – Same value as in `get_refresh_token.py` (your app’s Client ID). +- **TENANT_ID** – Use `"consumers"` so it matches this script’s authority (personal Microsoft accounts). +- **REFRESH_TOKEN** – The long string you just copied. + +Example: + +```python +CLIENT_ID = "28b10d08-5223-4d0b-8a2e-a2d92b519e8e" # your app’s Client ID +TENANT_ID = "consumers" # personal Microsoft accounts (outlook.com, etc.) +REFRESH_TOKEN = "" +``` + +Your connector can then use this refresh token to obtain access tokens and call Microsoft Graph (e.g. read/send mail) without the user signing in again each time. + +--- + +## Summary + +| Step | What you do | +|------|------------------| +| 1–5 | Create an Entra app, enable public client flow, add Mail + User delegated permissions. | +| 6 | Set `CLIENT_ID` in `get_refresh_token.py`. | +| 7 | Run `python get_refresh_token.py`. | +| 8 | Open microsoft.com/devicelogin, enter the code, sign in, accept permissions. | +| 9 | Copy the printed refresh token (and same `CLIENT_ID`) into your Outlook Connector; set `TENANT_ID = "consumers"`. | + +The refresh token is what the Outlook Connector needs to get new access tokens and talk to Microsoft Graph on behalf of the user. diff --git a/community/outlook-daily-brief/refresh_token/__init__.py b/community/outlook-daily-brief/refresh_token/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/outlook-daily-brief/refresh_token/get_refresh_token.py b/community/outlook-daily-brief/refresh_token/get_refresh_token.py new file mode 100644 index 00000000..572e9a8d --- /dev/null +++ b/community/outlook-daily-brief/refresh_token/get_refresh_token.py @@ -0,0 +1,60 @@ +""" +Get a refresh token for the Outlook Connector (main.py). +Uses the same MSAL device flow as auth.py but requests Mail scopes + offline_access +so the response includes a refresh_token you can paste into the main script. + +Usage: + 1. Set CLIENT_ID below (same as in your main script / app registration). + 2. pip install msal + 3. python get_refresh_token.py + 4. Open the URL, sign in with your personal Microsoft account, enter the code. + 5. Copy the refresh_token (and optionally CLIENT_ID) into main.py. + In main.py use TENANT_ID = "consumers" to match this script's authority. +""" + +import msal + +CLIENT_ID = "your_client_id" + +# Personal Microsoft accounts (outlook.com, live.com, hotmail.com) +AUTHORITY = "https://login.microsoftonline.com/consumers" + +app = msal.PublicClientApplication( + CLIENT_ID, + authority=AUTHORITY, +) + +# Mail.Read, Mail.ReadWrite, and Mail.Send are what the Outlook Connector needs. +# Do not add offline_access — MSAL reserves it and adds it when needed for refresh_token. +scopes = [ + "Mail.Send", + "Calendars.Read", +] + +flow = app.initiate_device_flow(scopes=scopes) + +if "user_code" not in flow: + print("Failed to create device flow") + print(flow) + exit(1) + +print(flow["message"]) +print() + +result = app.acquire_token_by_device_flow(flow) + +if "access_token" in result: + print("\n--- Success ---\n") + print("Access token (short-lived):") + print(result["access_token"][:80] + "...") + if "refresh_token" in result: + print("\nRefresh token (use this in main.py):") + print(result["refresh_token"]) + print("\nIn your Outlook Connector main.py set:") + print(' TENANT_ID = "consumers" # same authority as this script') + print(' REFRESH_TOKEN = ""') + else: + print("\nNo refresh_token in result. Ensure offline_access is in scopes.") +else: + print("\nToken error:") + print(result.get("error_description", result)) From 6e10a7f599a046e809da7298391a10947835a280 Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Fri, 20 Feb 2026 13:07:59 +0000 Subject: [PATCH 2/8] ensure init are empty --- community/outlook-daily-brief/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/community/outlook-daily-brief/__init__.py b/community/outlook-daily-brief/__init__.py index 92406215..e69de29b 100644 --- a/community/outlook-daily-brief/__init__.py +++ b/community/outlook-daily-brief/__init__.py @@ -1,3 +0,0 @@ -from .main import OutlookDailyBriefCapability - -__all__ = ["OutlookDailyBriefCapability"] From 785f4397c6426b17a52242dc9302ad4e466253c6 Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Fri, 20 Feb 2026 19:44:38 +0000 Subject: [PATCH 3/8] add register tag --- community/outlook-daily-brief/main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index fd7793c2..862f4ef5 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -96,6 +96,8 @@ class OutlookBriefCapability(MatchingCapability): # REGISTRATION & ENTRY # ------------------------------------------------------------------------- + # {{register capability}} + @classmethod def register_capability(cls) -> "MatchingCapability": with open( From 03852b4549e677c6340aa4a2a5991203acb5572b Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Fri, 20 Feb 2026 20:02:47 +0000 Subject: [PATCH 4/8] update register capability --- community/outlook-daily-brief/main.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index 862f4ef5..6f254491 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -6,8 +6,8 @@ import asyncio import json -import os from datetime import datetime, timezone +from pathlib import Path from typing import Dict, List, Optional import requests @@ -95,15 +95,12 @@ class OutlookBriefCapability(MatchingCapability): # ------------------------------------------------------------------------- # REGISTRATION & ENTRY # ------------------------------------------------------------------------- - # {{register capability}} @classmethod def register_capability(cls) -> "MatchingCapability": - with open( - os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") - ) as file: - data = json.load(file) + config_path = Path(__file__).resolve().parent / "config.json" + data = json.loads(config_path.read_text(encoding="utf-8")) return cls( unique_name=data["unique_name"], matching_hotwords=data["matching_hotwords"], From 225479e9acc4395e245df28d9b0cb9a387041bcf Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Sat, 21 Feb 2026 14:45:26 +0000 Subject: [PATCH 5/8] update code to align with open home --- community/outlook-daily-brief/main.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index 6f254491..dd6e8b22 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -7,7 +7,6 @@ import asyncio import json from datetime import datetime, timezone -from pathlib import Path from typing import Dict, List, Optional import requests @@ -95,16 +94,8 @@ class OutlookBriefCapability(MatchingCapability): # ------------------------------------------------------------------------- # REGISTRATION & ENTRY # ------------------------------------------------------------------------- - # {{register capability}} - @classmethod - def register_capability(cls) -> "MatchingCapability": - config_path = Path(__file__).resolve().parent / "config.json" - data = json.loads(config_path.read_text(encoding="utf-8")) - return cls( - unique_name=data["unique_name"], - matching_hotwords=data["matching_hotwords"], - ) + # {{register capability}} def call(self, worker: AgentWorker): self.worker = worker From 5ae2b893dd91f69b269fb5a7cc7fd8faaecfd85a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 14:45:49 +0000 Subject: [PATCH 6/8] style: auto-format Python files with autoflake + autopep8 --- community/outlook-daily-brief/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index dd6e8b22..10a90605 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -227,7 +227,7 @@ def classify_trigger_intent(self, trigger_context: Dict) -> Dict: clean = (raw or "").replace("```json", "").replace("```", "").strip() start, end = clean.find("{"), clean.rfind("}") if start != -1 and end > start: - clean = clean[start : end + 1] + clean = clean[start: end + 1] out = json.loads(clean) if isinstance(out, dict) and out.get("mode") in ("full", "urgent"): return out @@ -648,7 +648,7 @@ def _extract_city_from_change(self, text: str) -> Optional[str]: "change my location to ", ): if lower.startswith(prefix): - out = text[len(prefix) :].strip() + out = text[len(prefix):].strip() break if out is None and " to " in lower: out = text.split(" to ", 1)[-1].strip() From c8cbc85fac4d63a46b9cd2efabd28de9d5bc7f4f Mon Sep 17 00:00:00 2001 From: Ryan <152874150+RyanBhandal@users.noreply.github.com.> Date: Sat, 21 Feb 2026 15:20:44 +0000 Subject: [PATCH 7/8] update --- community/outlook-daily-brief/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index 10a90605..61029630 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -95,7 +95,7 @@ class OutlookBriefCapability(MatchingCapability): # REGISTRATION & ENTRY # ------------------------------------------------------------------------- - # {{register capability}} + #{{register capability}} def call(self, worker: AgentWorker): self.worker = worker From 4128fcaac7dd82dcb54f81087c4b9213569299b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 15:20:55 +0000 Subject: [PATCH 8/8] style: auto-format Python files with autoflake + autopep8 --- community/outlook-daily-brief/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/community/outlook-daily-brief/main.py b/community/outlook-daily-brief/main.py index 61029630..10a90605 100644 --- a/community/outlook-daily-brief/main.py +++ b/community/outlook-daily-brief/main.py @@ -95,7 +95,7 @@ class OutlookBriefCapability(MatchingCapability): # REGISTRATION & ENTRY # ------------------------------------------------------------------------- - #{{register capability}} + # {{register capability}} def call(self, worker: AgentWorker): self.worker = worker