From 5432523d4ce276d1f2083945e1dc07eead8d95e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20G=C3=BCra?= Date: Mon, 20 Apr 2026 05:59:16 +0000 Subject: [PATCH 1/2] Add Microsoft 365 community app (plugins/omi-ms365-app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-featured Microsoft 365 integration for Omi — Outlook Mail, Outlook Calendar, Microsoft Teams (chats + meetings), SharePoint and OneDrive — exposed as Omi chat tools. Self-contained FastAPI app following the convention of sibling apps in plugins/ (e.g. omi-google-calendar-app, omi-notion-app). Supersedes closed PR #6865, which used the deprecated community-plugins.json entry format. Layout: - main.py FastAPI + tool dispatcher (16 tools) - config.py Settings + Graph scopes - services/ auth, mail, calendar, teams, sharepoint, profile, graph_client, storage - omi-tools.json Tool manifest served at /.well-known/omi-tools.json - Procfile, railway.toml Railway/Render/Heroku deploy - requirements.txt, .env.example, .gitignore --- plugins/omi-ms365-app/.env.example | 15 ++ plugins/omi-ms365-app/.gitignore | 16 ++ plugins/omi-ms365-app/Procfile | 1 + plugins/omi-ms365-app/README.md | 186 ++++++++++++++ plugins/omi-ms365-app/config.py | 53 ++++ plugins/omi-ms365-app/main.py | 238 ++++++++++++++++++ plugins/omi-ms365-app/omi-tools.json | 184 ++++++++++++++ plugins/omi-ms365-app/railway.toml | 7 + plugins/omi-ms365-app/requirements.txt | 11 + plugins/omi-ms365-app/services/__init__.py | 0 plugins/omi-ms365-app/services/auth.py | 109 ++++++++ plugins/omi-ms365-app/services/calendar.py | 103 ++++++++ .../omi-ms365-app/services/graph_client.py | 102 ++++++++ plugins/omi-ms365-app/services/mail.py | 85 +++++++ plugins/omi-ms365-app/services/profile.py | 18 ++ plugins/omi-ms365-app/services/sharepoint.py | 71 ++++++ plugins/omi-ms365-app/services/storage.py | 70 ++++++ plugins/omi-ms365-app/services/teams.py | 63 +++++ 18 files changed, 1332 insertions(+) create mode 100644 plugins/omi-ms365-app/.env.example create mode 100644 plugins/omi-ms365-app/.gitignore create mode 100644 plugins/omi-ms365-app/Procfile create mode 100644 plugins/omi-ms365-app/README.md create mode 100644 plugins/omi-ms365-app/config.py create mode 100644 plugins/omi-ms365-app/main.py create mode 100644 plugins/omi-ms365-app/omi-tools.json create mode 100644 plugins/omi-ms365-app/railway.toml create mode 100644 plugins/omi-ms365-app/requirements.txt create mode 100644 plugins/omi-ms365-app/services/__init__.py create mode 100644 plugins/omi-ms365-app/services/auth.py create mode 100644 plugins/omi-ms365-app/services/calendar.py create mode 100644 plugins/omi-ms365-app/services/graph_client.py create mode 100644 plugins/omi-ms365-app/services/mail.py create mode 100644 plugins/omi-ms365-app/services/profile.py create mode 100644 plugins/omi-ms365-app/services/sharepoint.py create mode 100644 plugins/omi-ms365-app/services/storage.py create mode 100644 plugins/omi-ms365-app/services/teams.py diff --git a/plugins/omi-ms365-app/.env.example b/plugins/omi-ms365-app/.env.example new file mode 100644 index 00000000000..d855f5e45db --- /dev/null +++ b/plugins/omi-ms365-app/.env.example @@ -0,0 +1,15 @@ +# Microsoft Azure App Registration +MICROSOFT_CLIENT_ID=your-client-id-here +MICROSOFT_CLIENT_SECRET=your-client-secret-here +MICROSOFT_TENANT_ID=common +MICROSOFT_REDIRECT_URI=http://localhost:8080/auth/microsoft/callback + +# App config +APP_BASE_URL=http://localhost:8080 +SESSION_SECRET=generate-a-strong-random-string-here + +# Redis (optional; falls back to in-memory store if empty) +REDIS_URL= + +# Logging +LOG_LEVEL=INFO diff --git a/plugins/omi-ms365-app/.gitignore b/plugins/omi-ms365-app/.gitignore new file mode 100644 index 00000000000..78a90673c3f --- /dev/null +++ b/plugins/omi-ms365-app/.gitignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +venv/ +__pycache__/ +*.pyc +*.pyo +.DS_Store +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ +.idea/ +.vscode/ diff --git a/plugins/omi-ms365-app/Procfile b/plugins/omi-ms365-app/Procfile new file mode 100644 index 00000000000..0e048402efc --- /dev/null +++ b/plugins/omi-ms365-app/Procfile @@ -0,0 +1 @@ +web: uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/plugins/omi-ms365-app/README.md b/plugins/omi-ms365-app/README.md new file mode 100644 index 00000000000..4cca9495b8b --- /dev/null +++ b/plugins/omi-ms365-app/README.md @@ -0,0 +1,186 @@ +# Microsoft 365 Omi Integration + +Full-featured Microsoft 365 integration for Omi — bringing Outlook Mail, Outlook +Calendar, Microsoft Teams chats and meetings, SharePoint and OneDrive into +Omi's chat and conversation capabilities. + +## Features + +- **Outlook Mail** — list, search, read, send +- **Outlook Calendar** — list upcoming events, create events (optionally as Teams meetings), find free slots +- **Microsoft Teams** — list chats, send chat messages, list teams, create standalone online meetings +- **OneDrive / SharePoint** — list recent files, search, upload text files, read file content +- **Profile** — `Who am I?` / `/me` +- **OAuth 2.0** with Microsoft Entra ID (MSAL), token caching in Redis (fallback: in-memory) +- **Multi-tenant by default** (configurable) +- **Throttling-aware Graph client** with exponential backoff +- **Automatic manifest** — served at `/.well-known/omi-tools.json` + +## Setup + +### 1. Microsoft Azure App Registration + +1. Go to [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID** → **App registrations** → **+ New registration** +2. Configure: + - **Name**: `Omi Microsoft 365` + - **Account types**: *Accounts in any organizational directory and personal Microsoft accounts* (multi-tenant) + - **Redirect URI**: Web → `http://localhost:8080/auth/microsoft/callback` (adjust after deploy) +3. After creation, note the **Application (client) ID** and **Directory (tenant) ID** (`common` for multi-tenant). +4. Under **Certificates & secrets** → **+ New client secret** → save the **value** (not the id). +5. Under **API permissions** → **+ Add a permission** → **Microsoft Graph** → **Delegated** → add: + +``` +offline_access +User.Read +MailboxSettings.Read +Mail.Read +Mail.Send +Mail.ReadWrite +Calendars.ReadWrite +Chat.ReadWrite +ChannelMessage.Send +OnlineMeetings.ReadWrite +Team.ReadBasic.All +Files.ReadWrite.All +Sites.Read.All +People.Read +Contacts.Read +``` + +Click **Grant admin consent** if you are a tenant admin; otherwise users consent on first sign-in. + +### 2. Deploy + +This app is a vanilla FastAPI service. Any PaaS that supports Python + Redis works (Railway, Render, Fly, Heroku-style). + +**Railway** (matches sibling apps in this repo): + +1. Create a new project on [Railway](https://railway.app/) +2. Deploy from this folder (`plugins/omi-ms365-app`) +3. Add a **Redis** service +4. Set environment variables (see `.env.example`): + + ``` + MICROSOFT_CLIENT_ID=... + MICROSOFT_CLIENT_SECRET=... + MICROSOFT_TENANT_ID=common + MICROSOFT_REDIRECT_URI=https://your-app.up.railway.app/auth/microsoft/callback + APP_BASE_URL=https://your-app.up.railway.app + SESSION_SECRET= + ``` + +5. Railway auto-installs `requirements.txt` and starts via `railway.toml`. +6. After deploy, update Azure → **Authentication** with the final redirect URI. + +### 3. Register with Omi + +Create or update your Omi app and set: + +| Field | Value | +|---|---| +| **Setup URL** | `https://your-app.up.railway.app/setup/ms365?uid={{uid}}` | +| **Setup Completed URL** | `https://your-app.up.railway.app/setup_check?uid={{uid}}` | +| **Chat Tools Manifest URL** | `https://your-app.up.railway.app/.well-known/omi-tools.json` | + +The manifest auto-populates absolute endpoint URLs based on `APP_BASE_URL`. + +## Local development + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +cp .env.example .env +# fill MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, SESSION_SECRET + +uvicorn main:app --reload --port 8080 +``` + +Open `http://localhost:8080/setup/ms365?uid=test-user` to walk the OAuth flow. + +Verify a tool: + +```bash +curl -X POST http://localhost:8080/tools/get_me \ + -H "Content-Type: application/json" \ + -d '{"uid":"test-user","args":{}}' +``` + +## API endpoints + +### Chat tools (POST `/tools/{tool_name}`) + +| Tool | Description | +|---|---| +| `get_me` | Current user profile | +| `list_mail` | List recent mail | +| `search_mail` | Full-text search mail | +| `read_mail` | Read a single message | +| `send_mail` | Send a message | +| `list_calendar_events` | List upcoming events | +| `create_calendar_event` | Create event (optionally a Teams meeting) | +| `find_free_slots` | Find free slots across attendees | +| `list_chats` | List Teams chats | +| `send_chat_message` | Send a Teams chat message | +| `list_teams` | List Teams the user is a member of | +| `create_online_meeting` | Create a standalone Teams meeting | +| `list_recent_files` | Recent OneDrive / SharePoint files | +| `search_files` | Search files across OneDrive / SharePoint | +| `upload_text_file` | Upload a text file | +| `read_file_content` | Read a file's content | + +### OAuth & setup (GET) + +| Endpoint | Purpose | +|---|---| +| `/setup/ms365?uid=...` | Start OAuth flow | +| `/auth/microsoft/callback` | OAuth redirect target | +| `/setup_check?uid=...` | Return `{is_setup_completed: bool}` for Omi | +| `/.well-known/omi-tools.json` | Tool manifest Omi consumes | +| `/webhook/memory` | Memory webhook (no-op placeholder) | + +## Required environment variables + +| Variable | Purpose | +|---|---| +| `MICROSOFT_CLIENT_ID` | Azure App Registration client id | +| `MICROSOFT_CLIENT_SECRET` | Azure App Registration client secret | +| `MICROSOFT_TENANT_ID` | `common` for multi-tenant, or a specific tenant id | +| `MICROSOFT_REDIRECT_URI` | Must match Azure redirect URI exactly | +| `APP_BASE_URL` | Public base URL of this service | +| `SESSION_SECRET` | Random string used to sign OAuth state | +| `REDIS_URL` | Optional — Redis connection URL for token persistence | +| `LOG_LEVEL` | `INFO`, `DEBUG`, etc. | + +## Project layout + +``` +omi-ms365-app/ +├── main.py # FastAPI app + tool dispatch +├── config.py # Settings + Graph scope list +├── omi-tools.json # Tool manifest Omi reads +├── services/ +│ ├── auth.py # Microsoft OAuth + MSAL +│ ├── storage.py # Redis / in-memory token store +│ ├── graph_client.py # Throttling-aware Graph HTTP client +│ ├── profile.py +│ ├── mail.py +│ ├── calendar.py +│ ├── teams.py +│ └── sharepoint.py +├── requirements.txt +├── Procfile +├── railway.toml +└── .env.example +``` + +## Extending + +1. Add a function in `services/.py` with signature `async def foo(user_id: str, ...)`. +2. Register it in `_TOOLS` in `main.py`. +3. Add its declaration to `omi-tools.json`. + +## License + +MIT. diff --git a/plugins/omi-ms365-app/config.py b/plugins/omi-ms365-app/config.py new file mode 100644 index 00000000000..88544bbdf94 --- /dev/null +++ b/plugins/omi-ms365-app/config.py @@ -0,0 +1,53 @@ +"""Application configuration loaded from environment variables.""" +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +# Minimal Graph scope set for a full MS365 integration. +# Adjust in Azure Portal + here in parallel. +GRAPH_SCOPES: list[str] = [ + "offline_access", + "User.Read", + "MailboxSettings.Read", + # Mail + "Mail.Read", + "Mail.Send", + "Mail.ReadWrite", + # Calendar + "Calendars.ReadWrite", + # Teams / Chats / Meetings + "Chat.ReadWrite", + "ChannelMessage.Send", + "OnlineMeetings.ReadWrite", + "Team.ReadBasic.All", + # Files / SharePoint / OneDrive + "Files.ReadWrite.All", + "Sites.Read.All", + # Contacts / People + "People.Read", + "Contacts.Read", +] + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + microsoft_client_id: str + microsoft_client_secret: str + microsoft_tenant_id: str = "common" + microsoft_redirect_uri: str = "http://localhost:8080/auth/microsoft/callback" + + app_base_url: str = "http://localhost:8080" + session_secret: str = "change-me" + redis_url: str | None = None + log_level: str = "INFO" + + @property + def authority(self) -> str: + return f"https://login.microsoftonline.com/{self.microsoft_tenant_id}" + + +@lru_cache +def get_settings() -> Settings: + return Settings() # type: ignore[call-arg] diff --git a/plugins/omi-ms365-app/main.py b/plugins/omi-ms365-app/main.py new file mode 100644 index 00000000000..62f77401901 --- /dev/null +++ b/plugins/omi-ms365-app/main.py @@ -0,0 +1,238 @@ +"""OMI MS365 Plugin — FastAPI entrypoint. + +Exposes: +- / Landing + health +- /setup/ms365?uid= Setup screen for OMI to link a user +- /auth/microsoft Start OAuth +- /auth/microsoft/callback OAuth redirect handler +- /status?uid= Check if a user is connected +- /setup_check?uid= Same as /status — used by OMI registry +- /disconnect?uid= Revoke local token +- /webhook/memory OMI memory_creation webhook (no-op for now) +- /.well-known/omi-tools.json Tool manifest advertised to OMI +- /tools/ Tool execution endpoint called by OMI +""" +from __future__ import annotations + +import logging +import secrets +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from itsdangerous import BadSignature, URLSafeSerializer + +from config import get_settings +from services import auth, mail, profile +from services import calendar as cal +from services import teams as teams_svc +from services import sharepoint as sp + +logging.basicConfig(level=get_settings().log_level) +log = logging.getLogger("omi-ms365") + +app = FastAPI(title="OMI MS365 Plugin") +_signer = URLSafeSerializer(get_settings().session_secret, salt="oauth-state") + + +# --------------------------------------------------------------------------- +# Setup + OAuth flow +# --------------------------------------------------------------------------- + +@app.get("/", response_class=HTMLResponse) +async def root() -> str: + return """ + +

OMI MS365 Plugin

+

This is the backend for the OMI Microsoft 365 integration (Outlook, Teams, SharePoint, OneDrive).

+

Plugin status: running

+

Users activate the plugin from within the OMI app.

+ + """ + + +@app.get("/setup/ms365", response_class=HTMLResponse) +async def setup_page(uid: str = Query(..., description="OMI user id")) -> str: + """OMI loads this page inside its in-app webview when the user taps 'Setup'.""" + redirect = f"/auth/microsoft?uid={uid}" + return f""" + +

Connect Microsoft 365

+

This will let OMI read and act on your Outlook, Teams and SharePoint data + on your behalf. You can revoke access at any time.

+

Connect with Microsoft →

+ + """ + + +@app.get("/auth/microsoft") +async def auth_start(uid: str = Query(...)) -> RedirectResponse: + state = _signer.dumps({"uid": uid, "nonce": secrets.token_urlsafe(16)}) + url = auth.build_auth_url(state) + return RedirectResponse(url) + + +@app.get("/auth/microsoft/callback") +async def auth_callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, + error_description: str | None = None, +) -> HTMLResponse: + if error: + return HTMLResponse( + f"

Authorization failed

{error}: {error_description}
", + status_code=400, + ) + if not code or not state: + raise HTTPException(400, "Missing code or state") + + try: + payload = _signer.loads(state) + except BadSignature: + raise HTTPException(400, "Invalid state") + + uid: str = payload["uid"] + await auth.exchange_code_for_token(code, uid) + return HTMLResponse( + """ + +

✓ Connected

+

You can close this tab and return to OMI.

+ + """ + ) + + +@app.get("/status") +async def status(uid: str = Query(...)) -> dict[str, Any]: + try: + tok = await auth.get_access_token(uid) + return {"connected": bool(tok)} + except auth.AuthError: + return {"connected": False} + + +@app.get("/setup_check") +async def setup_check(uid: str = Query(...)) -> dict[str, Any]: + """Alias for /status — OMI registry calls this to verify setup is complete.""" + try: + tok = await auth.get_access_token(uid) + return {"is_setup_completed": bool(tok)} + except auth.AuthError: + return {"is_setup_completed": False} + + +@app.post("/disconnect") +async def disconnect(uid: str = Query(...)) -> dict[str, Any]: + await auth.disconnect(uid) + return {"status": "disconnected"} + + +# --------------------------------------------------------------------------- +# OMI memory_creation webhook +# --------------------------------------------------------------------------- + +@app.post("/webhook/memory") +async def memory_webhook(request: Request, uid: str | None = Query(default=None)) -> dict[str, Any]: + """OMI posts created memories here so the plugin can react to them. + + For now this is an acknowledgement endpoint — the MS365 tools are invoked + explicitly by the assistant via /tools/. Future revisions may use + this hook to auto-archive memories to OneDrive or create calendar events + mentioned in transcripts. + """ + try: + payload = await request.json() + except Exception: + payload = {} + if not uid: + uid = payload.get("uid") or (payload.get("user") or {}).get("id") + log.info("memory webhook uid=%s keys=%s", uid, list(payload.keys())) + return {"received": True} + + +# --------------------------------------------------------------------------- +# OMI tool manifest +# --------------------------------------------------------------------------- + +MANIFEST_PATH = Path(__file__).parent / "omi-tools.json" + + +@app.get("/.well-known/omi-tools.json") +async def tool_manifest() -> JSONResponse: + return JSONResponse(content=_load_manifest()) + + +def _load_manifest() -> dict[str, Any]: + import json + with open(MANIFEST_PATH) as f: + manifest = json.load(f) + base = get_settings().app_base_url.rstrip("/") + # Inject absolute URLs so OMI doesn't need to guess + for tool in manifest.get("tools", []): + tool["endpoint"] = f"{base}/tools/{tool['name']}" + manifest["setup_url"] = f"{base}/setup/ms365" + return manifest + + +# --------------------------------------------------------------------------- +# Tool dispatch +# --------------------------------------------------------------------------- + +async def _auth_guard(uid: str) -> None: + try: + await auth.get_access_token(uid) + except auth.AuthError as e: + raise HTTPException(401, f"Microsoft not connected — {e}") + + +@app.post("/tools/{tool_name}") +async def tool_dispatch(tool_name: str, request: Request) -> Any: + body: dict[str, Any] = {} + try: + body = await request.json() + except Exception: + pass + uid: str | None = body.get("uid") or request.query_params.get("uid") + if not uid: + raise HTTPException(400, "uid (OMI user id) is required") + args: dict[str, Any] = body.get("args", {}) or {} + + await _auth_guard(uid) + + try: + return await _TOOLS[tool_name](uid, **args) + except KeyError: + raise HTTPException(404, f"Unknown tool: {tool_name}") + except TypeError as e: + raise HTTPException(400, f"Bad arguments for {tool_name}: {e}") + + +# tool_name -> coroutine(uid, **args) +_TOOLS: dict[str, Any] = { + # profile + "get_me": profile.me, + # mail + "list_recent_emails": mail.list_recent, + "search_emails": mail.search, + "read_email": mail.read, + "send_email": mail.send, + # calendar + "list_upcoming_events": cal.list_upcoming, + "create_event": cal.create_event, + "find_free_slots": cal.find_free_slots, + # teams + "list_recent_chats": teams_svc.list_recent_chats, + "send_chat_message": teams_svc.send_chat_message, + "list_my_teams": teams_svc.list_my_teams, + "create_online_meeting": teams_svc.create_online_meeting, + # sharepoint / onedrive + "list_recent_files": sp.list_recent_files, + "search_files": sp.search_files, + "upload_text_file": sp.upload_text_file, + "read_file_text": sp.read_file_text, +} diff --git a/plugins/omi-ms365-app/omi-tools.json b/plugins/omi-ms365-app/omi-tools.json new file mode 100644 index 00000000000..f3d19a91489 --- /dev/null +++ b/plugins/omi-ms365-app/omi-tools.json @@ -0,0 +1,184 @@ +{ + "name": "ms365", + "version": "0.1.0", + "display_name": "Microsoft 365", + "description": "Read and act on your Outlook mail, calendar, Teams chats and SharePoint/OneDrive files via OMI.", + "auth": { + "type": "oauth2", + "provider": "microsoft" + }, + "tools": [ + { + "name": "get_me", + "description": "Get the connected Microsoft user's profile (display name, mail, job title).", + "parameters": {"type": "object", "properties": {}} + }, + { + "name": "list_recent_emails", + "description": "List the user's most recent Outlook emails. Use unread_only=true to focus on unread.", + "parameters": { + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 10}, + "unread_only": {"type": "boolean", "default": false} + } + } + }, + { + "name": "search_emails", + "description": "Search Outlook emails by free-text query.", + "parameters": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 10} + } + } + }, + { + "name": "read_email", + "description": "Read the full body of a specific email by id.", + "parameters": { + "type": "object", + "required": ["message_id"], + "properties": {"message_id": {"type": "string"}} + } + }, + { + "name": "send_email", + "description": "Send a new Outlook email.", + "parameters": { + "type": "object", + "required": ["to", "subject", "body"], + "properties": { + "to": {"type": "array", "items": {"type": "string"}}, + "subject": {"type": "string"}, + "body": {"type": "string"}, + "body_type": {"type": "string", "enum": ["Text", "HTML"], "default": "Text"}, + "cc": {"type": "array", "items": {"type": "string"}} + } + } + }, + { + "name": "list_upcoming_events", + "description": "List the user's upcoming calendar events for the next N days.", + "parameters": { + "type": "object", + "properties": { + "days": {"type": "integer", "default": 1} + } + } + }, + { + "name": "create_event", + "description": "Create a calendar event, optionally as a Teams online meeting.", + "parameters": { + "type": "object", + "required": ["subject", "start_iso", "end_iso"], + "properties": { + "subject": {"type": "string"}, + "start_iso": {"type": "string", "description": "ISO 8601 local datetime"}, + "end_iso": {"type": "string"}, + "attendees": {"type": "array", "items": {"type": "string"}}, + "body": {"type": "string", "default": ""}, + "online": {"type": "boolean", "default": true}, + "timezone_str": {"type": "string", "default": "Europe/Vienna"} + } + } + }, + { + "name": "find_free_slots", + "description": "Find common free time slots for a list of attendees.", + "parameters": { + "type": "object", + "required": ["duration_minutes", "attendees"], + "properties": { + "duration_minutes": {"type": "integer"}, + "attendees": {"type": "array", "items": {"type": "string"}}, + "within_days": {"type": "integer", "default": 5} + } + } + }, + { + "name": "list_recent_chats", + "description": "List the user's recent Teams chats.", + "parameters": { + "type": "object", + "properties": {"limit": {"type": "integer", "default": 15}} + } + }, + { + "name": "send_chat_message", + "description": "Send a Teams chat message to a specific chat id.", + "parameters": { + "type": "object", + "required": ["chat_id", "message"], + "properties": { + "chat_id": {"type": "string"}, + "message": {"type": "string"} + } + } + }, + { + "name": "list_my_teams", + "description": "List the Teams (groups) the user is a member of.", + "parameters": {"type": "object", "properties": {}} + }, + { + "name": "create_online_meeting", + "description": "Create a standalone Teams online meeting and return the join URL.", + "parameters": { + "type": "object", + "required": ["subject", "start_iso", "end_iso"], + "properties": { + "subject": {"type": "string"}, + "start_iso": {"type": "string"}, + "end_iso": {"type": "string"} + } + } + }, + { + "name": "list_recent_files", + "description": "List recently used files in OneDrive / SharePoint.", + "parameters": { + "type": "object", + "properties": {"limit": {"type": "integer", "default": 15}} + } + }, + { + "name": "search_files", + "description": "Search files in OneDrive by name / content.", + "parameters": { + "type": "object", + "required": ["query"], + "properties": { + "query": {"type": "string"}, + "limit": {"type": "integer", "default": 15} + } + } + }, + { + "name": "upload_text_file", + "description": "Upload a small text file (e.g. a transcript) to OneDrive at the given folder path.", + "parameters": { + "type": "object", + "required": ["folder_path", "filename", "content"], + "properties": { + "folder_path": {"type": "string", "description": "Folder relative to OneDrive root, e.g. 'Documents/OMI'"}, + "filename": {"type": "string"}, + "content": {"type": "string"} + } + } + }, + { + "name": "read_file_text", + "description": "Read the text content of a file in OneDrive by item id.", + "parameters": { + "type": "object", + "required": ["item_id"], + "properties": {"item_id": {"type": "string"}} + } + } + ] +} diff --git a/plugins/omi-ms365-app/railway.toml b/plugins/omi-ms365-app/railway.toml new file mode 100644 index 00000000000..6573527d5b3 --- /dev/null +++ b/plugins/omi-ms365-app/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT" +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/plugins/omi-ms365-app/requirements.txt b/plugins/omi-ms365-app/requirements.txt new file mode 100644 index 00000000000..cec730f0a20 --- /dev/null +++ b/plugins/omi-ms365-app/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +httpx==0.27.2 +msal==1.31.0 +redis==5.1.1 +pydantic==2.9.2 +pydantic-settings==2.5.2 +python-dotenv==1.0.1 +itsdangerous==2.2.0 +jinja2==3.1.4 +python-multipart==0.0.12 diff --git a/plugins/omi-ms365-app/services/__init__.py b/plugins/omi-ms365-app/services/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/plugins/omi-ms365-app/services/auth.py b/plugins/omi-ms365-app/services/auth.py new file mode 100644 index 00000000000..f7012ce1944 --- /dev/null +++ b/plugins/omi-ms365-app/services/auth.py @@ -0,0 +1,109 @@ +"""Microsoft OAuth + MSAL token handling. + +One MSAL ConfidentialClientApplication per process, token caches are per-user +and persisted via the TokenStore abstraction. +""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlencode + +from msal import ConfidentialClientApplication, SerializableTokenCache + +from config import GRAPH_SCOPES, get_settings +from services.storage import get_store + +log = logging.getLogger(__name__) + + +class AuthError(Exception): + """Raised when the user has no valid token or auth failed.""" + + +def _build_msal_app(cache: SerializableTokenCache | None = None) -> ConfidentialClientApplication: + settings = get_settings() + return ConfidentialClientApplication( + client_id=settings.microsoft_client_id, + client_credential=settings.microsoft_client_secret, + authority=settings.authority, + token_cache=cache, + ) + + +async def _load_cache(user_id: str) -> SerializableTokenCache: + cache = SerializableTokenCache() + blob = await get_store().get(user_id) + if blob: + cache.deserialize(blob) + return cache + + +async def _save_cache_if_dirty(user_id: str, cache: SerializableTokenCache) -> None: + if cache.has_state_changed: + await get_store().set(user_id, cache.serialize()) + + +def build_auth_url(state: str) -> str: + """Builds the Microsoft login URL the browser should redirect to.""" + settings = get_settings() + app = _build_msal_app() + return app.get_authorization_request_url( + scopes=[s for s in GRAPH_SCOPES if s != "offline_access"], + state=state, + redirect_uri=settings.microsoft_redirect_uri, + prompt="select_account", + ) + + +async def exchange_code_for_token(code: str, user_id: str) -> dict[str, Any]: + """Exchanges an OAuth code for tokens and persists them for the user.""" + settings = get_settings() + cache = await _load_cache(user_id) + app = _build_msal_app(cache) + result = app.acquire_token_by_authorization_code( + code, + scopes=[s for s in GRAPH_SCOPES if s != "offline_access"], + redirect_uri=settings.microsoft_redirect_uri, + ) + if "error" in result: + raise AuthError( + f"Token exchange failed: {result.get('error')} — {result.get('error_description')}" + ) + await _save_cache_if_dirty(user_id, cache) + return result + + +async def get_access_token(user_id: str) -> str: + """Returns a valid access token for the given OMI user. + + Uses MSAL's silent flow. If no refresh token is available, raises AuthError + so the caller can prompt the user to reconnect. + """ + cache = await _load_cache(user_id) + app = _build_msal_app(cache) + + accounts = app.get_accounts() + if not accounts: + raise AuthError("No Microsoft account linked — user must reconnect.") + + result = app.acquire_token_silent( + scopes=[s for s in GRAPH_SCOPES if s != "offline_access"], + account=accounts[0], + ) + if not result or "access_token" not in result: + raise AuthError("Silent token acquisition failed — user must reconnect.") + + await _save_cache_if_dirty(user_id, cache) + return result["access_token"] + + +async def disconnect(user_id: str) -> None: + await get_store().delete(user_id) + + +def build_setup_redirect(user_id: str) -> str: + """Produces the URL the OMI setup screen should send the user to.""" + settings = get_settings() + qs = urlencode({"uid": user_id}) + return f"{settings.app_base_url}/auth/microsoft?{qs}" diff --git a/plugins/omi-ms365-app/services/calendar.py b/plugins/omi-ms365-app/services/calendar.py new file mode 100644 index 00000000000..b9eb600f35e --- /dev/null +++ b/plugins/omi-ms365-app/services/calendar.py @@ -0,0 +1,103 @@ +"""Outlook Calendar operations via Microsoft Graph.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any + +from services.graph_client import GraphClient + + +def _slim_event(e: dict[str, Any]) -> dict[str, Any]: + return { + "id": e.get("id"), + "subject": e.get("subject"), + "start": (e.get("start") or {}).get("dateTime"), + "end": (e.get("end") or {}).get("dateTime"), + "tz": (e.get("start") or {}).get("timeZone"), + "location": (e.get("location") or {}).get("displayName"), + "organizer": (e.get("organizer") or {}).get("emailAddress", {}).get("address"), + "is_online": e.get("isOnlineMeeting"), + "join_url": e.get("onlineMeeting", {}).get("joinUrl") if e.get("onlineMeeting") else None, + "web_link": e.get("webLink"), + } + + +async def list_upcoming(user_id: str, days: int = 1) -> list[dict[str, Any]]: + start = datetime.now(timezone.utc) + end = start + timedelta(days=days) + async with GraphClient(user_id) as g: + data = await g.get( + "/me/calendarView", + params={ + "startDateTime": start.isoformat(), + "endDateTime": end.isoformat(), + "$orderby": "start/dateTime", + "$top": 50, + }, + ) + return [_slim_event(e) for e in data.get("value", [])] + + +async def create_event( + user_id: str, + subject: str, + start_iso: str, + end_iso: str, + *, + attendees: list[str] | None = None, + body: str = "", + online: bool = True, + timezone_str: str = "Europe/Vienna", +) -> dict[str, Any]: + payload: dict[str, Any] = { + "subject": subject, + "body": {"contentType": "HTML", "content": body}, + "start": {"dateTime": start_iso, "timeZone": timezone_str}, + "end": {"dateTime": end_iso, "timeZone": timezone_str}, + "isOnlineMeeting": online, + "onlineMeetingProvider": "teamsForBusiness" if online else None, + } + if attendees: + payload["attendees"] = [ + {"emailAddress": {"address": a}, "type": "required"} for a in attendees + ] + + async with GraphClient(user_id) as g: + data = await g.post("/me/events", json=payload) + return _slim_event(data) + + +async def find_free_slots( + user_id: str, + duration_minutes: int, + attendees: list[str], + *, + within_days: int = 5, +) -> list[dict[str, Any]]: + start = datetime.now(timezone.utc) + end = start + timedelta(days=within_days) + payload = { + "attendees": [ + {"emailAddress": {"address": a}, "type": "required"} for a in attendees + ], + "timeConstraint": { + "timeslots": [ + { + "start": {"dateTime": start.isoformat(), "timeZone": "UTC"}, + "end": {"dateTime": end.isoformat(), "timeZone": "UTC"}, + } + ] + }, + "meetingDuration": f"PT{duration_minutes}M", + "maxCandidates": 10, + } + async with GraphClient(user_id) as g: + data = await g.post("/me/findMeetingTimes", json=payload) + return [ + { + "start": s["meetingTimeSlot"]["start"]["dateTime"], + "end": s["meetingTimeSlot"]["end"]["dateTime"], + "confidence": s.get("confidence"), + } + for s in data.get("meetingTimeSuggestions", []) + ] diff --git a/plugins/omi-ms365-app/services/graph_client.py b/plugins/omi-ms365-app/services/graph_client.py new file mode 100644 index 00000000000..8b05c578db9 --- /dev/null +++ b/plugins/omi-ms365-app/services/graph_client.py @@ -0,0 +1,102 @@ +"""Thin async Microsoft Graph client with throttling-aware retry.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import httpx + +from services.auth import get_access_token + +log = logging.getLogger(__name__) + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" +MAX_RETRIES = 3 + + +class GraphError(Exception): + def __init__(self, status: int, payload: Any) -> None: + super().__init__(f"Graph {status}: {payload}") + self.status = status + self.payload = payload + + +class GraphClient: + def __init__(self, user_id: str) -> None: + self.user_id = user_id + self._client = httpx.AsyncClient(timeout=30.0) + + async def __aenter__(self) -> "GraphClient": + return self + + async def __aexit__(self, *_: Any) -> None: + await self._client.aclose() + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json: Any = None, + expect_json: bool = True, + ) -> Any: + url = path if path.startswith("http") else f"{GRAPH_BASE}{path}" + + for attempt in range(MAX_RETRIES): + token = await get_access_token(self.user_id) + headers = {"Authorization": f"Bearer {token}"} + if json is not None: + headers["Content-Type"] = "application/json" + + resp = await self._client.request( + method, url, headers=headers, params=params, json=json + ) + + # Throttling: honour Retry-After + if resp.status_code == 429 or resp.status_code >= 500: + if attempt == MAX_RETRIES - 1: + raise GraphError(resp.status_code, resp.text) + retry_after = int(resp.headers.get("Retry-After", "2")) + backoff = min(retry_after, 2 ** attempt + 1) + log.warning("Graph %s on %s — backing off %ss", resp.status_code, path, backoff) + await asyncio.sleep(backoff) + continue + + if resp.status_code >= 400: + payload: Any + try: + payload = resp.json() + except Exception: + payload = resp.text + raise GraphError(resp.status_code, payload) + + if not expect_json or resp.status_code == 204: + return None + return resp.json() + + raise GraphError(0, "Exhausted retries without response") + + # Public convenience wrappers --------------------------------------------- + + async def get(self, path: str, **kw: Any) -> Any: + return await self._request("GET", path, **kw) + + async def post(self, path: str, json: Any, **kw: Any) -> Any: + return await self._request("POST", path, json=json, **kw) + + async def patch(self, path: str, json: Any, **kw: Any) -> Any: + return await self._request("PATCH", path, json=json, **kw) + + async def delete(self, path: str, **kw: Any) -> Any: + return await self._request("DELETE", path, expect_json=False, **kw) + + async def put_bytes(self, path: str, data: bytes, content_type: str = "application/octet-stream") -> Any: + token = await get_access_token(self.user_id) + headers = {"Authorization": f"Bearer {token}", "Content-Type": content_type} + url = path if path.startswith("http") else f"{GRAPH_BASE}{path}" + resp = await self._client.put(url, headers=headers, content=data) + if resp.status_code >= 400: + raise GraphError(resp.status_code, resp.text) + return resp.json() if resp.content else None diff --git a/plugins/omi-ms365-app/services/mail.py b/plugins/omi-ms365-app/services/mail.py new file mode 100644 index 00000000000..0ceeaa12af8 --- /dev/null +++ b/plugins/omi-ms365-app/services/mail.py @@ -0,0 +1,85 @@ +"""Outlook / Exchange mail operations via Microsoft Graph.""" +from __future__ import annotations + +from typing import Any + +from services.graph_client import GraphClient + + +def _slim_message(m: dict[str, Any]) -> dict[str, Any]: + return { + "id": m.get("id"), + "subject": m.get("subject"), + "from": (m.get("from") or {}).get("emailAddress", {}), + "received": m.get("receivedDateTime"), + "preview": m.get("bodyPreview"), + "is_read": m.get("isRead"), + "has_attachments": m.get("hasAttachments"), + "web_link": m.get("webLink"), + } + + +async def list_recent(user_id: str, limit: int = 10, unread_only: bool = False) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + params: dict[str, Any] = { + "$top": limit, + "$orderby": "receivedDateTime desc", + "$select": "id,subject,from,receivedDateTime,bodyPreview,isRead,hasAttachments,webLink", + } + if unread_only: + params["$filter"] = "isRead eq false" + data = await g.get("/me/messages", params=params) + return [_slim_message(m) for m in data.get("value", [])] + + +async def search(user_id: str, query: str, limit: int = 10) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + data = await g.get( + "/me/messages", + params={ + "$search": f'"{query}"', + "$top": limit, + "$select": "id,subject,from,receivedDateTime,bodyPreview,isRead,hasAttachments,webLink", + }, + ) + return [_slim_message(m) for m in data.get("value", [])] + + +async def read(user_id: str, message_id: str) -> dict[str, Any]: + async with GraphClient(user_id) as g: + m = await g.get(f"/me/messages/{message_id}") + return { + **_slim_message(m), + "body": (m.get("body") or {}).get("content"), + "body_type": (m.get("body") or {}).get("contentType"), + "to": [r["emailAddress"] for r in m.get("toRecipients", [])], + "cc": [r["emailAddress"] for r in m.get("ccRecipients", [])], + } + + +async def send( + user_id: str, + to: list[str], + subject: str, + body: str, + *, + body_type: str = "Text", + cc: list[str] | None = None, +) -> dict[str, Any]: + def addr_list(emails: list[str]) -> list[dict[str, Any]]: + return [{"emailAddress": {"address": e}} for e in emails] + + payload: dict[str, Any] = { + "message": { + "subject": subject, + "body": {"contentType": body_type, "content": body}, + "toRecipients": addr_list(to), + }, + "saveToSentItems": True, + } + if cc: + payload["message"]["ccRecipients"] = addr_list(cc) + + async with GraphClient(user_id) as g: + await g.post("/me/sendMail", json=payload, expect_json=False) + return {"status": "sent", "to": to, "subject": subject} diff --git a/plugins/omi-ms365-app/services/profile.py b/plugins/omi-ms365-app/services/profile.py new file mode 100644 index 00000000000..f3eb4d458e2 --- /dev/null +++ b/plugins/omi-ms365-app/services/profile.py @@ -0,0 +1,18 @@ +"""User profile helper.""" +from __future__ import annotations + +from typing import Any + +from services.graph_client import GraphClient + + +async def me(user_id: str) -> dict[str, Any]: + async with GraphClient(user_id) as g: + data = await g.get("/me") + return { + "id": data.get("id"), + "display_name": data.get("displayName"), + "mail": data.get("mail") or data.get("userPrincipalName"), + "job_title": data.get("jobTitle"), + "preferred_language": data.get("preferredLanguage"), + } diff --git a/plugins/omi-ms365-app/services/sharepoint.py b/plugins/omi-ms365-app/services/sharepoint.py new file mode 100644 index 00000000000..9241801b748 --- /dev/null +++ b/plugins/omi-ms365-app/services/sharepoint.py @@ -0,0 +1,71 @@ +"""SharePoint + OneDrive file operations via Microsoft Graph.""" +from __future__ import annotations + +from typing import Any + +from services.graph_client import GraphClient + + +def _slim_item(it: dict[str, Any]) -> dict[str, Any]: + return { + "id": it.get("id"), + "name": it.get("name"), + "size": it.get("size"), + "modified": it.get("lastModifiedDateTime"), + "web_url": it.get("webUrl"), + "folder": "folder" in it, + "mime": (it.get("file") or {}).get("mimeType"), + } + + +async def list_recent_files(user_id: str, limit: int = 15) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + data = await g.get("/me/drive/recent", params={"$top": limit}) + return [_slim_item(i) for i in data.get("value", [])] + + +async def search_files(user_id: str, query: str, limit: int = 15) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + data = await g.get( + f"/me/drive/root/search(q='{query}')", + params={"$top": limit}, + ) + return [_slim_item(i) for i in data.get("value", [])] + + +async def upload_text_file( + user_id: str, + folder_path: str, + filename: str, + content: str, +) -> dict[str, Any]: + """Simple upload via Graph PUT (for files <4MB). + + folder_path: e.g. "Documents/OMI-Notes" (relative to OneDrive root). + """ + folder_path = folder_path.strip("/") + path = f"/me/drive/root:/{folder_path}/{filename}:/content" + async with GraphClient(user_id) as g: + data = await g.put_bytes(path, content.encode("utf-8"), content_type="text/plain") + return _slim_item(data) if data else {"status": "uploaded", "name": filename} + + +async def read_file_text(user_id: str, item_id: str) -> dict[str, Any]: + async with GraphClient(user_id) as g: + meta = await g.get(f"/me/drive/items/{item_id}") + # Download content via /content endpoint + import httpx + from services.auth import get_access_token + + token = await get_access_token(user_id) + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.get( + f"https://graph.microsoft.com/v1.0/me/drive/items/{item_id}/content", + headers={"Authorization": f"Bearer {token}"}, + ) + resp.raise_for_status() + try: + text = resp.content.decode("utf-8") + except UnicodeDecodeError: + text = f"" + return {**_slim_item(meta), "content": text} diff --git a/plugins/omi-ms365-app/services/storage.py b/plugins/omi-ms365-app/services/storage.py new file mode 100644 index 00000000000..93e6bb95cd6 --- /dev/null +++ b/plugins/omi-ms365-app/services/storage.py @@ -0,0 +1,70 @@ +"""Token store: Redis if REDIS_URL is set, else in-memory. + +Stores the MSAL SerializableTokenCache JSON blob keyed by the OMI user id. +In-memory mode is only for local dev — tokens are lost on restart. +""" +from __future__ import annotations + +import json +import logging +from typing import Protocol + +import redis.asyncio as redis + +from config import get_settings + +log = logging.getLogger(__name__) + +_KEY_PREFIX = "omi:ms365:cache:" + + +class TokenStore(Protocol): + async def get(self, user_id: str) -> str | None: ... + async def set(self, user_id: str, cache_blob: str) -> None: ... + async def delete(self, user_id: str) -> None: ... + + +class InMemoryStore: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + async def get(self, user_id: str) -> str | None: + return self._data.get(user_id) + + async def set(self, user_id: str, cache_blob: str) -> None: + self._data[user_id] = cache_blob + + async def delete(self, user_id: str) -> None: + self._data.pop(user_id, None) + + +class RedisStore: + def __init__(self, url: str) -> None: + self._client: redis.Redis = redis.from_url(url, decode_responses=True) + + async def get(self, user_id: str) -> str | None: + return await self._client.get(_KEY_PREFIX + user_id) + + async def set(self, user_id: str, cache_blob: str) -> None: + # 90 days TTL — refresh tokens are long-lived but rotate them periodically. + await self._client.set(_KEY_PREFIX + user_id, cache_blob, ex=90 * 24 * 3600) + + async def delete(self, user_id: str) -> None: + await self._client.delete(_KEY_PREFIX + user_id) + + +_store: TokenStore | None = None + + +def get_store() -> TokenStore: + global _store + if _store is not None: + return _store + settings = get_settings() + if settings.redis_url: + log.info("Using Redis token store") + _store = RedisStore(settings.redis_url) + else: + log.warning("No REDIS_URL set — using in-memory token store (dev only)") + _store = InMemoryStore() + return _store diff --git a/plugins/omi-ms365-app/services/teams.py b/plugins/omi-ms365-app/services/teams.py new file mode 100644 index 00000000000..acb8dac7321 --- /dev/null +++ b/plugins/omi-ms365-app/services/teams.py @@ -0,0 +1,63 @@ +"""Microsoft Teams operations: chats, messages, online meetings.""" +from __future__ import annotations + +from typing import Any + +from services.graph_client import GraphClient + + +async def list_recent_chats(user_id: str, limit: int = 15) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + data = await g.get("/me/chats", params={"$top": limit, "$orderby": "lastMessagePreview/createdDateTime desc"}) + return [ + { + "id": c.get("id"), + "topic": c.get("topic"), + "chat_type": c.get("chatType"), + "last_updated": c.get("lastUpdatedDateTime"), + "web_url": c.get("webUrl"), + } + for c in data.get("value", []) + ] + + +async def send_chat_message(user_id: str, chat_id: str, message: str) -> dict[str, Any]: + payload = {"body": {"contentType": "text", "content": message}} + async with GraphClient(user_id) as g: + data = await g.post(f"/chats/{chat_id}/messages", json=payload) + return {"id": data.get("id"), "chat_id": chat_id, "status": "sent"} + + +async def list_my_teams(user_id: str) -> list[dict[str, Any]]: + async with GraphClient(user_id) as g: + data = await g.get("/me/joinedTeams") + return [ + { + "id": t.get("id"), + "name": t.get("displayName"), + "description": t.get("description"), + } + for t in data.get("value", []) + ] + + +async def create_online_meeting( + user_id: str, + subject: str, + start_iso: str, + end_iso: str, +) -> dict[str, Any]: + payload = { + "subject": subject, + "startDateTime": start_iso, + "endDateTime": end_iso, + } + async with GraphClient(user_id) as g: + data = await g.post("/me/onlineMeetings", json=payload) + return { + "id": data.get("id"), + "subject": data.get("subject"), + "join_url": data.get("joinWebUrl"), + "start": data.get("startDateTime"), + "end": data.get("endDateTime"), + } From f8f9aa5eb2a94e5fe374c97561dcf6244d862378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patrick=20G=C3=BCra?= Date: Mon, 20 Apr 2026 06:42:58 +0000 Subject: [PATCH 2/2] Address greptile + codex review feedback on #6879 - graph_client: add get_bytes() helper that reuses session + throttling - sharepoint.search_files: escape single quotes in OData query (avoid injection) - sharepoint.read_file_text: use GraphClient.get_bytes() instead of bare httpx so throttling/retry apply; move imports to top - calendar.create_event: default timezone_str to 'UTC' (was 'Europe/Vienna') - config: make session_secret required (no default) so app fails fast if unset - main: move 'import json' to module top, split _TOOLS lookup from handler call so KeyError from unknown tool doesn't mask bugs inside handlers --- plugins/omi-ms365-app/config.py | 2 +- plugins/omi-ms365-app/main.py | 6 ++-- plugins/omi-ms365-app/services/calendar.py | 2 +- .../omi-ms365-app/services/graph_client.py | 30 +++++++++++++++++++ plugins/omi-ms365-app/services/sharepoint.py | 25 ++++++---------- 5 files changed, 45 insertions(+), 20 deletions(-) diff --git a/plugins/omi-ms365-app/config.py b/plugins/omi-ms365-app/config.py index 88544bbdf94..66ca130912b 100644 --- a/plugins/omi-ms365-app/config.py +++ b/plugins/omi-ms365-app/config.py @@ -39,7 +39,7 @@ class Settings(BaseSettings): microsoft_redirect_uri: str = "http://localhost:8080/auth/microsoft/callback" app_base_url: str = "http://localhost:8080" - session_secret: str = "change-me" + session_secret: str redis_url: str | None = None log_level: str = "INFO" diff --git a/plugins/omi-ms365-app/main.py b/plugins/omi-ms365-app/main.py index 62f77401901..2458b0e5fd2 100644 --- a/plugins/omi-ms365-app/main.py +++ b/plugins/omi-ms365-app/main.py @@ -14,6 +14,7 @@ """ from __future__ import annotations +import json import logging import secrets from pathlib import Path @@ -168,7 +169,6 @@ async def tool_manifest() -> JSONResponse: def _load_manifest() -> dict[str, Any]: - import json with open(MANIFEST_PATH) as f: manifest = json.load(f) base = get_settings().app_base_url.rstrip("/") @@ -205,9 +205,11 @@ async def tool_dispatch(tool_name: str, request: Request) -> Any: await _auth_guard(uid) try: - return await _TOOLS[tool_name](uid, **args) + handler = _TOOLS[tool_name] except KeyError: raise HTTPException(404, f"Unknown tool: {tool_name}") + try: + return await handler(uid, **args) except TypeError as e: raise HTTPException(400, f"Bad arguments for {tool_name}: {e}") diff --git a/plugins/omi-ms365-app/services/calendar.py b/plugins/omi-ms365-app/services/calendar.py index b9eb600f35e..53f0a171732 100644 --- a/plugins/omi-ms365-app/services/calendar.py +++ b/plugins/omi-ms365-app/services/calendar.py @@ -47,7 +47,7 @@ async def create_event( attendees: list[str] | None = None, body: str = "", online: bool = True, - timezone_str: str = "Europe/Vienna", + timezone_str: str = "UTC", ) -> dict[str, Any]: payload: dict[str, Any] = { "subject": subject, diff --git a/plugins/omi-ms365-app/services/graph_client.py b/plugins/omi-ms365-app/services/graph_client.py index 8b05c578db9..e9d2aa99da6 100644 --- a/plugins/omi-ms365-app/services/graph_client.py +++ b/plugins/omi-ms365-app/services/graph_client.py @@ -100,3 +100,33 @@ async def put_bytes(self, path: str, data: bytes, content_type: str = "applicati if resp.status_code >= 400: raise GraphError(resp.status_code, resp.text) return resp.json() if resp.content else None + + + async def get_bytes(self, path: str) -> bytes: + """GET raw bytes — used for file content downloads. + + Reuses this client's session + auth so callers don't bypass + throttling/retry by instantiating their own httpx.AsyncClient. + """ + url = path if path.startswith("http") else f"{GRAPH_BASE}{path}" + + for attempt in range(MAX_RETRIES): + token = await get_access_token(self.user_id) + headers = {"Authorization": f"Bearer {token}"} + resp = await self._client.get(url, headers=headers) + + if resp.status_code == 429 or resp.status_code >= 500: + if attempt == MAX_RETRIES - 1: + raise GraphError(resp.status_code, resp.text) + retry_after = int(resp.headers.get("Retry-After", "2")) + backoff = min(retry_after, 2 ** attempt + 1) + log.warning("Graph %s on %s — backing off %ss", resp.status_code, path, backoff) + await asyncio.sleep(backoff) + continue + + if resp.status_code >= 400: + raise GraphError(resp.status_code, resp.text) + return resp.content + + raise GraphError(0, "Exhausted retries without response") + diff --git a/plugins/omi-ms365-app/services/sharepoint.py b/plugins/omi-ms365-app/services/sharepoint.py index 9241801b748..8b5903ca24b 100644 --- a/plugins/omi-ms365-app/services/sharepoint.py +++ b/plugins/omi-ms365-app/services/sharepoint.py @@ -25,9 +25,11 @@ async def list_recent_files(user_id: str, limit: int = 15) -> list[dict[str, Any async def search_files(user_id: str, query: str, limit: int = 15) -> list[dict[str, Any]]: + # OData string literals must have single quotes escaped by doubling them. + safe_query = query.replace("'", "''") async with GraphClient(user_id) as g: data = await g.get( - f"/me/drive/root/search(q='{query}')", + f"/me/drive/root/search(q='{safe_query}')", params={"$top": limit}, ) return [_slim_item(i) for i in data.get("value", [])] @@ -53,19 +55,10 @@ async def upload_text_file( async def read_file_text(user_id: str, item_id: str) -> dict[str, Any]: async with GraphClient(user_id) as g: meta = await g.get(f"/me/drive/items/{item_id}") - # Download content via /content endpoint - import httpx - from services.auth import get_access_token - - token = await get_access_token(user_id) - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.get( - f"https://graph.microsoft.com/v1.0/me/drive/items/{item_id}/content", - headers={"Authorization": f"Bearer {token}"}, - ) - resp.raise_for_status() - try: - text = resp.content.decode("utf-8") - except UnicodeDecodeError: - text = f"" + # Reuse the GraphClient session so we inherit throttling + retry. + content = await g.get_bytes(f"/me/drive/items/{item_id}/content") + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + text = f"" return {**_slim_item(meta), "content": text}