diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index 3001323e..81e8c010 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -50,6 +50,7 @@ - To reply to the user, send on the platform the incoming message came from — check its source in the event stream. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Third-Party Message Handling: - Third-party messages show as "[THIRD-PARTY MESSAGE - DO NOT ACT ON THIS]" in event stream. @@ -170,7 +171,7 @@ Todo Workflow Phases (follow this order): Clarify before planning: -- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), use a send message action with wait_for_user_reply=true to ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. +- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. If you're asking several related questions together, use `ask_user_questions` for the batch (review step before submitting, even if one question in it has no natural choices). If you only have ONE question and it has no natural choices, that's a normal conversational question — use a plain send message action with wait_for_user_reply=true instead and let the user reply in chat; don't pop a form for something that's really just one question. Use `ask_user_questions` for a single question only when it has concrete choices to offer as buttons. 0. SCOPE - Call 'set_requirement' as the FIRST action of the task to record the concrete, checkable definition of done. Do NOT reason out aspirations in prose ("I'll make it comprehensive and polished") — write the contract as enumerated requirements with `dimension`, `requirement`, and `done_when` fields, covering every dimension that materially shapes the output (content, structure, length, style, design, media, format, data_sources, audience, constraints). Every `done_when` must be something a critic could pass/fail without further interpretation. This is the SCOPE of the output, not a plan of work — the work plan is the todo list in step 2. 1. Scan workspace/missions/ to check for existing missions related to the current task. 2. ACKNOWLEDGE - Send message to user confirming task receipt, you can adjust this based on the requirements @@ -204,6 +205,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Adaptive Execution: - If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos) @@ -404,6 +407,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Action Selection: - Choose the most direct action to accomplish the goal diff --git a/agent_file_system/AGENT.md b/agent_file_system/AGENT.md index 4b9e7c3f..4ffdab0f 100644 --- a/agent_file_system/AGENT.md +++ b/agent_file_system/AGENT.md @@ -1,5 +1,5 @@ --- -version: 3 +version: 4 purpose: agent operations manual --- @@ -17,6 +17,8 @@ connect platform → ## Integrations use an integration → ## Integrations (and grep its INTEGRATION.md) switch model → ## Models set API key → ## Models +delegate web research → ## Sub-Agents +lock the deliverable spec→ ## Tasks (set_requirement) generate document → ## Documents build Living UI → ## Living UI schedule recurring task → ## Proactive @@ -263,6 +265,9 @@ task_start(task_mode="complex", ...) ← from conversation OR schedule_task(mode="complex", schedule="immediate", ...) ← from inside a task │ ▼ +set_requirement() ← FIRST move, before you even acknowledge + │ + ▼ send_message ← acknowledge IMMEDIATELY │ ▼ @@ -286,6 +291,18 @@ wait for user reply ← queues a future trigger; you do NOT block, see ## Runti task_end ← only after explicit approval ``` +### Lock the deliverable spec: `set_requirement` + +`task_update_todos` is your plan (the steps). `set_requirement` is your contract (what the finished output must contain). They are different things and you need both for a complex task. + +Call `set_requirement` as the very first action of a complex task, before acknowledging. Pass a list of checkable items, each with: +- `dimension` — the aspect (content, structure, length, style, format, data_sources, tone, ...). +- `requirement` — the specific, falsifiable spec. NOT "make it polished" — say "includes a revenue table for FY22-24". +- `done_when` — the concrete pass/fail test. +- `status` — `pending` (default), `satisfied`, or `violated`. + +Then, in your Verify phase, call `set_requirement` again with each item marked `satisfied` or `violated` (a `violated` item means rework before you Confirm). Always pass the COMPLETE current list — it replaces the previous one, it does not append. The requirement list is pinned into your context every turn and survives event-stream summarization, so it is your durable checklist for "am I actually done". + ### Todo phase prefixes (mandatory in complex mode) Every todo must begin with one of these prefixes: @@ -332,6 +349,41 @@ See `## Workspace` for the mission template and scan-on-start protocol. --- +## Sub-Agents + +Inside a task you can delegate a self-contained chunk of work to a sub-agent with `spawn_subagent(agent_type, query)`. Use this to keep your own context clean while a focused worker does the digging. + +### When to delegate + +``` +Online research (search the web, fetch pages, gather facts) → spawn_subagent("research_agent", ...) +Local work (read files, grep the repo, memory_search) → do it yourself, don't delegate +``` + +`research_agent` is the type available today (it gathers source-cited facts and returns a brief — it does not interpret or make decisions). More types may appear over time; if `agent_type` is rejected, the type isn't registered — do the work yourself or ask the user. + +### How to write a good `query` + +The sub-agent starts BLANK. It cannot see your conversation, the user, memory, the current task, or anything you already know. So the `query` must be fully self-contained: +- State every fact, URL, name, and constraint it needs — do not reference "the file above" or "the user's request". +- Say exactly what shape you want back (a list? a table? a one-paragraph summary with sources?). + +A vague query gets a vague brief. Be specific. + +### Fan out for breadth + +If a topic has several distinct sub-questions, spawn ONE research_agent per sub-question in the SAME turn (multiple `spawn_subagent` calls in one decision). They run in parallel — three agents cost about the same wall-clock as one. Do NOT ask a single agent to cover many unrelated topics; it returns shallow results (and may refuse). + +### Reading the result + +`spawn_subagent` returns `{status, result, ...}`. **Only `result` matters** — act on that. If `status` is `failed` or `timeout`, the brief is unusable: re-scope the query (narrow it, split it) and try once more. Do not spawn the same failing query in a loop. + +### When a sub-agent misbehaves + +Each sub-agent writes its own log file — see `## Errors` (self-troubleshooting). If a research_agent returned something wrong or empty, open its `sub__.log` in the current run folder to see what it actually did, rather than guessing. + +--- + ## Communication Rules The user only sees what you send via `send_message` (or `send_message_with_attachment`). Everything else — actions, errors, internal reasoning — is invisible to them. @@ -435,26 +487,25 @@ The harness already handles certain failures so you do not have to. Recognizing ### LLM error classes (from `classify_llm_error`) -When an LLM call fails non-fatally, `classify_llm_error()` returns one of these messages. Knowing the class tells you whether retrying makes sense and what to tell the user: +When an LLM call fails, `classify_llm_error()` sorts it into a category. The category tells you whether retrying helps and what to tell the user: ``` -MSG_AUTH (HTTP 401/403) "Unable to connect to AI service. Check your API key in Settings." - → DO NOT retry. Tell user to set/fix API key. See ## Models. -MSG_MODEL (HTTP 404) "The selected AI model is not available." - → DO NOT retry. Tell user model name is wrong/unavailable. -MSG_CONFIG (HTTP 400) "AI service configuration error. The selected model may not support required features." - → DO NOT retry. May indicate a feature flag (vision, tool use) not supported by chosen model. -MSG_RATE_LIMIT (HTTP 429) "AI service is rate-limited. Please wait a moment and try again." - → Retryable after delay. Consider enabling slow_mode in settings. -MSG_SERVICE (HTTP 5xx) "AI service is temporarily unavailable. Please try again later." - → Retryable. Often transient. -MSG_CONNECTION (timeout, ConnectionError) "Unable to reach AI service. Check your internet." - → Retryable if connectivity recovers. -MSG_GENERIC (unmatched) "An error occurred with the AI service." - → Investigate before retrying. +category what it means what to do +────────── ─────────────────────────────────── ────────────────────────────────── +AUTH API key rejected / missing DO NOT retry. User fixes key. See ## Models. +CREDIT Out of credits / billing exhausted DO NOT retry — retrying never succeeds. + Tell the user to top up their provider + account (the error carries a billing link). +MODEL model name wrong / unavailable DO NOT retry. User picks a valid model. +RATE_LIMIT / provider throttling / usage cap Retryable after a delay. Consider slow_mode +QUOTA (see ## Models). +SERVER provider 5xx, temporary Retryable. Usually transient. +CONNECTION timeout / network Retryable once connectivity is back. +BAD_REQUEST / other Investigate before retrying. +UNKNOWN ``` -These come back as user-friendly strings to display; the harness wraps them in `"error"` events. You see them via the event stream and `display_message`. +Note CREDIT vs RATE_LIMIT: a rate limit clears if you wait; out-of-credits does not — never loop-retry a CREDIT error, just surface it. The displayed message is localized to the user's OS language, but the category and your response are the same regardless of language. ### Failure taxonomy and recovery decision @@ -532,12 +583,16 @@ EVENT.md agent_file_system/EVENT.md warning, action_error, internal). Already on disk and indexed by memory_search. -logs/.log project_root/logs/ +logs// project_root/logs// (ONE FOLDER PER RUN) runtime perspective: harness internals, every subsystem's INFO/WARN/ERROR log line. Loguru - format. Rotates at 50 MB, kept 14 days. - This is where stderr from sandboxed actions, - MCP server output, and Python tracebacks land. + format. Inside each run folder: + main.log you (main agent) only + all.log everything, interleaved + sub__.log one per sub-agent you spawned + This is where stderr from actions, MCP server + output, and Python tracebacks land. Rotates at + 50 MB, kept 14 days. diagnostic/logs/actions/ diagnostic/logs/actions/_.log.json per-action diagnostic dump (when run via the @@ -547,7 +602,8 @@ diagnostic/logs/actions/ diagnostic/logs/actions/_.log.json **Picking the right surface:** - "What did I do, and what did the harness say back?" → EVENT.md. -- "Why did this action / MCP / hot-reload actually fail?" → `logs/.log`. +- "Why did this action / MCP / hot-reload actually fail?" → newest `logs//all.log`. +- "Why did a sub-agent I spawned misbehave?" → that run's `sub__.log`. - "I want to replay one specific action's full input/output" → `diagnostic/logs/actions/`. **Log line format (loguru):** @@ -583,15 +639,17 @@ timestamp level module:function:line **Self-troubleshooting workflow.** When an action returns an error you cannot decode from `message` alone: ``` -1. Identify the latest log file: - list_folder logs/ ← logs are timestamped, latest is freshest +1. Identify the current run folder: + list_folder logs/ ← run folders are timestamped, latest is freshest + Then read all.log inside it (or main.log for just your own lines, or a + sub__.log for a specific sub-agent). 2. Find the time window of the failure: - From EVENT.md, note the timestamp of the failing event. - - That same timestamp will exist in logs/.log (within seconds). + - That same timestamp will exist in logs//all.log (within seconds). 3. Grep around that time + the relevant subsystem tag: - grep_files "[MCP]" logs/.log -A 5 -B 1 ← MCP server failure? - grep_files "[ACTION]" logs/.log -A 5 -B 1 ← action execution issue? - grep_files "ERROR" logs/.log -B 2 -A 10 ← any error-level line + context + grep_files "[MCP]" logs//all.log -A 5 -B 1 ← MCP server failure? + grep_files "[ACTION]" logs//all.log -A 5 -B 1 ← action execution issue? + grep_files "ERROR" logs//all.log -B 2 -A 10 ← any error-level line + context 4. If a Python traceback is present, read upward from the traceback to the most recent INFO line in the same subsystem — that tells you the last successful step before the failure. @@ -610,32 +668,32 @@ timestamp level module:function:line ``` # Did an MCP server crash on startup or fail to connect? -grep_files "[MCP]" logs/.log -A 3 +grep_files "[MCP]" logs//all.log -A 3 # → look for "Failed to connect", "subprocess exited", non-zero return codes. # Did the config watcher fail to apply a hot reload? -grep_files "[CONFIG_WATCHER]" logs/.log -A 3 +grep_files "[CONFIG_WATCHER]" logs//all.log -A 3 # Did settings.json fail to parse? -grep_files "[SETTINGS]" logs/.log -A 3 +grep_files "[SETTINGS]" logs//all.log -A 3 # Did an action time out, and which one? -grep_files "Execution timed out" logs/.log -B 5 +grep_files "Execution timed out" logs//all.log -B 5 # Did the LLM hit consecutive failures? -grep_files "LLMConsecutiveFailureError\|MSG_CONSECUTIVE_FAILURE" logs/.log -A 5 +grep_files "LLMConsecutiveFailureError\|MSG_CONSECUTIVE_FAILURE" logs//all.log -A 5 # Did a sandboxed action subprocess produce stderr? -grep_files "venv\|requirements\|subprocess" logs/.log -A 3 +grep_files "venv\|requirements\|subprocess" logs//all.log -A 3 # What did the agent's _check_agent_limits last log? -grep_files "[LIMIT]" logs/.log -A 2 +grep_files "[LIMIT]" logs//all.log -A 2 # When did the last task end, and how? -grep_files "[TASK].*ended\|task_end\|mark_task_cancel" logs/.log -A 3 +grep_files "[TASK].*ended\|task_end\|mark_task_cancel" logs//all.log -A 3 # Find the last 100 ERROR-level lines across the whole log: -grep_files "| ERROR " logs/.log -A 5 +grep_files "| ERROR " logs//all.log -A 5 ``` **Acting on what you find.** A log line is data, not a fix. The decision rules: @@ -678,7 +736,7 @@ Long gaps between INFO lines (no activity) the loop may be waiting for a tri **When logs are the only honest source of truth.** Some failures do not surface as `status=error` in the action result — they manifest as the action *seeming to work* but the side effect not happening (e.g., `run_shell` returns 0 but a script printed "ok" while silently catching an exception; an MCP tool returns success but logged a warning that the operation was a no-op). When you suspect a silent failure, grep the logs for the timestamp of your action and look for `WARNING` or unexpected `ERROR` lines around it. -**Rotation and freshness.** Log files rotate at 50 MB and old files are kept for 14 days. The latest file by mtime is the one with current activity. If your investigation needs older history (e.g., a crash from yesterday), `list_folder logs/` and pick by timestamp. +**Rotation and freshness.** Logs rotate at 50 MB and old files are kept for 14 days. The newest run FOLDER (by timestamp) holds the current session; read `all.log` inside it. If your investigation needs older history (e.g., a crash from yesterday), `list_folder logs/` and pick an earlier run folder. **Do not ask the user for log content you can read yourself.** The user does not have a better view than you do. If they ask "what's the error?", read the log, summarize, and explain. They are not your support layer — you are theirs. @@ -749,25 +807,14 @@ Full input schema: [app/data/action/grep_files.py](app/data/action/grep_files.py - Use as a pair when modifying an existing file. - `read_file` returns the exact content with line numbers. - `stream_edit` applies a precise diff. -- Preferred over a whole-file rewrite for edits. Preserves unrelated content and avoids clobbering the rest of the file. +- Preferred over `write_file` for edits. Preserves unrelated content and avoids whole-file overwrites. -### Creating new files -There is no dedicated write action. To create a new file (or do a deliberate -full rewrite of a small one), write it with `run_shell` using the host shell — -e.g. PowerShell `Set-Content` / `Add-Content` on Windows. - -For large files (long documents, scripts, datasets), DO NOT try to emit the -whole file in one step. Each action is a single model response bounded by the -output-token limit, and a long inline command also exceeds the shell's -command-line limit (cmd ~8 KB). Build the file incrementally instead: -1. Create the file with the first chunk (`Set-Content`). -2. Append the next section with `Add-Content` — one bounded chunk per step. -3. Repeat until the content is complete. -4. Then run or finalize it — run a script with `run_shell` (e.g. `python build_doc.py`), or for a PDF build the markdown then convert it with `create_pdf`. -Keep each chunk small — roughly ~150 lines (a few KB) at most — so it fits -comfortably within one response's output-token budget. +### write_file +Use only when: +- Creating a brand new file, OR +- Doing a deliberate full rewrite of a small file. -Never rewrite an existing large file this way — use `stream_edit` to patch it. +Never use `write_file` to patch an existing large file. Use `stream_edit`. For large files (long documents, scripts, datasets), DO NOT try to emit the whole file in one step. Each action is a single model response bounded by the @@ -775,16 +822,28 @@ output-token limit. Build the file incrementally instead: 1. Create the file with the first chunk (`write_file` in overwrite mode). 2. Append the next section with `write_file` in append mode — one bounded chunk per step. 3. Repeat until the content is complete. -4. Then run or finalize it — e.g. run a script with `run_shell` (`python build_doc.py`), or hand the file to whatever skill consumes it. +4. Then run or finalize it — run a script with `run_shell` (e.g. `python build_doc.py`), + or for a PDF build the markdown then convert it with `convert_to_pdf` (pass + `source_path` pointing at the markdown file; format is auto-detected from the + extension; pass `style` to override FORMAT.md). The same action handles every + source format (text, csv, xlsx, html, url, images, docx/odt/rtf/pptx). Use + `convert_from_pdf` for the reverse direction (PDF → .docx or .html). Keep each chunk small — roughly ~150 lines (a few KB) at most — so it fits comfortably within one response's output-token budget. +### Externalized (offloaded) action output +When an action returns a very large output, the harness does NOT dump it into your context — it saves it to a file and gives you a short pointer instead. You'll see a result like: +``` +Action completed. The output is too long therefore is saved in ... | keywords: ... +``` +When you see that, the real content is in the file at ``. Retrieve it the same way you read any file: `grep_files` the path with a keyword to jump to the part you need, or `read_file` it with `offset`/`limit` to page through. Do NOT treat the pointer message as the answer — go read the file. (`grep_files` and `read_file` outputs are never externalized, so you won't get a pointer-to-a-pointer.) + ### find_files vs list_folder - `list_folder`: top-level listing of a single directory. - `find_files`: recursive name pattern search across a tree. ### convert_to_markdown vs read_pdf -- `read_pdf`: direct PDF reading with page support. +- `read_pdf`: direct PDF reading with page support. By default it returns just the text/tables (lean, to save context); pass `include_metadata=true` for page count and engine info, or `mode="layout"` when you need per-word positions for a spatial/edit task. - `convert_to_markdown`: for office formats (docx, xlsx, pptx) you intend to grep afterwards. ### Anti-patterns @@ -955,7 +1014,7 @@ app/config/onboarding_config.json first-run state skills//SKILL.md installed skills (## Skills) .credentials/.json OAuth tokens, bot tokens, API keys DO NOT print contents to chat or logs -logs/.log runtime logs (## Errors) +logs//all.log runtime logs (## Errors) chroma_db_memory/ ChromaDB index for memory_search DO NOT edit ``` @@ -1053,7 +1112,7 @@ A mission with stale `Next Steps` is worse than no mission. Always leave it acti - Configuration files (use `app/config/`). - Skills (use `skills/`). - Credentials (use `.credentials/`). -- Logs (auto-go to `logs/.log`). +- Logs (auto-go to `logs//all.log`). - Editing AGENT.md / USER.md / SOUL.md / FORMAT.md (these are in `agent_file_system/`, not `workspace/`). --- @@ -1109,13 +1168,18 @@ This is non-optional. Generating documents without reading FORMAT.md produces in ### Action support -Document-reading actions in the standard action set: +Document actions in the standard action set: ``` convert_to_markdown normalize office formats before further processing read_pdf read a PDF with page support +convert_to_pdf render any source → PDF; source format auto-detected from input + (markdown/text/csv/xlsx/html/url/images/docx/odt/rtf/pptx) +convert_from_pdf PDF → editable .docx (pdf2docx) or layout-preserving .html (PyMuPDF); + the html target is the EDIT path: convert_from_pdf → stream_edit → convert_to_pdf +edit_pdf annotate / redact / replace / watermark an existing PDF ``` -For document *generation* (PDF, DOCX, PPTX, XLSX), there is no built-in action — use the per-format skills listed below, which drive the underlying libraries directly. +For DOCX/PPTX/XLSX *generation*, there is no built-in action — use the per-format skills listed below. Skills that compose document workflows (sample): ``` @@ -1259,7 +1323,7 @@ Examples of files with multiple registrations: - `integration_management.py` registers `list_available_integrations`, `connect_integration`, `check_integration_status`, `disconnect_integration`. - `discord/discord_actions.py`, `slack/slack_actions.py`, `telegram/telegram_actions.py`, `notion/notion_actions.py`, `linkedin/linkedin_actions.py`, `jira/jira_actions.py`, `github/github_actions.py`, `outlook/outlook_actions.py`, `whatsapp/whatsapp_actions.py`, `twitter/twitter_actions.py`, `google_workspace/{gmail,google_calendar,google_drive}_actions.py` each register many actions. -Total registered built-in actions: roughly 195 (varies by version). The exact number is logged at startup in `logs/.log` — search for `Action registry loaded`. +Total registered built-in actions: roughly 195 (varies by version). The exact number is logged at startup in `logs//all.log` — search for `Action registry loaded`. ### How to discover actions @@ -1318,6 +1382,8 @@ core send_message, task_start, task_end, task_update_todos, file_operations read_file, grep_files, find_files, list_folder, stream_edit, write_file, read_pdf, convert_to_markdown +document_processing convert_to_pdf, convert_from_pdf, edit_pdf, read_pdf, convert_to_markdown + shell run_shell web_research web_fetch, web_search, http_request @@ -1633,11 +1699,11 @@ You may also encounter MCP server entries that point at standalone JSON files; t 3. stream_edit ... make the edit (preserves unrelated content) 4. wait ~0.5s for debounce the watcher coalesces rapid saves 5. verify the reload happened see "Verifying a reload" below -6. if no effect: check logs/.log for [SETTINGS] / [MCP] / [CONFIG_WATCHER] errors +6. if no effect: check logs//all.log for [SETTINGS] / [MCP] / [CONFIG_WATCHER] errors [CONFIG_WATCHER] / [MCP] / [SETTINGS] errors ``` -Use `stream_edit`, never a whole-file rewrite, on configs. Rewriting the file risks losing unrelated keys the runtime relies on (e.g. `api_keys_configured` bookkeeping, your own `oauth` clients). +Use `stream_edit`, never `write_file`, on configs. A whole-file rewrite risks losing unrelated keys the runtime relies on (e.g. `api_keys_configured` bookkeeping, your own `oauth` clients). If the file is malformed JSON after your edit, the reload fails and the previous in-memory config keeps running. Read the file back and fix the syntax. `[SETTINGS] JSONDecodeError` will appear in the log. @@ -1726,12 +1792,12 @@ By config: ``` settings.json - - check logs: grep_files "[SETTINGS]" logs/.log -A 1 + - check logs: grep_files "[SETTINGS]" logs//all.log -A 1 - or read back: read_file app/config/settings.json (confirm your edit landed) - in next task: model/provider/api_key changes are observable when an LLM call fires mcp_config.json - - check logs: grep_files "[MCP]" logs/.log -A 2 + - check logs: grep_files "[MCP]" logs//all.log -A 2 - look for: "Connecting to ''", "[StdioTransport] Starting subprocess" - in next task: list_action_sets shows mcp_ as a registered set @@ -1741,11 +1807,11 @@ skills_config.json - new / slash commands appear after sync_skill_commands fires external_comms_config.json - - check logs: grep_files "[EXT_COMMS]" logs/.log -A 2 + - check logs: grep_files "[EXT_COMMS]" logs//all.log -A 2 - if telegram/whatsapp enabled and started, expect connection success messages scheduler_config.json - - check logs: grep_files "[SCHEDULER]" logs/.log -A 2 + - check logs: grep_files "[SCHEDULER]" logs//all.log -A 2 - call scheduled_task_list action → confirms entries ``` @@ -2129,7 +2195,7 @@ After enabling/adding, in order of cheapness: ``` 1. grep the latest log for the server's name: - grep_files "[MCP].*" logs/.log -A 1 + grep_files "[MCP].*" logs//all.log -A 1 Expect: "Successfully connected" + "Registered N tools". 2. confirm the action set is registered: @@ -2402,7 +2468,7 @@ This skill walks through the scaffold (writes the SKILL.md, sets up the director **3. Author by hand.** ``` 1. mkdir skills/ -2. run_shell to create skills//SKILL.md +2. write_file skills//SKILL.md (use the format above; copy a similar existing skill as template) 3. stream_edit app/config/skills_config.json to add to enabled_skills 4. wait ~0.5s for hot-reload @@ -2428,7 +2494,7 @@ Toggle via `stream_edit` on `skills_config.json`, OR via the user-side commands After enable / disable / install: ``` -1. grep_files "[SKILL]" logs/.log -A 1 (confirm reload fired) +1. grep_files "[SKILL]" logs//all.log -A 1 (confirm reload fired) 2. action: list_skills (returns the live list) 3. user-side: /skill list (same data, different UI) 4. / (only works if user-invocable=true @@ -2750,7 +2816,7 @@ After any connect attempt: ``` 1. check_integration_status(integration_id) → returns success + account display 2. /cred status (user-side) → overview of all integrations -3. grep_files "[]" logs/.log → look for connect / auth errors +3. grep_files "[]" logs//all.log → look for connect / auth errors ``` If `check_integration_status` returns "Not connected" right after a successful `connect_integration` call, something is wrong. Common: the credential validated but the listener failed to start (check logs for that platform's tag). @@ -2765,6 +2831,16 @@ disconnect_integration(integration_id, account_id?) The user can also `/ disconnect [account_id]`. +### Multiple accounts per integration + +Gmail, Google Calendar, Google Drive, Google Docs, YouTube, Outlook, LinkedIn, Notion, HubSpot, and Slack support more than one connected account. Each account can have a user-given nickname ("work", "personal") and one account is always the *primary*. + +- Most actions on these integrations take an optional `account` param: an email/workspace-id/hub-domain, a unique substring of one, or the nickname. **If the user's phrasing names or qualifies an account at all** ("my school calendar", "post from my work LinkedIn", "check the beta Slack"), extract that qualifier and pass it as `account` — resolution is safe against a wrong/fuzzy guess (it errors with a clear account list instead of silently succeeding on the wrong account). Only omit `account` / default to primary when the user's message gives no such qualifier. +- The five Google services (Gmail/Calendar/Drive/Docs/YouTube) share ONE alias namespace per underlying Google account — a nickname set on one applies to all of them automatically. +- If `account` doesn't resolve (typo, ambiguous substring, wrong integration), the action's error lists every connected account for that integration — read it back to the user and let them clarify; don't guess. +- To see what's connected (including each account's alias and which is primary), call `check_integration_status(integration_id)` — its `accounts` array has `{id, display, alias, is_primary}` per account. There's no separate "list accounts" action. +- **You cannot set an alias or change the primary account yourself** — no agent action exists for that. Direct the user to Settings → Integrations → Manage → set the nickname / click "Set primary" → "Save changes". + ### Common failure modes ``` @@ -2797,7 +2873,7 @@ connection works once, fails next session token expired (some use tokens have short TTL) ``` -When in doubt: read the action's error message in full, then check `logs/.log` for the integration's tag. +When in doubt: read the action's error message in full, then check `logs//all.log` for the integration's tag. ### When to use integration actions vs MCP @@ -2874,6 +2950,8 @@ deepseek deepseek-chat (none) (none) moonshot moonshot-v1-8k (none) (none) text only grok grok-3 grok-4-0709 (none) xAI minimax MiniMax-Text-01 (none) (none) text only +glm glm-5.2 glm-5.2 (none) Z.ai (GLM), OpenAI-compat +fugu fugu (none) (none) Sakana (Fugu), text only ``` If you set `model.llm_model: null` in settings.json, the default from MODEL_REGISTRY is used. Set an explicit string to override. @@ -2981,6 +3059,12 @@ If the user just provides a new key for the CURRENT provider (e.g., they updated run /provider to rebuild the client cleanly. ``` +### Subscription sign-in (ChatGPT / Grok) + +Some users authenticate OpenAI or Grok by signing in to their paid subscription (browser OAuth) instead of pasting an API key. Tokens live in `.credentials/*_oauth.json` and take precedence over any API key for that provider. + +The one thing you MUST know: **ChatGPT subscription mode cannot make tool calls.** It routes through OpenAI's Codex backend, which does not support the agent's actions. Symptom: actions mysteriously won't run, or you get a "not supported when using Codex with a ChatGPT account" error. The fix is to tell the user to either disconnect the subscription and use an API key, or upgrade if they're on the free tier. Do not keep retrying — it will not start working. + ### Connection testing Before declaring the switch worked, verify. There's a built-in test using @@ -3087,7 +3171,11 @@ This list is opinion, not authoritative. The user has the final say. ## Memory -Memory is your long-term recall. It is RAG-backed (semantic search over a vector index), not text-grep over MEMORY.md. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You read memory via the `memory_search` action; you do NOT write MEMORY.md directly. +Memory is your long-term recall. It is RAG-backed (relevance search over MEMORY.md and a few other files), not text-grep. Items reach MEMORY.md only after the daily memory-processing pipeline distills them from the event stream. You do NOT write MEMORY.md directly. + +Two ways memory reaches you: +- **Automatic injection (passive).** On every user message and at task creation, the most relevant memories are retrieved for you and dropped into your context as a `relevant_memories` event. You do NOT need to call `memory_search` just to see what you already know — it's already there. +- **`memory_search` action (active).** Use it when you need to dig deeper on a specific question mid-task, beyond what got auto-injected. Code: [agent_core/core/impl/memory/manager.py](agent_core/core/impl/memory/manager.py) (`MemoryManager`), [agent_core/core/impl/memory/memory_file_watcher.py](agent_core/core/impl/memory/memory_file_watcher.py) (incremental re-indexing), [app/data/action/memory_search.py](app/data/action/memory_search.py) (action). @@ -3149,7 +3237,7 @@ One fact per line. Multi-line entries break the parser. ### How memory_search works -`memory_search(query, top_k)` is a vector search via ChromaDB ([app/data/action/memory_search.py](app/data/action/memory_search.py)): +`memory_search(query, top_k)` runs a relevance search (semantic + keyword) over the indexed files ([app/data/action/memory_search.py](app/data/action/memory_search.py)): ``` input: @@ -3175,7 +3263,7 @@ output: Pointers are LIGHTWEIGHT references, not full content. To read the full chunk, `read_file ` and find the section, OR call the manager's `retrieve_full_content(chunk_id)` if exposed via an action. -Relevance score is normalized from ChromaDB's L2 distance: `relevance = 1.0 / (1.0 + distance)`. A score above ~0.6 is usually "highly relevant"; below ~0.3 is weak. +Relevance score is 0.0-1.0 (higher = more relevant), blending semantic similarity with keyword match. Treat it as a ranking hint within one query — don't compare scores across different queries. Ranking is NOT influenced by how recent a memory is; an old high-relevance fact outranks a fresh irrelevant one. ### Indexed files (what memory_search can find) @@ -3229,7 +3317,7 @@ When MEMORY.md exceeds `memory.max_items` in settings.json (default 200), prunin ``` 1. memory-processing task includes needs_pruning=True -2. processor evaluates each entry's relevance and recency +2. processor keeps high-utility entries regardless of age, drops the least useful 3. trims down to memory.prune_target (default 135) 4. discarded entries are dropped (not archived) ``` @@ -3261,7 +3349,7 @@ Option 3: Manual trigger (if user requests) ### Hard rules -- You MUST NOT `stream_edit` or otherwise write to MEMORY.md. Only the memory processor writes there. +- You MUST NOT `stream_edit` or `write_file` MEMORY.md. Only the memory processor writes there. - You MUST NOT edit EVENT.md, EVENT_UNPROCESSED.md, CONVERSATION_HISTORY.md, or TASK_HISTORY.md. - You MAY edit USER.md (with user confirmation, see `## Self-Edit`). - You MAY edit AGENT.md (with caution, see `## Self-Edit`). @@ -3288,7 +3376,7 @@ Toggling `memory.enabled` to false does NOT delete `MEMORY.md` or `chroma_db_mem - `memory_search` returns "Memory is disabled" → check `memory.enabled` in settings.json. The user may have turned it off. - `memory_search` returns empty `results: []` with no error → the index may be empty (fresh install) or the query phrasing doesn't match the indexed content. Try rephrasing or `grep_files` as fallback. - Editing AGENT.md, USER.md, PROACTIVE.md, MEMORY.md, or EVENT_UNPROCESSED.md re-triggers re-indexing. If you make rapid edits, the watcher debounces but still consumes some time. Don't loop edit-then-search. -- `relevance_score` is L2-distance-normalized. Don't compare scores across queries (different queries have different score distributions). +- `relevance_score` is a per-query ranking hint. Don't compare scores across queries (different queries have different score distributions), and don't read a recency signal into it — ranking ignores age. - The `chroma_db_memory/` directory is an opaque ChromaDB store. Do not try to repair or migrate it. If corrupted, the user must delete the directory and let the manager rebuild on next startup. --- @@ -3849,7 +3937,7 @@ This is non-optional. Without outcome history, the task has no memory of what it ``` 1. recurring_read(frequency="all", enabled_only=false) ← see all entries 2. read_file agent_file_system/PROACTIVE.md ← inspect raw -3. grep_files "[PROACTIVE]" logs/.log -A 1 ← startup confirmation +3. grep_files "[PROACTIVE]" logs//all.log -A 1 ← startup confirmation 4. After the next scheduled fire time, check logs and EVENT.md for execution. ``` @@ -3858,7 +3946,7 @@ If the task should have fired but didn't, check: - `enabled` on the task itself in PROACTIVE.md - `time` and `day` match the current moment - `conditions` are met -- The heartbeat itself fired (`grep_files "Heartbeat" logs/.log`) +- The heartbeat itself fired (`grep_files "Heartbeat" logs//all.log`) ### Where authority lives @@ -4298,7 +4386,7 @@ If you can't pick one cleanly, the change isn't well-scoped yet. Ask the user be ``` 1. Read the section you want to change (and its neighbors) so your edit matches the surrounding tone and structure. -2. stream_edit AGENT.md (NEVER do a whole-file rewrite; you'd lose the rest of the file). +2. stream_edit AGENT.md (NEVER write_file; you'd lose the rest of the file). 3. Bump the `version:` line in the front matter when the change is material. 4. Sync to template: also stream_edit app/data/agent_file_system_template/AGENT.md so new installs get the upgrade. Both files must stay byte-identical. diff --git a/app/agent_base.py b/app/agent_base.py index d701b44e..26215bc9 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -64,6 +64,7 @@ initialize_manager, ) +from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS from app.internal_action_interface import InternalActionInterface from app.llm import LLMInterface @@ -552,9 +553,9 @@ async def react(self, trigger: Trigger) -> None: await self._create_new_trigger( session_id, { - "fire_at_delay": 10800, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, "wait_for_user_reply": True, - }, # 3 hours + }, STATE, ) return @@ -1776,7 +1777,7 @@ async def _pause_task_for_limit_choice(self, session_id: str) -> None: TriggerSpec( source=TriggerSource.LIMIT_REACHED, description="Waiting for user decision on limit reached", - fire_at=time.time() + 10800, + fire_at=time.time() + WAIT_FOR_REPLY_PARK_DELAY_SECONDS, priority=5, session_id=session_id, payload={"gui_mode": STATE.gui_mode}, diff --git a/app/data/action/ask_user_questions.py b/app/data/action/ask_user_questions.py new file mode 100644 index 00000000..19912e09 --- /dev/null +++ b/app/data/action/ask_user_questions.py @@ -0,0 +1,160 @@ +from agent_core import action + + +@action( + name="ask_user_questions", + irreversible=True, + description=( + "Ask the user one or more clarifying questions before continuing, when you genuinely " + "cannot proceed without more information. USE THIS when you have concrete choices to put " + "in front of the user (which connected account, which of these files, yes/no-style " + "decisions) — buttons are faster and less ambiguous than free text. Also use it to ask " + "SEVERAL related questions together as one batch (with a review step before submitting), " + "even if one of those questions in the batch doesn't have natural choices. " + "DO NOT use this for a single standalone question that has no natural choices (e.g. just " + "'what's the recipient's email?' alone) — that is a normal conversation, not a form: use a " + "send message action with wait_for_user_reply=true instead and let the user reply in the " + "chat like normal. Popping a structured question card for something that's really just one " + "plain question breaks the conversational flow and trains the user to stop paying attention " + "to these prompts. Also use send_message/wait_for_user_reply for anything that must go out " + "on an external platform (Telegram, WhatsApp, etc.), since this action only renders in the " + "local CraftBot chat. Each question can offer multiple-choice buttons; the user can also " + "always type a free-text answer or escape out of the whole batch without answering. Do NOT " + "use this for trivial details you could reasonably infer or default — only for genuine " + "ambiguity that would produce a wrong or unwanted result if you guessed. This action always " + "pauses the task until the user responds; do not pair it with task_start (it would park the " + "task before it runs). If the user escapes without answering, you will see 'The user " + "declined to answer.' in their reply — do NOT immediately re-ask the same questions; ask in " + "plain chat what they'd like you to do instead. During a proactive/scheduled run with " + "nobody connected to the interface, this action refuses with an error instead of parking " + "indefinitely — proceed with your best judgment or end the task instead." + ), + default=True, + action_sets=["core"], + parallelizable=False, + input_schema={ + "questions": { + "type": "array", + "description": ( + "Array of question objects, asked together as one batch. Each object has " + "'question' (string: the question text, REQUIRED), 'choices' (array of strings: " + "2-6 short answers; can be an EMPTY array for one question within a multi-question " + "batch that has no natural choices, but don't call this action with a single " + "question that has empty choices — that's a plain conversational question, use " + "send_message instead), and 'multi_select' (boolean, OPTIONAL, default false: true " + "if the user may pick more than one choice, shown as checkboxes; false shows " + "single-pick buttons). The user can always free-type an answer instead of picking a " + "choice, or decline, so choices don't need to be exhaustive. " + 'Example single-pick: [{"question": "Which Gmail account should I use?", "choices": ' + '["alan@work.com", "alan@personal.com"]}]\n' + 'Example multi-pick: [{"question": "Which recipients should get this?", "choices": ' + '["alan@work.com", "team@work.com", "boss@work.com"], "multi_select": true}]\n' + 'Example batch mixing a choice question with an open-ended one: ' + '[{"question": "Which account?", "choices": ["work", "personal"]}, ' + '{"question": "What should the subject be?", "choices": []}]' + ), + "example": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "ok", + "description": "Indicates the action completed successfully.", + }, + "fire_at_delay": { + "type": "number", + "example": 10800, + "description": "Delay in seconds before the next follow-up action should be scheduled. Always 10800 (3 hours) — this action always pauses for a reply.", + }, + "wait_for_user_reply": { + "type": "boolean", + "example": True, + "description": "Always true — tells the agent loop to mark the task as waiting and hold off the next trigger until the user replies.", + }, + }, + test_payload={ + "questions": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + "simulated_mode": True, + }, +) +async def ask_user_questions(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + + questions = input_data.get("questions") or [] + simulated_mode = input_data.get("simulated_mode", False) + session_id = input_data.get("_session_id") + + if not questions: + return {"status": "error", "message": "questions must be a non-empty array"} + + if not simulated_mode: + import app.internal_action_interface as internal_action_interface + + ui_adapter = internal_action_interface.InternalActionInterface.ui_adapter + if ui_adapter is None: + return { + "status": "error", + "message": ( + "Cannot ask the user interactively — no CraftBot browser interface " + "is attached (CLI/headless mode). Ask conversationally with a send " + "message action (wait_for_user_reply=true) instead." + ), + } + # Fail closed: only park the task when the adapter affirms a user is + # present to answer (browser adapter: at least one connected client). + if not ui_adapter.can_prompt_user(): + return { + "status": "error", + "message": ( + "Cannot ask the user right now — no one is connected to the CraftBot " + "interface (this looks like a proactive/scheduled run with nobody watching). " + "Do not wait on a reply that may not come for hours: proceed with your best " + "reasonable judgment and note the assumption you made, or end the task if you " + "truly cannot proceed without this answer." + ), + } + + from app.ui_layer.components.types import ChatMessageOption, ChatMessageQuestion + + question_objs = [ + ChatMessageQuestion( + id=f"q{i + 1}", + text=q.get("question", ""), + choices=[ + ChatMessageOption(label=c, value=c) + for c in q.get("choices", []) + ], + multi_select=bool(q.get("multi_select", False)), + ) + for i, q in enumerate(questions) + ] + content = "\n".join(f"{i + 1}. {q.text}" for i, q in enumerate(question_objs)) + + from app.onboarding import onboarding_manager + + agent_name = onboarding_manager.state.agent_name or "Agent" + await ui_adapter._display_chat_message( + agent_name, + content, + "agent", + task_session_id=session_id, + questions=question_objs, + ) + + status = "success" if simulated_mode else "ok" + return { + "status": status, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, + "wait_for_user_reply": True, + } diff --git a/app/data/action/constants.py b/app/data/action/constants.py new file mode 100644 index 00000000..019aecb0 --- /dev/null +++ b/app/data/action/constants.py @@ -0,0 +1,6 @@ +"""Shared constants for agent actions and the agent loop.""" + +# How long a task parks while waiting on a user reply (3 hours). Used by every +# action that returns wait_for_user_reply and by agent_base's wait re-schedule +# paths — keep them in sync by importing this rather than inlining the number. +WAIT_FOR_REPLY_PARK_DELAY_SECONDS = 10800 diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index e29fdb65..901e62aa 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -174,6 +174,7 @@ async def run_client( integration: str, method_name: str, *, + account: Optional[str] = None, unwrap_envelope: bool = False, success_message: Optional[str] = None, fail_message: str = "Operation failed", @@ -182,10 +183,15 @@ async def run_client( """Resolve client by integration, check creds, call method, wrap result. The named method may be sync or async; coroutines are awaited. + + ``account`` selects which connected account to use for integrations that + support multiple (currently the Google services) — an email or unique + fragment. ``None`` uses the primary account; unrelated integrations + ignore it (their client's ``__init__`` doesn't look at ``_account``). """ from craftos_integrations import get_client - client = get_client(integration) + client = get_client(integration, account) if client is None: return {"status": "error", "message": f"Unknown integration: {integration}"} if not client.has_credentials(): @@ -224,15 +230,19 @@ def run_client_sync( integration: str, method_name: str, *, + account: Optional[str] = None, unwrap_envelope: bool = False, success_message: Optional[str] = None, fail_message: str = "Operation failed", **kwargs, ) -> Dict[str, Any]: - """Sync flavor of ``run_client`` for sync actions calling sync methods.""" + """Sync flavor of ``run_client`` for sync actions calling sync methods. + + See ``run_client`` for what ``account`` does. + """ from craftos_integrations import get_client - client = get_client(integration) + client = get_client(integration, account) if client is None: return {"status": "error", "message": f"Unknown integration: {integration}"} if not client.has_credentials(): @@ -270,25 +280,27 @@ def run_client_sync( return {"status": "error", "message": str(e)} -def get_client_or_error(integration: str): +def get_client_or_error(integration: str, account: Optional[str] = None): """Resolve a client + run the credential check. Returns a tuple ``(client, error_dict)``: - on success: ``(client, None)`` - on failure: ``(None, {"status": "error", "message": ...})`` + ``account`` selects which connected account (see ``run_client``). + Use this in actions that return bespoke result shapes / do multi-step logic and can't use ``run_client`` or ``with_client``:: def my_action(input_data): - client, err = get_client_or_error("google_workspace") + client, err = get_client_or_error("gmail", input_data.get("account")) if err: return err ... """ from craftos_integrations import get_client - client = get_client(integration) + client = get_client(integration, account) if client is None: return None, { "status": "error", @@ -300,7 +312,7 @@ def my_action(input_data): async def with_client( - integration: str, fn: Callable, *args, **kwargs + integration: str, fn: Callable, *args, account: Optional[str] = None, **kwargs ) -> Dict[str, Any]: """Call ``fn(client, *args, **kwargs)`` after credential check. @@ -308,8 +320,10 @@ async def with_client( multiple calls in sequence, payload building, etc. ``fn`` may be sync or async. Wraps the return as ``{"status": "success", "result": ...}``; for bespoke result shapes use ``get_client_or_error`` instead. + + ``account`` selects which connected account (see ``run_client``). """ - client, err = get_client_or_error(integration) + client, err = get_client_or_error(integration, account) if err: return err try: diff --git a/app/data/action/integrations/_integration_essentials.py b/app/data/action/integrations/_integration_essentials.py index 0e69482e..ed6070a9 100644 --- a/app/data/action/integrations/_integration_essentials.py +++ b/app/data/action/integrations/_integration_essentials.py @@ -72,6 +72,24 @@ def _build_keyword_index() -> Dict[str, str]: key = key.lower().strip() if key: index.setdefault(key, integration_id) + + # The auto-derived keys above only cover an integration's own id/first + # token — for compound Google Workspace names that means "google + # calendar"/"google" matches but the bare second word ("calendar", + # "docs", "drive", "youtube") never does, even though that's how people + # actually talk ("what's on my school calendar"). Seed those explicitly + # (overwriting, not setdefault) so essentials injection actually fires + # on the common phrasing. "google" itself is left alone — it's + # inherently ambiguous across 5 services, unlike these individual words. + for key, integration_id in ( + ("calendar", "google_calendar"), + ("docs", "google_docs"), + ("doc", "google_docs"), + ("drive", "google_drive"), + ("youtube", "google_youtube"), + ): + if integration_id in integration_ids: + index[key] = integration_id return index diff --git a/app/data/action/integrations/google_workspace/gmail_actions.py b/app/data/action/integrations/google_workspace/gmail_actions.py index 27391b0d..b3295e58 100644 --- a/app/data/action/integrations/google_workspace/gmail_actions.py +++ b/app/data/action/integrations/google_workspace/gmail_actions.py @@ -32,6 +32,11 @@ "description": "Optional list of file paths to attach.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -42,6 +47,7 @@ def send_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "send_email", + account=input_data.get("account"), unwrap_envelope=True, success_message="Email sent.", fail_message="Failed to send email.", @@ -62,6 +68,11 @@ def send_gmail(input_data: dict) -> dict: "description": "Number of recent emails to list.", "example": 5, }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -71,6 +82,7 @@ def list_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "list_emails", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list emails.", n=input_data.get("count", 5), @@ -97,6 +109,11 @@ def list_gmail(input_data: dict) -> dict: "description": "Whether to include full email body and attachment metadata.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -106,6 +123,7 @@ def get_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "get_email", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get email.", message_id=input_data["message_id"], @@ -128,6 +146,11 @@ def get_gmail(input_data: dict) -> dict: "description": "Include full body text.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -137,6 +160,7 @@ def read_top_emails(input_data: dict) -> dict: return run_client_sync( "gmail", "read_top_emails", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to read emails.", n=input_data.get("count", 5), @@ -164,6 +188,11 @@ def read_top_emails(input_data: dict) -> dict: "description": "Include Spam/Trash.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -173,6 +202,7 @@ def search_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "search_messages", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to search.", query=input_data["query"], @@ -203,6 +233,11 @@ def search_gmail(input_data: dict) -> dict: "description": "Optional attachment file paths.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -213,6 +248,7 @@ def reply_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "reply_to_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to reply.", message_id=input_data["message_id"], @@ -248,6 +284,11 @@ def reply_gmail(input_data: dict) -> dict: "description": "Optional attachment file paths.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -258,6 +299,7 @@ def forward_gmail(input_data: dict) -> dict: return run_client_sync( "gmail", "forward_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to forward.", message_id=input_data["message_id"], @@ -456,6 +498,11 @@ def batch_delete_gmail(input_data: dict) -> dict: "description": "Max threads.", "example": 25, }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -465,6 +512,7 @@ def list_gmail_threads(input_data: dict) -> dict: return run_client_sync( "gmail", "list_threads", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list threads.", query=input_data.get("query") or None, @@ -484,6 +532,11 @@ def list_gmail_threads(input_data: dict) -> dict: "description": "metadata | full | minimal.", "example": "metadata", }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -493,6 +546,7 @@ def get_gmail_thread(input_data: dict) -> dict: return run_client_sync( "gmail", "get_thread", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get thread.", thread_id=input_data["thread_id"], @@ -612,6 +666,11 @@ def delete_gmail_thread(input_data: dict) -> dict: input_schema={ "max_results": {"type": "integer", "description": "Max drafts.", "example": 25}, "query": {"type": "string", "description": "Optional q query.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -621,6 +680,7 @@ def list_gmail_drafts(input_data: dict) -> dict: return run_client_sync( "gmail", "list_drafts", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list drafts.", max_results=input_data.get("max_results", 25), @@ -639,6 +699,11 @@ def list_gmail_drafts(input_data: dict) -> dict: "description": "metadata | full | minimal.", "example": "metadata", }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -648,6 +713,7 @@ def get_gmail_draft(input_data: dict) -> dict: return run_client_sync( "gmail", "get_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get draft.", draft_id=input_data["draft_id"], @@ -670,6 +736,11 @@ def get_gmail_draft(input_data: dict) -> dict: "description": "Local file paths.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -680,6 +751,7 @@ def create_gmail_draft(input_data: dict) -> dict: return run_client_sync( "gmail", "create_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create draft.", to=input_data["to"], @@ -707,6 +779,11 @@ def create_gmail_draft(input_data: dict) -> dict: "description": "Local file paths.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -717,6 +794,7 @@ def update_gmail_draft(input_data: dict) -> dict: return run_client_sync( "gmail", "update_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to update draft.", draft_id=input_data["draft_id"], @@ -736,6 +814,11 @@ def update_gmail_draft(input_data: dict) -> dict: action_sets=["gmail_drafts", "gmail"], input_schema={ "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -746,6 +829,7 @@ def send_gmail_draft(input_data: dict) -> dict: return run_client_sync( "gmail", "send_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to send draft.", draft_id=input_data["draft_id"], @@ -758,6 +842,11 @@ def send_gmail_draft(input_data: dict) -> dict: action_sets=["gmail_drafts"], input_schema={ "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -768,6 +857,7 @@ def delete_gmail_draft(input_data: dict) -> dict: return run_client_sync( "gmail", "delete_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete draft.", draft_id=input_data["draft_id"], @@ -973,6 +1063,11 @@ def delete_gmail_label(input_data: dict) -> dict: "description": "Filename to use when save_to is a directory. Use the filename from get_gmail attachments list.", "example": "invoice.pdf", }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -983,6 +1078,7 @@ def download_gmail_attachment(input_data: dict) -> dict: return run_client_sync( "gmail", "download_attachment", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to download attachment.", message_id=input_data["message_id"], @@ -996,7 +1092,13 @@ def download_gmail_attachment(input_data: dict) -> dict: name="get_gmail_profile", description="Get the authenticated user's Gmail profile: email address, message/thread totals, historyId.", action_sets=["gmail_mail", "gmail"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_gmail_profile(input_data: dict) -> dict: @@ -1005,6 +1107,7 @@ def get_gmail_profile(input_data: dict) -> dict: return run_client_sync( "gmail", "get_profile", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get profile.", ) @@ -1034,6 +1137,11 @@ def get_gmail_profile(input_data: dict) -> dict: "example": "me@example.com", }, "attachments": {"type": "array", "description": "Attachments.", "example": []}, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1044,6 +1152,7 @@ def send_google_workspace_email(input_data: dict) -> dict: return run_client_sync( "gmail", "send_email", + account=input_data.get("account"), unwrap_envelope=True, success_message="Email sent.", fail_message="Failed to send email.", @@ -1067,6 +1176,11 @@ def send_google_workspace_email(input_data: dict) -> dict: "description": "Optional sender email.", "example": "me@example.com", }, + "account": { + "type": "string", + "description": "Optional Gmail account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1076,6 +1190,7 @@ def read_recent_google_workspace_emails(input_data: dict) -> dict: return run_client_sync( "gmail", "read_top_emails", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to read emails.", n=input_data.get("n", 5), diff --git a/app/data/action/integrations/google_workspace/google_calendar_actions.py b/app/data/action/integrations/google_workspace/google_calendar_actions.py index 0f022638..1dc2e98f 100644 --- a/app/data/action/integrations/google_workspace/google_calendar_actions.py +++ b/app/data/action/integrations/google_workspace/google_calendar_actions.py @@ -21,6 +21,11 @@ "description": "Calendar ID (default: primary).", "example": "primary", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -30,6 +35,7 @@ def create_google_meet(input_data: dict) -> dict: return run_client_sync( "google_calendar", "create_meet_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create event.", calendar_id=input_data.get("calendar_id", "primary"), @@ -57,6 +63,11 @@ def create_google_meet(input_data: dict) -> dict: "description": "Calendar ID (default: primary).", "example": "primary", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -66,6 +77,7 @@ def check_calendar_availability(input_data: dict) -> dict: return run_client_sync( "google_calendar", "check_availability", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to check availability.", calendar_id=input_data.get("calendar_id", "primary"), @@ -105,6 +117,11 @@ def check_calendar_availability(input_data: dict) -> dict: "description": "Sender.", "example": "me@example.com", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -119,9 +136,11 @@ def check_availability_and_schedule(input_data: dict) -> dict: except Exception as e: return {"status": "error", "message": str(e)} + account = input_data.get("account") avail = run_client_sync( "google_calendar", "check_availability", + account=account, unwrap_envelope=True, fail_message="Google Calendar FreeBusy API error", calendar_id="primary", @@ -162,6 +181,7 @@ def check_availability_and_schedule(input_data: dict) -> dict: result = run_client_sync( "google_calendar", "create_meet_event", + account=account, unwrap_envelope=True, fail_message="Google Calendar API error", calendar_id="primary", @@ -210,6 +230,11 @@ def check_availability_and_schedule(input_data: dict) -> dict: "description": "Max events to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -219,6 +244,7 @@ def list_google_calendar_events(input_data: dict) -> dict: return run_client_sync( "google_calendar", "list_events", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list events.", calendar_id=input_data.get("calendar_id", "primary"), @@ -239,6 +265,11 @@ def list_google_calendar_events(input_data: dict) -> dict: "description": "Calendar ID (default: primary).", "example": "primary", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -248,6 +279,7 @@ def get_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "get_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get event.", event_id=input_data["event_id"], @@ -280,6 +312,11 @@ def get_google_calendar_event(input_data: dict) -> dict: "description": "Set true if event_data includes attachments.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -290,6 +327,7 @@ def create_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "insert_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create event.", calendar_id=input_data.get("calendar_id", "primary"), @@ -320,6 +358,11 @@ def create_google_calendar_event(input_data: dict) -> dict: "description": "none, all, externalOnly.", "example": "none", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -330,6 +373,7 @@ def update_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "update_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to update event.", calendar_id=input_data.get("calendar_id", "primary"), @@ -360,6 +404,11 @@ def update_google_calendar_event(input_data: dict) -> dict: "description": "none, all, externalOnly.", "example": "none", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -370,6 +419,7 @@ def patch_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "patch_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to patch event.", calendar_id=input_data.get("calendar_id", "primary"), @@ -390,6 +440,11 @@ def patch_google_calendar_event(input_data: dict) -> dict: "description": "Calendar ID (default: primary).", "example": "primary", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -400,6 +455,7 @@ def delete_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "delete_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete event.", event_id=input_data["event_id"], @@ -467,6 +523,11 @@ def move_google_calendar_event(input_data: dict) -> dict: "description": "none, all, externalOnly.", "example": "none", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -477,6 +538,7 @@ def quick_add_google_calendar_event(input_data: dict) -> dict: return run_client_sync( "google_calendar", "quick_add_event", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to quick-add event.", calendar_id=input_data.get("calendar_id", "primary"), @@ -575,7 +637,13 @@ def import_google_calendar_event(input_data: dict) -> dict: name="list_google_calendars", description="List calendars the user has access to (from their calendarList).", action_sets=["google_calendar_admin", "google_calendar"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_google_calendars(input_data: dict) -> dict: @@ -584,6 +652,7 @@ def list_google_calendars(input_data: dict) -> dict: return run_client_sync( "google_calendar", "list_calendars", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list calendars.", ) @@ -599,6 +668,11 @@ def list_google_calendars(input_data: dict) -> dict: "description": "Calendar ID (default: primary).", "example": "primary", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -608,6 +682,7 @@ def get_google_calendar(input_data: dict) -> dict: return run_client_sync( "google_calendar", "get_calendar", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get calendar.", calendar_id=input_data.get("calendar_id", "primary"), diff --git a/app/data/action/integrations/google_workspace/google_docs_actions.py b/app/data/action/integrations/google_workspace/google_docs_actions.py index 8eafeb1e..025a7afb 100644 --- a/app/data/action/integrations/google_workspace/google_docs_actions.py +++ b/app/data/action/integrations/google_workspace/google_docs_actions.py @@ -17,6 +17,11 @@ "description": "Title for the new document.", "example": "Meeting Notes", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -26,6 +31,7 @@ def create_google_doc(input_data: dict) -> dict: return run_client_sync( "google_docs", "create_document", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create Google Doc.", title=input_data["title"], @@ -42,6 +48,11 @@ def create_google_doc(input_data: dict) -> dict: "description": "The Google Doc's document ID.", "example": "1abcDEF...", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -51,6 +62,7 @@ def get_google_doc(input_data: dict) -> dict: return run_client_sync( "google_docs", "get_document", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to fetch document.", document_id=input_data["document_id"], @@ -67,6 +79,11 @@ def get_google_doc(input_data: dict) -> dict: "description": "The Google Doc's document ID.", "example": "1abcDEF...", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -76,6 +93,7 @@ def get_google_doc_text(input_data: dict) -> dict: return run_client_sync( "google_docs", "get_document_text", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to read document.", document_id=input_data["document_id"], @@ -92,6 +110,11 @@ def get_google_doc_text(input_data: dict) -> dict: "description": "Max number of docs to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -101,6 +124,7 @@ def list_google_docs(input_data: dict) -> dict: return run_client_sync( "google_docs", "list_documents", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list docs.", max_results=input_data.get("max_results", 50), @@ -122,6 +146,11 @@ def list_google_docs(input_data: dict) -> dict: "description": "Max number of docs to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -131,6 +160,7 @@ def search_google_docs(input_data: dict) -> dict: return run_client_sync( "google_docs", "search_documents", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to search docs.", query=input_data["query"], @@ -148,6 +178,11 @@ def search_google_docs(input_data: dict) -> dict: "description": "The Google Doc's document ID.", "example": "1abcDEF...", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -158,6 +193,7 @@ def delete_google_doc(input_data: dict) -> dict: return run_client_sync( "google_docs", "delete_document", + account=input_data.get("account"), unwrap_envelope=True, success_message="Document deleted.", fail_message="Failed to delete document.", @@ -180,6 +216,11 @@ def delete_google_doc(input_data: dict) -> dict: "description": "Title for the copy.", "example": "Meeting Notes (copy)", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -190,6 +231,7 @@ def copy_google_doc(input_data: dict) -> dict: return run_client_sync( "google_docs", "copy_document", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to copy document.", document_id=input_data["document_id"], @@ -217,6 +259,11 @@ def copy_google_doc(input_data: dict) -> dict: "description": "Local file path to write to.", "example": "/tmp/doc.pdf", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -226,6 +273,7 @@ def export_google_doc(input_data: dict) -> dict: return run_client_sync( "google_docs", "export_document", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to export document.", document_id=input_data["document_id"], diff --git a/app/data/action/integrations/google_workspace/google_drive_actions.py b/app/data/action/integrations/google_workspace/google_drive_actions.py index e8c2861f..7224ea61 100644 --- a/app/data/action/integrations/google_workspace/google_drive_actions.py +++ b/app/data/action/integrations/google_workspace/google_drive_actions.py @@ -16,6 +16,11 @@ "description": "Google Drive folder ID. Use 'root' for the user's My Drive.", "example": "root", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -25,6 +30,7 @@ def list_drive_files(input_data: dict) -> dict: return run_client_sync( "google_drive", "list_drive_files", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list files.", folder_id=input_data["folder_id"], @@ -46,6 +52,11 @@ def list_drive_files(input_data: dict) -> dict: "description": "Max results.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -55,6 +66,7 @@ def search_drive_files(input_data: dict) -> dict: return run_client_sync( "google_drive", "search_drive", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to search files.", query=input_data["query"], @@ -73,6 +85,11 @@ def search_drive_files(input_data: dict) -> dict: "description": "Comma-separated field list (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -82,6 +99,7 @@ def get_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "get_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get file.", file_id=input_data["file_id"], @@ -104,6 +122,11 @@ def get_drive_file(input_data: dict) -> dict: "description": "Optional parent folder ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -114,6 +137,7 @@ def create_drive_folder(input_data: dict) -> dict: return run_client_sync( "google_drive", "create_drive_folder", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create folder.", name=input_data["name"], @@ -146,6 +170,11 @@ def create_drive_folder(input_data: dict) -> dict: "description": "Target folder ID (defaults to root).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -156,6 +185,7 @@ def upload_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "upload_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to upload file.", file_path=input_data["file_path"], @@ -185,6 +215,11 @@ def upload_drive_file(input_data: dict) -> dict: "description": "MIME type (defaults to autodetect).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -195,6 +230,7 @@ def update_drive_file_content(input_data: dict) -> dict: return run_client_sync( "google_drive", "update_drive_file_content", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to update file content.", file_id=input_data["file_id"], @@ -214,6 +250,11 @@ def update_drive_file_content(input_data: dict) -> dict: "description": "Local path to save to. Parent directories will be created.", "example": "C:/Users/me/downloads/report.pdf", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -224,6 +265,7 @@ def download_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "download_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to download file.", file_id=input_data["file_id"], @@ -251,6 +293,11 @@ def download_drive_file(input_data: dict) -> dict: "description": "Target export MIME type.", "example": "application/pdf", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -261,6 +308,7 @@ def export_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "export_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to export file.", file_id=input_data["file_id"], @@ -285,6 +333,11 @@ def export_drive_file(input_data: dict) -> dict: "description": "Target folder ID (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -295,6 +348,7 @@ def copy_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "copy_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to copy file.", file_id=input_data["file_id"], @@ -323,6 +377,11 @@ def copy_drive_file(input_data: dict) -> dict: "description": "Current parent folder ID.", "example": "root", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -333,6 +392,7 @@ def move_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "move_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to move file.", file_id=input_data["file_id"], @@ -367,6 +427,11 @@ def move_drive_file(input_data: dict) -> dict: "description": "Send to trash without deleting (optional).", "example": False, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -377,6 +442,7 @@ def update_drive_file_metadata(input_data: dict) -> dict: return run_client_sync( "google_drive", "update_drive_file_metadata", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to update file.", file_id=input_data["file_id"], @@ -393,6 +459,11 @@ def update_drive_file_metadata(input_data: dict) -> dict: action_sets=["google_drive_files", "google_drive"], input_schema={ "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -403,6 +474,7 @@ def delete_drive_file(input_data: dict) -> dict: return run_client_sync( "google_drive", "delete_drive_file", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete file.", file_id=input_data["file_id"], @@ -432,7 +504,13 @@ def empty_drive_trash(input_data: dict) -> dict: name="get_drive_about", description="Get Drive account info: storage quota, max upload size, supported export/import formats, root folder ID.", action_sets=["google_drive_files", "google_drive"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_drive_about(input_data: dict) -> dict: @@ -441,6 +519,7 @@ def get_drive_about(input_data: dict) -> dict: return run_client_sync( "google_drive", "get_drive_about", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get Drive info.", ) @@ -462,6 +541,11 @@ def get_drive_about(input_data: dict) -> dict: "description": "Email.", "example": "me@example.com", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -471,6 +555,7 @@ def find_drive_folder_by_name(input_data: dict) -> dict: return run_client_sync( "google_drive", "find_drive_folder_by_name", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to find folder.", name=input_data["name"], @@ -489,6 +574,11 @@ def find_drive_folder_by_name(input_data: dict) -> dict: "description": "Email.", "example": "me@example.com", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -505,6 +595,7 @@ def resolve_drive_folder_path(input_data: dict) -> dict: result = run_client_sync( "google_drive", "find_drive_folder_by_name", + account=input_data.get("account"), unwrap_envelope=True, fail_message=f"Failed to look up '{part}'", name=part, diff --git a/app/data/action/integrations/google_workspace/google_youtube_actions.py b/app/data/action/integrations/google_workspace/google_youtube_actions.py index ec9fee2a..c1899acb 100644 --- a/app/data/action/integrations/google_workspace/google_youtube_actions.py +++ b/app/data/action/integrations/google_workspace/google_youtube_actions.py @@ -5,7 +5,13 @@ name="get_my_youtube_channel", description="Return the authenticated user's YouTube channel info (id, title, subscriber/view counts).", action_sets=["google_youtube"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_my_youtube_channel(input_data: dict) -> dict: @@ -14,6 +20,7 @@ def get_my_youtube_channel(input_data: dict) -> dict: return run_client_sync( "google_youtube", "get_my_channel", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to fetch channel.", ) @@ -39,6 +46,11 @@ def get_my_youtube_channel(input_data: dict) -> dict: "description": "Max number of results.", "example": 25, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -48,6 +60,7 @@ def search_youtube(input_data: dict) -> dict: return run_client_sync( "google_youtube", "search", + account=input_data.get("account"), unwrap_envelope=True, fail_message="YouTube search failed.", query=input_data["query"], @@ -66,6 +79,11 @@ def search_youtube(input_data: dict) -> dict: "description": "The YouTube video ID.", "example": "dQw4w9WgXcQ", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -75,6 +93,7 @@ def get_youtube_video(input_data: dict) -> dict: return run_client_sync( "google_youtube", "get_video", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to fetch video.", video_id=input_data["video_id"], @@ -91,6 +110,11 @@ def get_youtube_video(input_data: dict) -> dict: "description": "Max number of subscriptions to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -100,6 +124,7 @@ def list_my_youtube_subscriptions(input_data: dict) -> dict: return run_client_sync( "google_youtube", "list_my_subscriptions", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list subscriptions.", max_results=input_data.get("max_results", 50), @@ -116,6 +141,11 @@ def list_my_youtube_subscriptions(input_data: dict) -> dict: "description": "Max number of playlists to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -125,6 +155,7 @@ def list_my_youtube_playlists(input_data: dict) -> dict: return run_client_sync( "google_youtube", "list_my_playlists", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list playlists.", max_results=input_data.get("max_results", 50), @@ -146,6 +177,11 @@ def list_my_youtube_playlists(input_data: dict) -> dict: "description": "Max number of items to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -155,6 +191,7 @@ def list_youtube_playlist_items(input_data: dict) -> dict: return run_client_sync( "google_youtube", "list_playlist_items", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list playlist items.", playlist_id=input_data["playlist_id"], @@ -172,6 +209,11 @@ def list_youtube_playlist_items(input_data: dict) -> dict: "description": "The channel ID to subscribe to.", "example": "UC...", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -181,6 +223,7 @@ def subscribe_to_youtube_channel(input_data: dict) -> dict: return run_client_sync( "google_youtube", "subscribe", + account=input_data.get("account"), unwrap_envelope=True, success_message="Subscribed.", fail_message="Failed to subscribe.", @@ -198,6 +241,11 @@ def subscribe_to_youtube_channel(input_data: dict) -> dict: "description": "The subscription record ID.", "example": "abc123...", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -207,6 +255,7 @@ def unsubscribe_from_youtube_channel(input_data: dict) -> dict: return run_client_sync( "google_youtube", "unsubscribe", + account=input_data.get("account"), unwrap_envelope=True, success_message="Unsubscribed.", fail_message="Failed to unsubscribe.", @@ -229,6 +278,11 @@ def unsubscribe_from_youtube_channel(input_data: dict) -> dict: "description": "One of: like, dislike, none.", "example": "like", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -238,6 +292,7 @@ def rate_youtube_video(input_data: dict) -> dict: return run_client_sync( "google_youtube", "rate_video", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to rate video.", video_id=input_data["video_id"], @@ -261,6 +316,11 @@ def rate_youtube_video(input_data: dict) -> dict: "description": "Comment text.", "example": "Great video!", }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -270,6 +330,7 @@ def post_youtube_comment(input_data: dict) -> dict: return run_client_sync( "google_youtube", "post_comment", + account=input_data.get("account"), unwrap_envelope=True, success_message="Comment posted.", fail_message="Failed to post comment.", @@ -293,6 +354,11 @@ def post_youtube_comment(input_data: dict) -> dict: "description": "Max number of comments to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional Google account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -302,6 +368,7 @@ def get_youtube_video_comments(input_data: dict) -> dict: return run_client_sync( "google_youtube", "get_video_comments", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to fetch comments.", video_id=input_data["video_id"], diff --git a/app/data/action/integrations/hubspot/hubspot_actions.py b/app/data/action/integrations/hubspot/hubspot_actions.py index 823fe33a..413896a8 100644 --- a/app/data/action/integrations/hubspot/hubspot_actions.py +++ b/app/data/action/integrations/hubspot/hubspot_actions.py @@ -44,6 +44,11 @@ "description": "Include archived contacts.", "example": False, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -54,6 +59,7 @@ async def list_hubspot_contacts(input_data: dict) -> dict: return await run_client( "hubspot", "list_contacts", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -81,6 +87,11 @@ async def list_hubspot_contacts(input_data: dict) -> dict: "description": "Comma-separated object types to include associations for.", "example": "companies,deals", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -92,6 +103,7 @@ async def get_hubspot_contact(input_data: dict) -> dict: return await run_client( "hubspot", "get_contact", + account=input_data.get("account"), contact_id=input_data["contact_id"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, associations=[a.strip() for a in assocs.split(",") if a.strip()] or None, @@ -112,6 +124,11 @@ async def get_hubspot_contact(input_data: dict) -> dict: "lastname": "Doe", }, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -122,6 +139,7 @@ async def create_hubspot_contact(input_data: dict) -> dict: return await run_client( "hubspot", "create_contact", + account=input_data.get("account"), properties=input_data["properties"], ) @@ -141,6 +159,11 @@ async def create_hubspot_contact(input_data: dict) -> dict: "description": "Properties to update (flat dict).", "example": {"phone": "+1-555-0100"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -151,6 +174,7 @@ async def update_hubspot_contact(input_data: dict) -> dict: return await run_client( "hubspot", "update_contact", + account=input_data.get("account"), contact_id=input_data["contact_id"], properties=input_data["properties"], ) @@ -166,6 +190,11 @@ async def update_hubspot_contact(input_data: dict) -> dict: "description": "Contact ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -174,7 +203,10 @@ async def delete_hubspot_contact(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "delete_contact", contact_id=input_data["contact_id"] + "hubspot", + "delete_contact", + account=input_data.get("account"), + contact_id=input_data["contact_id"], ) @@ -214,6 +246,11 @@ async def delete_hubspot_contact(input_data: dict) -> dict: "example": 30, }, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -224,6 +261,7 @@ async def search_hubspot_contacts(input_data: dict) -> dict: return await run_client( "hubspot", "search_contacts", + account=input_data.get("account"), query=input_data.get("query") or None, filter_groups=input_data.get("filter_groups") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -247,6 +285,11 @@ async def search_hubspot_contacts(input_data: dict) -> dict: "description": "Comma-separated properties to return.", "example": "email,firstname", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -257,6 +300,7 @@ async def batch_get_hubspot_contacts(input_data: dict) -> dict: return await run_client( "hubspot", "batch_get_contacts", + account=input_data.get("account"), ids=input_data["ids"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) @@ -272,6 +316,11 @@ async def batch_get_hubspot_contacts(input_data: dict) -> dict: "description": "List of property dicts.", "example": [{"email": "a@x.com"}, {"email": "b@x.com"}], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -280,7 +329,10 @@ async def batch_create_hubspot_contacts(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "batch_create_contacts", records=input_data["records"] + "hubspot", + "batch_create_contacts", + account=input_data.get("account"), + records=input_data["records"], ) @@ -299,6 +351,11 @@ async def batch_create_hubspot_contacts(input_data: dict) -> dict: "description": "Contact ID that gets merged INTO the primary.", "example": "456", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -309,6 +366,7 @@ async def merge_hubspot_contacts(input_data: dict) -> dict: return await run_client( "hubspot", "merge_contacts", + account=input_data.get("account"), primary_id=input_data["primary_id"], id_to_merge=input_data["id_to_merge"], ) @@ -340,6 +398,11 @@ async def merge_hubspot_contacts(input_data: dict) -> dict: "description": "Include archived.", "example": False, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -350,6 +413,7 @@ async def list_hubspot_companies(input_data: dict) -> dict: return await run_client( "hubspot", "list_companies", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -377,6 +441,11 @@ async def list_hubspot_companies(input_data: dict) -> dict: "description": "Comma-separated association types.", "example": "contacts,deals", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -388,6 +457,7 @@ async def get_hubspot_company(input_data: dict) -> dict: return await run_client( "hubspot", "get_company", + account=input_data.get("account"), company_id=input_data["company_id"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, associations=[a.strip() for a in assocs.split(",") if a.strip()] or None, @@ -404,6 +474,11 @@ async def get_hubspot_company(input_data: dict) -> dict: "description": "Flat property dict.", "example": {"name": "Acme Co", "domain": "acme.com"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -412,7 +487,10 @@ async def create_hubspot_company(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "create_company", properties=input_data["properties"] + "hubspot", + "create_company", + account=input_data.get("account"), + properties=input_data["properties"], ) @@ -431,6 +509,11 @@ async def create_hubspot_company(input_data: dict) -> dict: "description": "Properties to update.", "example": {"industry": "Software"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -441,6 +524,7 @@ async def update_hubspot_company(input_data: dict) -> dict: return await run_client( "hubspot", "update_company", + account=input_data.get("account"), company_id=input_data["company_id"], properties=input_data["properties"], ) @@ -456,6 +540,11 @@ async def update_hubspot_company(input_data: dict) -> dict: "description": "Company ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -464,7 +553,10 @@ async def delete_hubspot_company(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "delete_company", company_id=input_data["company_id"] + "hubspot", + "delete_company", + account=input_data.get("account"), + company_id=input_data["company_id"], ) @@ -500,6 +592,11 @@ async def delete_hubspot_company(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -510,6 +607,7 @@ async def search_hubspot_companies(input_data: dict) -> dict: return await run_client( "hubspot", "search_companies", + account=input_data.get("account"), query=input_data.get("query") or None, filter_groups=input_data.get("filter_groups") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -533,6 +631,11 @@ async def search_hubspot_companies(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "name,domain", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -543,6 +646,7 @@ async def batch_get_hubspot_companies(input_data: dict) -> dict: return await run_client( "hubspot", "batch_get_companies", + account=input_data.get("account"), ids=input_data["ids"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, ) @@ -558,6 +662,11 @@ async def batch_get_hubspot_companies(input_data: dict) -> dict: "description": "List of property dicts.", "example": [{"name": "Acme"}, {"name": "Foo"}], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -566,7 +675,10 @@ async def batch_create_hubspot_companies(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "batch_create_companies", records=input_data["records"] + "hubspot", + "batch_create_companies", + account=input_data.get("account"), + records=input_data["records"], ) @@ -592,6 +704,11 @@ async def batch_create_hubspot_companies(input_data: dict) -> dict: "description": "Include archived.", "example": False, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -602,6 +719,7 @@ async def list_hubspot_deals(input_data: dict) -> dict: return await run_client( "hubspot", "list_deals", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -629,6 +747,11 @@ async def list_hubspot_deals(input_data: dict) -> dict: "description": "Comma-separated association types.", "example": "contacts,companies", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -640,6 +763,7 @@ async def get_hubspot_deal(input_data: dict) -> dict: return await run_client( "hubspot", "get_deal", + account=input_data.get("account"), deal_id=input_data["deal_id"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, associations=[a.strip() for a in assocs.split(",") if a.strip()] or None, @@ -660,6 +784,11 @@ async def get_hubspot_deal(input_data: dict) -> dict: "dealstage": "qualifiedtobuy", }, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -668,7 +797,10 @@ async def create_hubspot_deal(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "create_deal", properties=input_data["properties"] + "hubspot", + "create_deal", + account=input_data.get("account"), + properties=input_data["properties"], ) @@ -687,6 +819,11 @@ async def create_hubspot_deal(input_data: dict) -> dict: "description": "Properties to update.", "example": {"amount": "75000"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -697,6 +834,7 @@ async def update_hubspot_deal(input_data: dict) -> dict: return await run_client( "hubspot", "update_deal", + account=input_data.get("account"), deal_id=input_data["deal_id"], properties=input_data["properties"], ) @@ -712,6 +850,11 @@ async def update_hubspot_deal(input_data: dict) -> dict: "description": "Deal ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -719,7 +862,12 @@ async def update_hubspot_deal(input_data: dict) -> dict: async def delete_hubspot_deal(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "delete_deal", deal_id=input_data["deal_id"]) + return await run_client( + "hubspot", + "delete_deal", + account=input_data.get("account"), + deal_id=input_data["deal_id"], + ) @action( @@ -754,6 +902,11 @@ async def delete_hubspot_deal(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -764,6 +917,7 @@ async def search_hubspot_deals(input_data: dict) -> dict: return await run_client( "hubspot", "search_deals", + account=input_data.get("account"), query=input_data.get("query") or None, filter_groups=input_data.get("filter_groups") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -782,6 +936,11 @@ async def search_hubspot_deals(input_data: dict) -> dict: "description": "List of property dicts.", "example": [{"dealname": "A"}, {"dealname": "B"}], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -790,7 +949,10 @@ async def batch_create_hubspot_deals(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "batch_create_deals", records=input_data["records"] + "hubspot", + "batch_create_deals", + account=input_data.get("account"), + records=input_data["records"], ) @@ -809,6 +971,11 @@ async def batch_create_hubspot_deals(input_data: dict) -> dict: "description": "Target stage ID (use list_hubspot_pipeline_stages to find).", "example": "closedwon", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -819,6 +986,7 @@ async def move_hubspot_deal_stage(input_data: dict) -> dict: return await run_client( "hubspot", "move_deal_stage", + account=input_data.get("account"), deal_id=input_data["deal_id"], stage_id=input_data["stage_id"], ) @@ -836,6 +1004,11 @@ async def move_hubspot_deal_stage(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -845,6 +1018,7 @@ async def list_hubspot_deals_by_pipeline(input_data: dict) -> dict: return await run_client( "hubspot", "list_deals_by_pipeline", + account=input_data.get("account"), pipeline_id=input_data["pipeline_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, @@ -873,6 +1047,11 @@ async def list_hubspot_deals_by_pipeline(input_data: dict) -> dict: "description": "Include archived.", "example": False, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -883,6 +1062,7 @@ async def list_hubspot_tickets(input_data: dict) -> dict: return await run_client( "hubspot", "list_tickets", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -910,6 +1090,11 @@ async def list_hubspot_tickets(input_data: dict) -> dict: "description": "Comma-separated association types.", "example": "contacts,companies", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -921,6 +1106,7 @@ async def get_hubspot_ticket(input_data: dict) -> dict: return await run_client( "hubspot", "get_ticket", + account=input_data.get("account"), ticket_id=input_data["ticket_id"], properties=[p.strip() for p in props.split(",") if p.strip()] or None, associations=[a.strip() for a in assocs.split(",") if a.strip()] or None, @@ -941,6 +1127,11 @@ async def get_hubspot_ticket(input_data: dict) -> dict: "hs_ticket_priority": "HIGH", }, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -949,7 +1140,10 @@ async def create_hubspot_ticket(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "create_ticket", properties=input_data["properties"] + "hubspot", + "create_ticket", + account=input_data.get("account"), + properties=input_data["properties"], ) @@ -968,6 +1162,11 @@ async def create_hubspot_ticket(input_data: dict) -> dict: "description": "Properties to update.", "example": {"hs_ticket_priority": "URGENT"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -978,6 +1177,7 @@ async def update_hubspot_ticket(input_data: dict) -> dict: return await run_client( "hubspot", "update_ticket", + account=input_data.get("account"), ticket_id=input_data["ticket_id"], properties=input_data["properties"], ) @@ -993,6 +1193,11 @@ async def update_hubspot_ticket(input_data: dict) -> dict: "description": "Ticket ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1001,7 +1206,10 @@ async def delete_hubspot_ticket(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "delete_ticket", ticket_id=input_data["ticket_id"] + "hubspot", + "delete_ticket", + account=input_data.get("account"), + ticket_id=input_data["ticket_id"], ) @@ -1037,6 +1245,11 @@ async def delete_hubspot_ticket(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1047,6 +1260,7 @@ async def search_hubspot_tickets(input_data: dict) -> dict: return await run_client( "hubspot", "search_tickets", + account=input_data.get("account"), query=input_data.get("query") or None, filter_groups=input_data.get("filter_groups") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1070,6 +1284,11 @@ async def search_hubspot_tickets(input_data: dict) -> dict: "description": "Closed-stage ID for this pipeline (use list_hubspot_pipeline_stages).", "example": "4", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1080,6 +1299,7 @@ async def close_hubspot_ticket(input_data: dict) -> dict: return await run_client( "hubspot", "close_ticket", + account=input_data.get("account"), ticket_id=input_data["ticket_id"], closed_stage_id=input_data["closed_stage_id"], ) @@ -1097,6 +1317,11 @@ async def close_hubspot_ticket(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1106,6 +1331,7 @@ async def list_hubspot_tickets_by_pipeline(input_data: dict) -> dict: return await run_client( "hubspot", "list_tickets_by_pipeline", + account=input_data.get("account"), pipeline_id=input_data["pipeline_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, @@ -1129,6 +1355,11 @@ async def list_hubspot_tickets_by_pipeline(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "hs_task_subject,hs_task_status,hs_timestamp", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1139,6 +1370,7 @@ async def list_hubspot_tasks(input_data: dict) -> dict: return await run_client( "hubspot", "list_tasks", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1190,6 +1422,11 @@ async def list_hubspot_tasks(input_data: dict) -> dict: "description": "ID of the associated object.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1200,6 +1437,7 @@ async def create_hubspot_task(input_data: dict) -> dict: return await run_client( "hubspot", "create_task", + account=input_data.get("account"), subject=input_data["subject"], body=input_data.get("body", ""), due_timestamp_ms=input_data.get("due_timestamp_ms"), @@ -1226,6 +1464,11 @@ async def create_hubspot_task(input_data: dict) -> dict: "description": "Properties to update.", "example": {"hs_task_status": "COMPLETED"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1236,6 +1479,7 @@ async def update_hubspot_task(input_data: dict) -> dict: return await run_client( "hubspot", "update_task", + account=input_data.get("account"), task_id=input_data["task_id"], properties=input_data["properties"], ) @@ -1251,6 +1495,11 @@ async def update_hubspot_task(input_data: dict) -> dict: "description": "Task ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1258,7 +1507,12 @@ async def update_hubspot_task(input_data: dict) -> dict: async def delete_hubspot_task(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "delete_task", task_id=input_data["task_id"]) + return await run_client( + "hubspot", + "delete_task", + account=input_data.get("account"), + task_id=input_data["task_id"], + ) @action( @@ -1273,6 +1527,11 @@ async def delete_hubspot_task(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "hs_note_body,hs_timestamp", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1283,6 +1542,7 @@ async def list_hubspot_notes(input_data: dict) -> dict: return await run_client( "hubspot", "list_notes", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1310,6 +1570,11 @@ async def list_hubspot_notes(input_data: dict) -> dict: "description": "ID of associated object.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1320,6 +1585,7 @@ async def create_hubspot_note(input_data: dict) -> dict: return await run_client( "hubspot", "create_note", + account=input_data.get("account"), body=input_data["body"], owner_id=input_data.get("owner_id") or None, associated_object_type=input_data.get("associated_object_type") or None, @@ -1337,6 +1603,11 @@ async def create_hubspot_note(input_data: dict) -> dict: "description": "Note ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1344,7 +1615,12 @@ async def create_hubspot_note(input_data: dict) -> dict: async def delete_hubspot_note(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "delete_note", note_id=input_data["note_id"]) + return await run_client( + "hubspot", + "delete_note", + account=input_data.get("account"), + note_id=input_data["note_id"], + ) @action( @@ -1359,6 +1635,11 @@ async def delete_hubspot_note(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "hs_call_title,hs_call_duration,hs_call_direction", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1369,6 +1650,7 @@ async def list_hubspot_calls(input_data: dict) -> dict: return await run_client( "hubspot", "list_calls", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1431,6 +1713,11 @@ async def list_hubspot_calls(input_data: dict) -> dict: "description": "Associated object ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1441,6 +1728,7 @@ async def log_hubspot_call(input_data: dict) -> dict: return await run_client( "hubspot", "log_call", + account=input_data.get("account"), title=input_data["title"], body=input_data.get("body", ""), timestamp_ms=input_data.get("timestamp_ms"), @@ -1467,6 +1755,11 @@ async def log_hubspot_call(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "hs_email_subject,hs_email_direction", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1477,6 +1770,7 @@ async def list_hubspot_emails(input_data: dict) -> dict: return await run_client( "hubspot", "list_emails", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1534,6 +1828,11 @@ async def list_hubspot_emails(input_data: dict) -> dict: "description": "Associated object ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1544,6 +1843,7 @@ async def log_hubspot_email(input_data: dict) -> dict: return await run_client( "hubspot", "log_email", + account=input_data.get("account"), subject=input_data["subject"], text_body=input_data.get("text_body", ""), html_body=input_data.get("html_body", ""), @@ -1569,6 +1869,11 @@ async def log_hubspot_email(input_data: dict) -> dict: "description": "Comma-separated properties.", "example": "hs_meeting_title,hs_meeting_start_time", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1579,6 +1884,7 @@ async def list_hubspot_meetings(input_data: dict) -> dict: return await run_client( "hubspot", "list_meetings", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, properties=[p.strip() for p in props.split(",") if p.strip()] or None, @@ -1631,6 +1937,11 @@ async def list_hubspot_meetings(input_data: dict) -> dict: "description": "Associated object ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1641,6 +1952,7 @@ async def create_hubspot_meeting(input_data: dict) -> dict: return await run_client( "hubspot", "create_meeting", + account=input_data.get("account"), title=input_data["title"], body=input_data.get("body", ""), start_timestamp_ms=input_data["start_timestamp_ms"], @@ -1663,6 +1975,11 @@ async def create_hubspot_meeting(input_data: dict) -> dict: "description": "Meeting ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1671,7 +1988,10 @@ async def delete_hubspot_meeting(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "delete_meeting", meeting_id=input_data["meeting_id"] + "hubspot", + "delete_meeting", + account=input_data.get("account"), + meeting_id=input_data["meeting_id"], ) @@ -1695,6 +2015,11 @@ async def delete_hubspot_meeting(input_data: dict) -> dict: "description": "Optional: specific list IDs to fetch.", "example": [], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1704,6 +2029,7 @@ async def list_hubspot_lists(input_data: dict) -> dict: return await run_client( "hubspot", "list_lists", + account=input_data.get("account"), limit=input_data.get("limit", 30), list_ids=input_data.get("list_ids") or None, ) @@ -1715,13 +2041,23 @@ async def list_hubspot_lists(input_data: dict) -> dict: action_sets=["hubspot_lists"], input_schema={ "list_id": {"type": "string", "description": "List ID.", "example": "1"}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_hubspot_list(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "get_list", list_id=input_data["list_id"]) + return await run_client( + "hubspot", + "get_list", + account=input_data.get("account"), + list_id=input_data["list_id"], + ) @action( @@ -1749,6 +2085,11 @@ async def get_hubspot_list(input_data: dict) -> dict: "description": "Filter tree for DYNAMIC lists.", "example": {}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1759,6 +2100,7 @@ async def create_hubspot_list(input_data: dict) -> dict: return await run_client( "hubspot", "create_list", + account=input_data.get("account"), name=input_data["name"], object_type_id=input_data.get("object_type_id", "0-1"), processing_type=input_data.get("processing_type", "MANUAL"), @@ -1772,6 +2114,11 @@ async def create_hubspot_list(input_data: dict) -> dict: action_sets=["hubspot_lists"], input_schema={ "list_id": {"type": "string", "description": "List ID.", "example": "1"}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1779,7 +2126,12 @@ async def create_hubspot_list(input_data: dict) -> dict: async def delete_hubspot_list(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "delete_list", list_id=input_data["list_id"]) + return await run_client( + "hubspot", + "delete_list", + account=input_data.get("account"), + list_id=input_data["list_id"], + ) @action( @@ -1793,6 +2145,11 @@ async def delete_hubspot_list(input_data: dict) -> dict: "description": "Contact IDs to add.", "example": ["123", "456"], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1803,6 +2160,7 @@ async def add_contacts_to_hubspot_list(input_data: dict) -> dict: return await run_client( "hubspot", "add_contacts_to_list", + account=input_data.get("account"), list_id=input_data["list_id"], contact_ids=input_data["contact_ids"], ) @@ -1819,6 +2177,11 @@ async def add_contacts_to_hubspot_list(input_data: dict) -> dict: "description": "Contact IDs to remove.", "example": ["123", "456"], }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1829,6 +2192,7 @@ async def remove_contacts_from_hubspot_list(input_data: dict) -> dict: return await run_client( "hubspot", "remove_contacts_from_list", + account=input_data.get("account"), list_id=input_data["list_id"], contact_ids=input_data["contact_ids"], ) @@ -1849,6 +2213,11 @@ async def remove_contacts_from_hubspot_list(input_data: dict) -> dict: "description": "Object type: deals or tickets.", "example": "deals", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1856,7 +2225,10 @@ async def list_hubspot_pipelines(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "list_pipelines", object_type=input_data["object_type"] + "hubspot", + "list_pipelines", + account=input_data.get("account"), + object_type=input_data["object_type"], ) @@ -1875,6 +2247,11 @@ async def list_hubspot_pipelines(input_data: dict) -> dict: "description": "Pipeline ID.", "example": "default", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1884,6 +2261,7 @@ async def get_hubspot_pipeline(input_data: dict) -> dict: return await run_client( "hubspot", "get_pipeline", + account=input_data.get("account"), object_type=input_data["object_type"], pipeline_id=input_data["pipeline_id"], ) @@ -1916,6 +2294,11 @@ async def get_hubspot_pipeline(input_data: dict) -> dict: "description": "Display order among pipelines.", "example": 0, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1926,6 +2309,7 @@ async def create_hubspot_pipeline(input_data: dict) -> dict: return await run_client( "hubspot", "create_pipeline", + account=input_data.get("account"), object_type=input_data["object_type"], label=input_data["label"], stages=input_data["stages"], @@ -1948,6 +2332,11 @@ async def create_hubspot_pipeline(input_data: dict) -> dict: "description": "Pipeline ID.", "example": "default", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1957,6 +2346,7 @@ async def list_hubspot_pipeline_stages(input_data: dict) -> dict: return await run_client( "hubspot", "list_pipeline_stages", + account=input_data.get("account"), object_type=input_data["object_type"], pipeline_id=input_data["pipeline_id"], ) @@ -1987,6 +2377,11 @@ async def list_hubspot_pipeline_stages(input_data: dict) -> dict: "description": "Stage fields to update.", "example": {"label": "Qualified — Buying"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1997,6 +2392,7 @@ async def update_hubspot_pipeline_stage(input_data: dict) -> dict: return await run_client( "hubspot", "update_pipeline_stage", + account=input_data.get("account"), object_type=input_data["object_type"], pipeline_id=input_data["pipeline_id"], stage_id=input_data["stage_id"], @@ -2024,6 +2420,11 @@ async def update_hubspot_pipeline_stage(input_data: dict) -> dict: "description": "Max results (1-500).", "example": 100, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2033,6 +2434,7 @@ async def list_hubspot_owners(input_data: dict) -> dict: return await run_client( "hubspot", "list_owners", + account=input_data.get("account"), email=input_data.get("email") or None, limit=input_data.get("limit", 100), ) @@ -2044,13 +2446,23 @@ async def list_hubspot_owners(input_data: dict) -> dict: action_sets=["hubspot_owners"], input_schema={ "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_hubspot_owner(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "get_owner", owner_id=input_data["owner_id"]) + return await run_client( + "hubspot", + "get_owner", + account=input_data.get("account"), + owner_id=input_data["owner_id"], + ) # ================================================================== @@ -2068,6 +2480,11 @@ async def get_hubspot_owner(input_data: dict) -> dict: "description": "contacts/companies/deals/tickets or custom schema name.", "example": "contacts", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2075,7 +2492,10 @@ async def list_hubspot_properties(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "list_properties", object_type=input_data["object_type"] + "hubspot", + "list_properties", + account=input_data.get("account"), + object_type=input_data["object_type"], ) @@ -2094,6 +2514,11 @@ async def list_hubspot_properties(input_data: dict) -> dict: "description": "Property internal name.", "example": "firstname", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2103,6 +2528,7 @@ async def get_hubspot_property(input_data: dict) -> dict: return await run_client( "hubspot", "get_property", + account=input_data.get("account"), object_type=input_data["object_type"], property_name=input_data["property_name"], ) @@ -2129,6 +2555,11 @@ async def get_hubspot_property(input_data: dict) -> dict: "groupName": "contactinformation", }, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2139,6 +2570,7 @@ async def create_hubspot_property(input_data: dict) -> dict: return await run_client( "hubspot", "create_property", + account=input_data.get("account"), object_type=input_data["object_type"], definition=input_data["definition"], ) @@ -2164,6 +2596,11 @@ async def create_hubspot_property(input_data: dict) -> dict: "description": "Fields to update.", "example": {"label": "Color preference"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2174,6 +2611,7 @@ async def update_hubspot_property(input_data: dict) -> dict: return await run_client( "hubspot", "update_property", + account=input_data.get("account"), object_type=input_data["object_type"], property_name=input_data["property_name"], definition=input_data["definition"], @@ -2195,6 +2633,11 @@ async def update_hubspot_property(input_data: dict) -> dict: "description": "Property internal name.", "example": "favorite_color", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2205,6 +2648,7 @@ async def delete_hubspot_property(input_data: dict) -> dict: return await run_client( "hubspot", "delete_property", + account=input_data.get("account"), object_type=input_data["object_type"], property_name=input_data["property_name"], ) @@ -2220,6 +2664,11 @@ async def delete_hubspot_property(input_data: dict) -> dict: "description": "Object type.", "example": "contacts", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2229,6 +2678,7 @@ async def list_hubspot_property_groups(input_data: dict) -> dict: return await run_client( "hubspot", "list_property_groups", + account=input_data.get("account"), object_type=input_data["object_type"], ) @@ -2268,6 +2718,11 @@ async def list_hubspot_property_groups(input_data: dict) -> dict: "description": "Optional: specific association type ID (use list_hubspot_association_types).", "example": 0, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2278,6 +2733,7 @@ async def create_hubspot_association(input_data: dict) -> dict: return await run_client( "hubspot", "create_association", + account=input_data.get("account"), from_object_type=input_data["from_object_type"], from_object_id=input_data["from_object_id"], to_object_type=input_data["to_object_type"], @@ -2312,6 +2768,11 @@ async def create_hubspot_association(input_data: dict) -> dict: "example": 100, }, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2321,6 +2782,7 @@ async def list_hubspot_associations(input_data: dict) -> dict: return await run_client( "hubspot", "list_associations", + account=input_data.get("account"), from_object_type=input_data["from_object_type"], from_object_id=input_data["from_object_id"], to_object_type=input_data["to_object_type"], @@ -2354,6 +2816,11 @@ async def list_hubspot_associations(input_data: dict) -> dict: "description": "Target ID.", "example": "456", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2364,6 +2831,7 @@ async def delete_hubspot_association(input_data: dict) -> dict: return await run_client( "hubspot", "delete_association", + account=input_data.get("account"), from_object_type=input_data["from_object_type"], from_object_id=input_data["from_object_id"], to_object_type=input_data["to_object_type"], @@ -2386,6 +2854,11 @@ async def delete_hubspot_association(input_data: dict) -> dict: "description": "Target type.", "example": "contacts", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2395,6 +2868,7 @@ async def list_hubspot_association_types(input_data: dict) -> dict: return await run_client( "hubspot", "list_association_types", + account=input_data.get("account"), from_object_type=input_data["from_object_type"], to_object_type=input_data["to_object_type"], ) @@ -2412,6 +2886,11 @@ async def list_hubspot_association_types(input_data: dict) -> dict: input_schema={ "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2421,6 +2900,7 @@ async def list_hubspot_forms(input_data: dict) -> dict: return await run_client( "hubspot", "list_forms", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) @@ -2436,13 +2916,23 @@ async def list_hubspot_forms(input_data: dict) -> dict: "description": "Form GUID.", "example": "abc12345-6789-0abc-def0-123456789abc", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_hubspot_form(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "get_form", form_id=input_data["form_id"]) + return await run_client( + "hubspot", + "get_form", + account=input_data.get("account"), + form_id=input_data["form_id"], + ) @action( @@ -2473,6 +2963,11 @@ async def get_hubspot_form(input_data: dict) -> dict: "description": "Optional context (hutk, pageUrl, pageName, ipAddress).", "example": {"pageName": "Demo Request"}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2483,6 +2978,7 @@ async def submit_hubspot_form(input_data: dict) -> dict: return await run_client( "hubspot", "submit_form", + account=input_data.get("account"), portal_id=input_data["portal_id"], form_guid=input_data["form_guid"], fields=input_data["fields"], @@ -2506,6 +3002,11 @@ async def submit_hubspot_form(input_data: dict) -> dict: "example": 30, }, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2515,6 +3016,7 @@ async def list_hubspot_form_submissions(input_data: dict) -> dict: return await run_client( "hubspot", "list_form_submissions", + account=input_data.get("account"), form_guid=input_data["form_guid"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, @@ -2533,6 +3035,11 @@ async def list_hubspot_form_submissions(input_data: dict) -> dict: input_schema={ "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2542,6 +3049,7 @@ async def list_hubspot_marketing_emails(input_data: dict) -> dict: return await run_client( "hubspot", "list_marketing_emails", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) @@ -2557,6 +3065,11 @@ async def list_hubspot_marketing_emails(input_data: dict) -> dict: "description": "Marketing email ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2564,7 +3077,10 @@ async def get_hubspot_marketing_email(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "get_marketing_email", email_id=input_data["email_id"] + "hubspot", + "get_marketing_email", + account=input_data.get("account"), + email_id=input_data["email_id"], ) @@ -2594,6 +3110,11 @@ async def get_hubspot_marketing_email(input_data: dict) -> dict: "description": "Optional contact-property overrides.", "example": {}, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2604,6 +3125,7 @@ async def send_hubspot_single_send(input_data: dict) -> dict: return await run_client( "hubspot", "send_single_email", + account=input_data.get("account"), email_id=input_data["email_id"], to_email=input_data["to_email"], custom_properties=input_data.get("custom_properties") or None, @@ -2621,6 +3143,11 @@ async def send_hubspot_single_send(input_data: dict) -> dict: "description": "Marketing email ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2630,6 +3157,7 @@ async def get_hubspot_marketing_email_statistics(input_data: dict) -> dict: return await run_client( "hubspot", "get_marketing_email_statistics", + account=input_data.get("account"), email_id=input_data["email_id"], ) @@ -2664,6 +3192,11 @@ async def get_hubspot_marketing_email_statistics(input_data: dict) -> dict: "description": "Overwrite existing file with the same name.", "example": False, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2674,6 +3207,7 @@ async def upload_hubspot_file(input_data: dict) -> dict: return await run_client( "hubspot", "upload_file", + account=input_data.get("account"), file_path=input_data["file_path"], folder_path=input_data.get("folder_path", "/"), access=input_data.get("access", "PRIVATE"), @@ -2691,13 +3225,23 @@ async def upload_hubspot_file(input_data: dict) -> dict: "description": "File ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_hubspot_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "get_file", file_id=input_data["file_id"]) + return await run_client( + "hubspot", + "get_file", + account=input_data.get("account"), + file_id=input_data["file_id"], + ) @action( @@ -2710,6 +3254,11 @@ async def get_hubspot_file(input_data: dict) -> dict: "description": "File ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2717,7 +3266,12 @@ async def get_hubspot_file(input_data: dict) -> dict: async def delete_hubspot_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client - return await run_client("hubspot", "delete_file", file_id=input_data["file_id"]) + return await run_client( + "hubspot", + "delete_file", + account=input_data.get("account"), + file_id=input_data["file_id"], + ) @action( @@ -2727,6 +3281,11 @@ async def delete_hubspot_file(input_data: dict) -> dict: input_schema={ "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2736,6 +3295,7 @@ async def list_hubspot_folders(input_data: dict) -> dict: return await run_client( "hubspot", "list_folders", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) @@ -2753,6 +3313,11 @@ async def list_hubspot_folders(input_data: dict) -> dict: input_schema={ "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2762,6 +3327,7 @@ async def list_hubspot_conversations(input_data: dict) -> dict: return await run_client( "hubspot", "list_conversations", + account=input_data.get("account"), limit=input_data.get("limit", 30), after=input_data.get("after") or None, ) @@ -2777,6 +3343,11 @@ async def list_hubspot_conversations(input_data: dict) -> dict: "description": "Thread ID.", "example": "123456789", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2784,7 +3355,10 @@ async def get_hubspot_conversation(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client return await run_client( - "hubspot", "get_conversation", thread_id=input_data["thread_id"] + "hubspot", + "get_conversation", + account=input_data.get("account"), + thread_id=input_data["thread_id"], ) @@ -2800,6 +3374,11 @@ async def get_hubspot_conversation(input_data: dict) -> dict: }, "limit": {"type": "integer", "description": "Max results.", "example": 30}, "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2809,6 +3388,7 @@ async def list_hubspot_conversation_messages(input_data: dict) -> dict: return await run_client( "hubspot", "list_conversation_messages", + account=input_data.get("account"), thread_id=input_data["thread_id"], limit=input_data.get("limit", 30), after=input_data.get("after") or None, @@ -2859,6 +3439,11 @@ async def list_hubspot_conversation_messages(input_data: dict) -> dict: "description": "Optional sender actor ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2869,6 +3454,7 @@ async def send_hubspot_conversation_message(input_data: dict) -> dict: return await run_client( "hubspot", "send_conversation_message", + account=input_data.get("account"), thread_id=input_data["thread_id"], text=input_data["text"], channel_id=input_data["channel_id"], @@ -2893,6 +3479,11 @@ async def send_hubspot_conversation_message(input_data: dict) -> dict: "description": "HubSpot App ID (developer console).", "example": "1234567", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -2902,6 +3493,7 @@ async def list_hubspot_webhook_subscriptions(input_data: dict) -> dict: return await run_client( "hubspot", "list_webhook_subscriptions", + account=input_data.get("account"), app_id=input_data["app_id"], ) @@ -2931,6 +3523,11 @@ async def list_hubspot_webhook_subscriptions(input_data: dict) -> dict: "description": "Whether the subscription is active.", "example": True, }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2941,6 +3538,7 @@ async def create_hubspot_webhook_subscription(input_data: dict) -> dict: return await run_client( "hubspot", "create_webhook_subscription", + account=input_data.get("account"), app_id=input_data["app_id"], event_type=input_data["event_type"], property_name=input_data.get("property_name") or None, @@ -2963,6 +3561,11 @@ async def create_hubspot_webhook_subscription(input_data: dict) -> dict: "description": "Subscription ID.", "example": "abc123", }, + "account": { + "type": "string", + "description": "Optional HubSpot portal (hub domain, hub id, or unique fragment, e.g. 'work'). Omit to use the primary portal.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -2973,6 +3576,7 @@ async def delete_hubspot_webhook_subscription(input_data: dict) -> dict: return await run_client( "hubspot", "delete_webhook_subscription", + account=input_data.get("account"), app_id=input_data["app_id"], subscription_id=input_data["subscription_id"], ) diff --git a/app/data/action/integrations/linkedin/linkedin_actions.py b/app/data/action/integrations/linkedin/linkedin_actions.py index a7f4f090..d70884fe 100644 --- a/app/data/action/integrations/linkedin/linkedin_actions.py +++ b/app/data/action/integrations/linkedin/linkedin_actions.py @@ -20,13 +20,21 @@ def _person_urn(client) -> str: name="get_linkedin_profile", description="Get the authenticated user's LinkedIn profile.", action_sets=["linkedin"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_profile(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "get_user_profile") + return run_client_sync( + "linkedin", "get_user_profile", account=input_data.get("account") + ) # ------------------------------------------------------------------ @@ -49,6 +57,11 @@ def get_linkedin_profile(input_data: dict) -> dict: "description": "Visibility: PUBLIC, CONNECTIONS, or LOGGED_IN.", "example": "PUBLIC", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -62,6 +75,7 @@ async def create_linkedin_post(input_data: dict) -> dict: input_data["text"], visibility=input_data.get("visibility", "PUBLIC"), ), + account=input_data.get("account"), ) @@ -74,14 +88,24 @@ async def create_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def delete_linkedin_post(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "delete_post", post_urn=input_data["post_urn"]) + return run_client_sync( + "linkedin", + "delete_post", + account=input_data.get("account"), + post_urn=input_data["post_urn"], + ) @action( @@ -93,21 +117,38 @@ def delete_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_post(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "get_post", post_urn=input_data["post_urn"]) + return run_client_sync( + "linkedin", + "get_post", + account=input_data.get("account"), + post_urn=input_data["post_urn"], + ) @action( name="get_my_linkedin_posts", description="Get my posts.", action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) async def get_my_linkedin_posts(input_data: dict) -> dict: @@ -118,6 +159,7 @@ async def get_my_linkedin_posts(input_data: dict) -> dict: lambda c: c.get_posts_by_author( _person_urn(c), count=input_data.get("count", 50) ), + account=input_data.get("account"), ) @@ -130,7 +172,12 @@ async def get_my_linkedin_posts(input_data: dict) -> dict: "type": "string", "description": "Org URN.", "example": "urn:li:organization:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -140,6 +187,7 @@ def get_linkedin_organization_posts(input_data: dict) -> dict: return run_client_sync( "linkedin", "get_posts_by_author", + account=input_data.get("account"), author_urn=input_data["organization_urn"], ) @@ -159,6 +207,11 @@ def get_linkedin_organization_posts(input_data: dict) -> dict: "description": "Commentary.", "example": "Interesting!", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -172,6 +225,7 @@ async def reshare_linkedin_post(input_data: dict) -> dict: input_data["original_post_urn"], commentary=input_data.get("commentary", ""), ), + account=input_data.get("account"), ) @@ -189,7 +243,12 @@ async def reshare_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -199,6 +258,7 @@ async def like_linkedin_post(input_data: dict) -> dict: return await with_client( "linkedin", lambda c: c.like_post(_person_urn(c), input_data["post_urn"]), + account=input_data.get("account"), ) @@ -211,7 +271,12 @@ async def like_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -221,6 +286,7 @@ async def unlike_linkedin_post(input_data: dict) -> dict: return await with_client( "linkedin", lambda c: c.unlike_post(_person_urn(c), input_data["post_urn"]), + account=input_data.get("account"), ) @@ -233,7 +299,12 @@ async def unlike_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -241,7 +312,10 @@ def get_linkedin_post_likes(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_post_reactions", post_urn=input_data["post_urn"] + "linkedin", + "get_post_reactions", + account=input_data.get("account"), + post_urn=input_data["post_urn"], ) @@ -260,6 +334,11 @@ def get_linkedin_post_likes(input_data: dict) -> dict: "description": "Comment text.", "example": "Great post!", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -271,6 +350,7 @@ async def comment_on_linkedin_post(input_data: dict) -> dict: lambda c: c.comment_on_post( _person_urn(c), input_data["post_urn"], input_data["text"] ), + account=input_data.get("account"), ) @@ -283,7 +363,12 @@ async def comment_on_linkedin_post(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -291,7 +376,10 @@ def get_linkedin_post_comments(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_post_comments", post_urn=input_data["post_urn"] + "linkedin", + "get_post_comments", + account=input_data.get("account"), + post_urn=input_data["post_urn"], ) @@ -310,6 +398,11 @@ def get_linkedin_post_comments(input_data: dict) -> dict: "description": "Comment URN.", "example": "urn:li:comment:123", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -321,6 +414,7 @@ async def delete_linkedin_comment(input_data: dict) -> dict: lambda c: c.delete_comment( _person_urn(c), input_data["post_urn"], input_data["comment_urn"] ), + account=input_data.get("account"), ) @@ -339,6 +433,11 @@ async def delete_linkedin_comment(input_data: dict) -> dict: "description": "Number of connections to return.", "example": 50, }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -346,7 +445,10 @@ def get_linkedin_connections(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_connections", count=input_data.get("count", 50) + "linkedin", + "get_connections", + account=input_data.get("account"), + count=input_data.get("count", 50), ) @@ -371,6 +473,11 @@ def get_linkedin_connections(input_data: dict) -> dict: "description": "Message body.", "example": "Hi, I wanted to connect...", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -385,6 +492,7 @@ async def send_linkedin_message(input_data: dict) -> dict: input_data["subject"], input_data["body"], ), + account=input_data.get("account"), ) @@ -400,6 +508,11 @@ async def send_linkedin_message(input_data: dict) -> dict: "example": "urn:li:person:123", }, "message": {"type": "string", "description": "Message.", "example": "Hi"}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -409,6 +522,7 @@ def send_linkedin_connection_request(input_data: dict) -> dict: return run_client_sync( "linkedin", "send_connection_request", + account=input_data.get("account"), invitee_profile_urn=input_data["invitee_profile_urn"], message=input_data.get("message"), ) @@ -418,14 +532,24 @@ def send_linkedin_connection_request(input_data: dict) -> dict: name="get_linkedin_sent_invitations", description="Get sent invitations.", action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_sent_invitations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_sent_invitations", count=input_data.get("count", 50) + "linkedin", + "get_sent_invitations", + account=input_data.get("account"), + count=input_data.get("count", 50), ) @@ -433,14 +557,24 @@ def get_linkedin_sent_invitations(input_data: dict) -> dict: name="get_linkedin_received_invitations", description="Get received invitations.", action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_received_invitations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_received_invitations", count=input_data.get("count", 50) + "linkedin", + "get_received_invitations", + account=input_data.get("account"), + count=input_data.get("count", 50), ) @@ -459,6 +593,11 @@ def get_linkedin_received_invitations(input_data: dict) -> dict: "description": "accept/ignore.", "example": "accept", }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -468,6 +607,7 @@ def respond_to_linkedin_invitation(input_data: dict) -> dict: return run_client_sync( "linkedin", "respond_to_invitation", + account=input_data.get("account"), invitation_urn=input_data["invitation_urn"], action=input_data["action"], ) @@ -477,14 +617,24 @@ def respond_to_linkedin_invitation(input_data: dict) -> dict: name="get_linkedin_conversations", description="Get conversations.", action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 20}}, + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 20}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_conversations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_conversations", count=input_data.get("count", 20) + "linkedin", + "get_conversations", + account=input_data.get("account"), + count=input_data.get("count", 20), ) @@ -513,6 +663,11 @@ def get_linkedin_conversations(input_data: dict) -> dict: "description": "Number of results.", "example": 25, }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -522,6 +677,7 @@ def search_linkedin_jobs(input_data: dict) -> dict: return run_client_sync( "linkedin", "search_jobs", + account=input_data.get("account"), keywords=input_data["keywords"], location=input_data.get("location"), count=input_data.get("count", 25), @@ -533,14 +689,24 @@ def search_linkedin_jobs(input_data: dict) -> dict: description="Get job details.", action_sets=["linkedin"], input_schema={ - "job_id": {"type": "string", "description": "Job ID.", "example": "123"} + "job_id": {"type": "string", "description": "Job ID.", "example": "123"}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_job_details(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "get_job_details", job_id=input_data["job_id"]) + return run_client_sync( + "linkedin", + "get_job_details", + account=input_data.get("account"), + job_id=input_data["job_id"], + ) @action( @@ -548,7 +714,12 @@ def get_linkedin_job_details(input_data: dict) -> dict: description="Search companies.", action_sets=["linkedin"], input_schema={ - "keywords": {"type": "string", "description": "Keywords.", "example": "tech"} + "keywords": {"type": "string", "description": "Keywords.", "example": "tech"}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -556,7 +727,10 @@ def search_linkedin_companies(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "search_companies", keywords=input_data["keywords"] + "linkedin", + "search_companies", + account=input_data.get("account"), + keywords=input_data["keywords"], ) @@ -569,7 +743,12 @@ def search_linkedin_companies(input_data: dict) -> dict: "type": "string", "description": "Vanity name.", "example": "microsoft", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -577,7 +756,10 @@ def lookup_linkedin_company(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_company_by_vanity_name", vanity_name=input_data["vanity_name"] + "linkedin", + "get_company_by_vanity_name", + account=input_data.get("account"), + vanity_name=input_data["vanity_name"], ) @@ -586,14 +768,24 @@ def lookup_linkedin_company(input_data: dict) -> dict: description="Get person profile by ID.", action_sets=["linkedin"], input_schema={ - "person_id": {"type": "string", "description": "Person ID.", "example": "123"} + "person_id": {"type": "string", "description": "Person ID.", "example": "123"}, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_person(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "get_person", person_id=input_data["person_id"]) + return run_client_sync( + "linkedin", + "get_person", + account=input_data.get("account"), + person_id=input_data["person_id"], + ) # ------------------------------------------------------------------ @@ -605,13 +797,21 @@ def get_linkedin_person(input_data: dict) -> dict: name="get_linkedin_organizations", description="Get user's organizations.", action_sets=["linkedin"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_linkedin_organizations(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("linkedin", "get_my_organizations") + return run_client_sync( + "linkedin", "get_my_organizations", account=input_data.get("account") + ) @action( @@ -623,7 +823,12 @@ def get_linkedin_organizations(input_data: dict) -> dict: "type": "string", "description": "Org ID.", "example": "123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -631,7 +836,10 @@ def get_linkedin_organization_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_organization", organization_id=input_data["organization_id"] + "linkedin", + "get_organization", + account=input_data.get("account"), + organization_id=input_data["organization_id"], ) @@ -644,7 +852,12 @@ def get_linkedin_organization_info(input_data: dict) -> dict: "type": "string", "description": "Org URN.", "example": "urn:li:organization:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -654,6 +867,7 @@ def get_linkedin_organization_analytics(input_data: dict) -> dict: return run_client_sync( "linkedin", "get_organization_analytics", + account=input_data.get("account"), organization_urn=input_data["organization_urn"], ) @@ -667,7 +881,12 @@ def get_linkedin_organization_analytics(input_data: dict) -> dict: "type": "string", "description": "Post URN.", "example": "urn:li:share:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -675,7 +894,10 @@ def get_linkedin_post_analytics(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "linkedin", "get_post_analytics", share_urns=[input_data["post_urn"]] + "linkedin", + "get_post_analytics", + account=input_data.get("account"), + share_urns=[input_data["post_urn"]], ) @@ -688,7 +910,12 @@ def get_linkedin_post_analytics(input_data: dict) -> dict: "type": "string", "description": "Org URN.", "example": "urn:li:organization:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -698,6 +925,7 @@ async def follow_linkedin_organization(input_data: dict) -> dict: return await with_client( "linkedin", lambda c: c.follow_organization(_person_urn(c), input_data["organization_urn"]), + account=input_data.get("account"), ) @@ -710,7 +938,12 @@ async def follow_linkedin_organization(input_data: dict) -> dict: "type": "string", "description": "Org URN.", "example": "urn:li:organization:123", - } + }, + "account": { + "type": "string", + "description": "Optional LinkedIn account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -722,4 +955,5 @@ async def unfollow_linkedin_organization(input_data: dict) -> dict: lambda c: c.unfollow_organization( _person_urn(c), input_data["organization_urn"] ), + account=input_data.get("account"), ) diff --git a/app/data/action/integrations/notion/notion_actions.py b/app/data/action/integrations/notion/notion_actions.py index 0a0115cb..dc283b5b 100644 --- a/app/data/action/integrations/notion/notion_actions.py +++ b/app/data/action/integrations/notion/notion_actions.py @@ -21,6 +21,11 @@ "description": "Optional: 'page' or 'database'.", "example": "page", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -30,6 +35,7 @@ def search_notion(input_data: dict) -> dict: return run_client_sync( "notion", "search", + account=input_data.get("account"), query=input_data["query"], filter_type=input_data.get("filter_type"), ) @@ -50,13 +56,23 @@ def search_notion(input_data: dict) -> dict: "description": "Notion page ID.", "example": "abc123", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_page", page_id=input_data["page_id"]) + return run_client_sync( + "notion", + "get_page", + account=input_data.get("account"), + page_id=input_data["page_id"], + ) @action( @@ -84,6 +100,11 @@ def get_notion_page(input_data: dict) -> dict: "description": "Optional content blocks.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -94,6 +115,7 @@ def create_notion_page(input_data: dict) -> dict: return run_client_sync( "notion", "create_page", + account=input_data.get("account"), parent_id=input_data["parent_id"], parent_type=input_data["parent_type"], properties=input_data["properties"], @@ -116,6 +138,11 @@ def create_notion_page(input_data: dict) -> dict: "description": "Properties to update.", "example": {}, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -126,6 +153,7 @@ def update_notion_page(input_data: dict) -> dict: return run_client_sync( "notion", "update_page", + account=input_data.get("account"), page_id=input_data["page_id"], properties=input_data["properties"], ) @@ -137,6 +165,11 @@ def update_notion_page(input_data: dict) -> dict: action_sets=["notion_pages", "notion"], input_schema={ "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -144,7 +177,12 @@ def update_notion_page(input_data: dict) -> dict: def archive_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "archive_page", page_id=input_data["page_id"]) + return run_client_sync( + "notion", + "archive_page", + account=input_data.get("account"), + page_id=input_data["page_id"], + ) @action( @@ -153,6 +191,11 @@ def archive_notion_page(input_data: dict) -> dict: action_sets=["notion_pages"], input_schema={ "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -160,7 +203,12 @@ def archive_notion_page(input_data: dict) -> dict: def restore_notion_page(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "restore_page", page_id=input_data["page_id"]) + return run_client_sync( + "notion", + "restore_page", + account=input_data.get("account"), + page_id=input_data["page_id"], + ) @action( @@ -179,6 +227,11 @@ def restore_notion_page(input_data: dict) -> dict: "description": "Pagination size.", "example": 100, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -188,6 +241,7 @@ def get_notion_page_property(input_data: dict) -> dict: return run_client_sync( "notion", "get_page_property", + account=input_data.get("account"), page_id=input_data["page_id"], property_id=input_data["property_id"], page_size=input_data.get("page_size", 100), @@ -209,6 +263,11 @@ def get_notion_page_property(input_data: dict) -> dict: "description": "Database ID.", "example": "abc123", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -219,7 +278,10 @@ def get_notion_database_schema(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", "get_database", database_id=input_data["database_id"] + "notion", + "get_database", + account=input_data.get("account"), + database_id=input_data["database_id"], ) @@ -243,6 +305,11 @@ def get_notion_database_schema(input_data: dict) -> dict: "description": "Optional sort array.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -252,6 +319,7 @@ def query_notion_database(input_data: dict) -> dict: return run_client_sync( "notion", "query_database", + account=input_data.get("account"), database_id=input_data["database_id"], filter_obj=input_data.get("filter"), sorts=input_data.get("sorts"), @@ -294,6 +362,11 @@ def query_notion_database(input_data: dict) -> dict: "example": {}, }, "cover": {"type": "object", "description": "Cover (optional).", "example": {}}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -304,6 +377,7 @@ def create_notion_database(input_data: dict) -> dict: return run_client_sync( "notion", "create_database", + account=input_data.get("account"), parent_page_id=input_data["parent_page_id"], title=input_data.get("title"), description=input_data.get("description"), @@ -340,6 +414,11 @@ def create_notion_database(input_data: dict) -> dict: "description": "Set inline (optional).", "example": False, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -350,6 +429,7 @@ def update_notion_database(input_data: dict) -> dict: return run_client_sync( "notion", "update_database", + account=input_data.get("account"), database_id=input_data["database_id"], title=input_data.get("title"), description=input_data.get("description"), @@ -364,6 +444,11 @@ def update_notion_database(input_data: dict) -> dict: action_sets=["notion_databases"], input_schema={ "database_id": {"type": "string", "description": "Database ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -372,7 +457,10 @@ def archive_notion_database(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", "archive_database", database_id=input_data["database_id"] + "notion", + "archive_database", + account=input_data.get("account"), + database_id=input_data["database_id"], ) @@ -382,6 +470,11 @@ def archive_notion_database(input_data: dict) -> dict: action_sets=["notion_databases"], input_schema={ "database_id": {"type": "string", "description": "Database ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -390,7 +483,10 @@ def restore_notion_database(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "notion", "restore_database", database_id=input_data["database_id"] + "notion", + "restore_database", + account=input_data.get("account"), + database_id=input_data["database_id"], ) @@ -424,6 +520,11 @@ def restore_notion_database(input_data: dict) -> dict: ), "example": False, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -438,7 +539,10 @@ def get_notion_page_content(input_data: dict) -> dict: include_metadata = bool(input_data.get("include_metadata", False)) result = run_client_sync( - "notion", "get_block_children", block_id=input_data["page_id"] + "notion", + "get_block_children", + account=input_data.get("account"), + block_id=input_data["page_id"], ) if include_metadata or result.get("status") == "error": return result @@ -484,6 +588,11 @@ def _simplify(b: dict) -> dict: "description": "List of block objects.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -494,6 +603,7 @@ def append_notion_page_content(input_data: dict) -> dict: return run_client_sync( "notion", "append_block_children", + account=input_data.get("account"), block_id=input_data["page_id"], children=input_data["children"], ) @@ -505,13 +615,23 @@ def append_notion_page_content(input_data: dict) -> dict: action_sets=["notion_blocks", "notion"], input_schema={ "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_block(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_block", block_id=input_data["block_id"]) + return run_client_sync( + "notion", + "get_block", + account=input_data.get("account"), + block_id=input_data["block_id"], + ) @action( @@ -525,6 +645,11 @@ def get_notion_block(input_data: dict) -> dict: "description": "Per-block-type update object.", "example": {"paragraph": {"rich_text": [{"text": {"content": "Updated"}}]}}, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -535,6 +660,7 @@ def update_notion_block(input_data: dict) -> dict: return run_client_sync( "notion", "update_block", + account=input_data.get("account"), block_id=input_data["block_id"], block_update=input_data["block_update"], ) @@ -546,6 +672,11 @@ def update_notion_block(input_data: dict) -> dict: action_sets=["notion_blocks", "notion"], input_schema={ "block_id": {"type": "string", "description": "Block ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -553,7 +684,12 @@ def update_notion_block(input_data: dict) -> dict: def delete_notion_block(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "delete_block", block_id=input_data["block_id"]) + return run_client_sync( + "notion", + "delete_block", + account=input_data.get("account"), + block_id=input_data["block_id"], + ) # ------------------------------------------------------------------ @@ -577,6 +713,11 @@ def delete_notion_block(input_data: dict) -> dict: "description": "Pagination cursor (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -586,6 +727,7 @@ def list_notion_comments(input_data: dict) -> dict: return run_client_sync( "notion", "list_comments", + account=input_data.get("account"), block_id=input_data["block_id"], page_size=input_data.get("page_size", 100), start_cursor=input_data.get("start_cursor") or None, @@ -617,6 +759,11 @@ def list_notion_comments(input_data: dict) -> dict: "description": "Discussion ID to reply to (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -627,6 +774,7 @@ def create_notion_comment(input_data: dict) -> dict: return run_client_sync( "notion", "create_comment", + account=input_data.get("account"), rich_text=input_data["rich_text"], parent_page_id=input_data.get("parent_page_id") or None, parent_block_id=input_data.get("parent_block_id") or None, @@ -650,6 +798,11 @@ def create_notion_comment(input_data: dict) -> dict: "description": "Pagination cursor (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -659,6 +812,7 @@ def list_notion_users(input_data: dict) -> dict: return run_client_sync( "notion", "list_users", + account=input_data.get("account"), page_size=input_data.get("page_size", 100), start_cursor=input_data.get("start_cursor") or None, ) @@ -670,26 +824,46 @@ def list_notion_users(input_data: dict) -> dict: action_sets=["notion_users", "notion"], input_schema={ "user_id": {"type": "string", "description": "User ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_user(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_user", user_id=input_data["user_id"]) + return run_client_sync( + "notion", + "get_user", + account=input_data.get("account"), + user_id=input_data["user_id"], + ) @action( name="get_notion_bot_info", description="Get info about the authenticated Notion bot (workspace_name, owner, capabilities).", action_sets=["notion_users", "notion"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_notion_bot_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("notion", "get_bot_info") + return run_client_sync( + "notion", + "get_bot_info", + account=input_data.get("account"), + ) # ------------------------------------------------------------------ @@ -712,6 +886,11 @@ def get_notion_bot_info(input_data: dict) -> dict: "description": "MIME type (autodetect if omitted).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -722,6 +901,7 @@ def upload_notion_file(input_data: dict) -> dict: return run_client_sync( "notion", "upload_local_file", + account=input_data.get("account"), file_path=input_data["file_path"], content_type=input_data.get("content_type") or None, ) @@ -757,6 +937,11 @@ def upload_notion_file(input_data: dict) -> dict: "description": "Required for external_url mode.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -768,6 +953,7 @@ def create_notion_file_upload(input_data: dict) -> dict: return run_client_sync( "notion", "create_file_upload", + account=input_data.get("account"), mode=input_data.get("mode", "single_part"), filename=input_data.get("filename") or None, content_type=input_data.get("content_type") or None, @@ -796,6 +982,11 @@ def create_notion_file_upload(input_data: dict) -> dict: "description": "1..1000, only for multi_part.", "example": 0, }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -807,6 +998,7 @@ def send_notion_file_upload(input_data: dict) -> dict: return run_client_sync( "notion", "send_file_upload", + account=input_data.get("account"), file_upload_id=input_data["file_upload_id"], file_path=input_data["file_path"], part_number=pn if pn else None, @@ -823,6 +1015,11 @@ def send_notion_file_upload(input_data: dict) -> dict: "description": "File upload ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -833,6 +1030,7 @@ def complete_notion_file_upload(input_data: dict) -> dict: return run_client_sync( "notion", "complete_file_upload", + account=input_data.get("account"), file_upload_id=input_data["file_upload_id"], ) @@ -847,6 +1045,11 @@ def complete_notion_file_upload(input_data: dict) -> dict: "description": "File upload ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -856,6 +1059,7 @@ def get_notion_file_upload(input_data: dict) -> dict: return run_client_sync( "notion", "get_file_upload", + account=input_data.get("account"), file_upload_id=input_data["file_upload_id"], ) @@ -876,6 +1080,11 @@ def get_notion_file_upload(input_data: dict) -> dict: "description": "Pagination cursor (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Notion workspace name (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -885,6 +1094,7 @@ def list_notion_file_uploads(input_data: dict) -> dict: return run_client_sync( "notion", "list_file_uploads", + account=input_data.get("account"), status=input_data.get("status") or None, page_size=input_data.get("page_size", 100), start_cursor=input_data.get("start_cursor") or None, diff --git a/app/data/action/integrations/outlook/outlook_actions.py b/app/data/action/integrations/outlook/outlook_actions.py index 61da8556..06b40575 100644 --- a/app/data/action/integrations/outlook/outlook_actions.py +++ b/app/data/action/integrations/outlook/outlook_actions.py @@ -32,6 +32,11 @@ "description": "Optional CC recipients (comma-separated).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -42,6 +47,7 @@ def send_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "send_email", + account=input_data.get("account"), unwrap_envelope=True, success_message="Email sent.", fail_message="Failed to send email.", @@ -67,6 +73,11 @@ def send_outlook_email(input_data: dict) -> dict: "description": "Only show unread emails.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -76,6 +87,7 @@ def list_outlook_emails(input_data: dict) -> dict: return run_client_sync( "outlook", "list_emails", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list emails.", n=input_data.get("count", 10), @@ -93,6 +105,11 @@ def list_outlook_emails(input_data: dict) -> dict: "description": "Outlook message ID.", "example": "AAMk...", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -102,6 +119,7 @@ def get_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "get_email", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get email.", message_id=input_data["message_id"], @@ -123,6 +141,11 @@ def get_outlook_email(input_data: dict) -> dict: "description": "Include full body text.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -132,6 +155,7 @@ def read_top_outlook_emails(input_data: dict) -> dict: return run_client_sync( "outlook", "read_top_emails", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to read emails.", n=input_data.get("count", 5), @@ -155,6 +179,11 @@ def read_top_outlook_emails(input_data: dict) -> dict: "description": "Optional folder name (inbox/sentitems/etc.) or ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -164,6 +193,7 @@ def search_outlook_emails(input_data: dict) -> dict: return run_client_sync( "outlook", "search_messages", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to search.", query=input_data["query"], @@ -193,6 +223,11 @@ def search_outlook_emails(input_data: dict) -> dict: "description": "Optional comma-separated extra recipients.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -209,6 +244,7 @@ def reply_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "reply_to_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to reply.", message_id=input_data["message_id"], @@ -229,6 +265,11 @@ def reply_outlook_email(input_data: dict) -> dict: "example": "AAMk...", }, "comment": {"type": "string", "description": "Reply body.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -239,6 +280,7 @@ def reply_all_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "reply_all_to_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to reply-all.", message_id=input_data["message_id"], @@ -267,6 +309,11 @@ def reply_all_outlook_email(input_data: dict) -> dict: "description": "Optional intro comment.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -281,6 +328,7 @@ def forward_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "forward_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to forward.", message_id=input_data["message_id"], @@ -304,6 +352,11 @@ def forward_outlook_email(input_data: dict) -> dict: "description": "Optional initial reply text.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -314,6 +367,7 @@ def create_outlook_reply_draft(input_data: dict) -> dict: return run_client_sync( "outlook", "create_reply_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create reply draft.", message_id=input_data["message_id"], @@ -337,6 +391,11 @@ def create_outlook_reply_draft(input_data: dict) -> dict: "example": "", }, "comment": {"type": "string", "description": "Optional intro.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -349,6 +408,7 @@ def create_outlook_forward_draft(input_data: dict) -> dict: return run_client_sync( "outlook", "create_forward_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create forward draft.", message_id=input_data["message_id"], @@ -384,6 +444,11 @@ def create_outlook_forward_draft(input_data: dict) -> dict: "example": "", }, "html": {"type": "boolean", "description": "Body is HTML.", "example": False}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -395,6 +460,7 @@ def create_outlook_draft(input_data: dict) -> dict: return run_client_sync( "outlook", "create_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create draft.", subject=input_data["subject"], @@ -430,6 +496,11 @@ def create_outlook_draft(input_data: dict) -> dict: }, "cc": {"type": "string", "description": "New CC (optional).", "example": ""}, "bcc": {"type": "string", "description": "New BCC (optional).", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -441,6 +512,7 @@ def update_outlook_draft(input_data: dict) -> dict: return run_client_sync( "outlook", "update_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to update draft.", message_id=input_data["message_id"], @@ -460,6 +532,11 @@ def update_outlook_draft(input_data: dict) -> dict: action_sets=["outlook_mail", "outlook"], input_schema={ "message_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -470,6 +547,7 @@ def send_outlook_draft(input_data: dict) -> dict: return run_client_sync( "outlook", "send_draft", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to send draft.", message_id=input_data["message_id"], @@ -482,6 +560,11 @@ def send_outlook_draft(input_data: dict) -> dict: action_sets=["outlook_mail", "outlook"], input_schema={ "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -492,6 +575,7 @@ def delete_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "delete_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete.", message_id=input_data["message_id"], @@ -509,6 +593,11 @@ def delete_outlook_email(input_data: dict) -> dict: "description": "Folder ID or well-known name.", "example": "archive", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -519,6 +608,7 @@ def move_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "move_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to move.", message_id=input_data["message_id"], @@ -537,6 +627,11 @@ def move_outlook_email(input_data: dict) -> dict: "description": "Folder ID or well-known name.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -547,6 +642,7 @@ def copy_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "copy_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to copy.", message_id=input_data["message_id"], @@ -564,6 +660,11 @@ def copy_outlook_email(input_data: dict) -> dict: "description": "Outlook message ID.", "example": "AAMk...", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -574,6 +675,7 @@ def mark_outlook_email_read(input_data: dict) -> dict: return run_client_sync( "outlook", "mark_as_read", + account=input_data.get("account"), unwrap_envelope=True, success_message="Email marked as read.", fail_message="Failed to mark email.", @@ -587,6 +689,11 @@ def mark_outlook_email_read(input_data: dict) -> dict: action_sets=["outlook_mail"], input_schema={ "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -597,6 +704,7 @@ def mark_outlook_email_unread(input_data: dict) -> dict: return run_client_sync( "outlook", "mark_as_unread", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to mark unread.", message_id=input_data["message_id"], @@ -614,6 +722,11 @@ def mark_outlook_email_unread(input_data: dict) -> dict: "description": "notFlagged, flagged, or complete.", "example": "flagged", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -624,6 +737,7 @@ def flag_outlook_email(input_data: dict) -> dict: return run_client_sync( "outlook", "flag_message", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to flag.", message_id=input_data["message_id"], @@ -642,6 +756,11 @@ def flag_outlook_email(input_data: dict) -> dict: "description": "Comma-separated category display names.", "example": "Personal,Important", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -654,6 +773,7 @@ def set_outlook_email_categories(input_data: dict) -> dict: return run_client_sync( "outlook", "set_message_categories", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to set categories.", message_id=input_data["message_id"], @@ -672,6 +792,11 @@ def set_outlook_email_categories(input_data: dict) -> dict: action_sets=["outlook_attachments", "outlook"], input_schema={ "message_id": {"type": "string", "description": "Message ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -681,6 +806,7 @@ def list_outlook_attachments(input_data: dict) -> dict: return run_client_sync( "outlook", "list_attachments", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list attachments.", message_id=input_data["message_id"], @@ -703,6 +829,11 @@ def list_outlook_attachments(input_data: dict) -> dict: "description": "Local path to save to.", "example": "C:/Users/me/downloads/file.pdf", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -713,6 +844,7 @@ def download_outlook_attachment(input_data: dict) -> dict: return run_client_sync( "outlook", "download_attachment", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to download.", message_id=input_data["message_id"], @@ -741,6 +873,11 @@ def download_outlook_attachment(input_data: dict) -> dict: "description": "MIME type (autodetect if omitted).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -751,6 +888,7 @@ def add_outlook_attachment(input_data: dict) -> dict: return run_client_sync( "outlook", "add_attachment", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to add attachment.", message_id=input_data["message_id"], @@ -770,6 +908,11 @@ def add_outlook_attachment(input_data: dict) -> dict: "description": "Attachment ID.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -780,6 +923,7 @@ def delete_outlook_attachment(input_data: dict) -> dict: return run_client_sync( "outlook", "delete_attachment", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete attachment.", message_id=input_data["message_id"], @@ -796,7 +940,13 @@ def delete_outlook_attachment(input_data: dict) -> dict: name="list_outlook_folders", description="List mail folders in Outlook.", action_sets=["outlook_folders", "outlook"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_outlook_folders(input_data: dict) -> dict: @@ -805,6 +955,7 @@ def list_outlook_folders(input_data: dict) -> dict: return run_client_sync( "outlook", "list_folders", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list folders.", ) @@ -820,6 +971,11 @@ def list_outlook_folders(input_data: dict) -> dict: "description": "Folder ID or well-known name (inbox, drafts, sentitems, etc.).", "example": "inbox", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -829,6 +985,7 @@ def get_outlook_folder(input_data: dict) -> dict: return run_client_sync( "outlook", "get_folder", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get folder.", folder_id=input_data["folder_id"], @@ -850,6 +1007,11 @@ def get_outlook_folder(input_data: dict) -> dict: "description": "Parent folder ID or well-known name. Default msgfolderroot.", "example": "msgfolderroot", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -860,6 +1022,7 @@ def create_outlook_folder(input_data: dict) -> dict: return run_client_sync( "outlook", "create_folder", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create folder.", display_name=input_data["display_name"], @@ -874,6 +1037,11 @@ def create_outlook_folder(input_data: dict) -> dict: input_schema={ "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, "display_name": {"type": "string", "description": "New name.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -884,6 +1052,7 @@ def update_outlook_folder(input_data: dict) -> dict: return run_client_sync( "outlook", "update_folder", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to rename folder.", folder_id=input_data["folder_id"], @@ -897,6 +1066,11 @@ def update_outlook_folder(input_data: dict) -> dict: action_sets=["outlook_folders"], input_schema={ "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -907,6 +1081,7 @@ def delete_outlook_folder(input_data: dict) -> dict: return run_client_sync( "outlook", "delete_folder", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete folder.", folder_id=input_data["folder_id"], @@ -923,6 +1098,11 @@ def delete_outlook_folder(input_data: dict) -> dict: "description": "Parent folder ID or well-known name. Default msgfolderroot.", "example": "msgfolderroot", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -932,6 +1112,7 @@ def list_outlook_child_folders(input_data: dict) -> dict: return run_client_sync( "outlook", "list_child_folders", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list child folders.", folder_id=input_data.get("folder_id", "msgfolderroot"), @@ -954,6 +1135,11 @@ def list_outlook_child_folders(input_data: dict) -> dict: "description": "Filter to unread.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -963,6 +1149,7 @@ def list_outlook_folder_messages(input_data: dict) -> dict: return run_client_sync( "outlook", "list_folder_messages", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list messages.", folder_id=input_data["folder_id"], @@ -980,7 +1167,13 @@ def list_outlook_folder_messages(input_data: dict) -> dict: name="get_outlook_mailbox_settings", description="Get the user's mailbox settings (timezone, locale, working hours, etc.).", action_sets=["outlook_settings"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_outlook_mailbox_settings(input_data: dict) -> dict: @@ -989,6 +1182,7 @@ def get_outlook_mailbox_settings(input_data: dict) -> dict: return run_client_sync( "outlook", "get_mailbox_settings", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get settings.", ) @@ -998,7 +1192,13 @@ def get_outlook_mailbox_settings(input_data: dict) -> dict: name="get_outlook_automatic_replies", description="Get the current out-of-office / automatic reply settings.", action_sets=["outlook_settings", "outlook"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_outlook_automatic_replies(input_data: dict) -> dict: @@ -1007,6 +1207,7 @@ def get_outlook_automatic_replies(input_data: dict) -> dict: return run_client_sync( "outlook", "get_automatic_replies", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to get auto-replies.", ) @@ -1047,6 +1248,11 @@ def get_outlook_automatic_replies(input_data: dict) -> dict: "description": "ISO 8601 end (only for status=scheduled).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1057,6 +1263,7 @@ def update_outlook_automatic_replies(input_data: dict) -> dict: return run_client_sync( "outlook", "update_automatic_replies", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to set auto-replies.", status=input_data["status"], @@ -1076,7 +1283,13 @@ def update_outlook_automatic_replies(input_data: dict) -> dict: name="list_outlook_inbox_rules", description="List inbox rules (server-side mail rules).", action_sets=["outlook_settings"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_outlook_inbox_rules(input_data: dict) -> dict: @@ -1085,6 +1298,7 @@ def list_outlook_inbox_rules(input_data: dict) -> dict: return run_client_sync( "outlook", "list_inbox_rules", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list rules.", ) @@ -1120,6 +1334,11 @@ def list_outlook_inbox_rules(input_data: dict) -> dict: "description": "Enable on create.", "example": True, }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1130,6 +1349,7 @@ def create_outlook_inbox_rule(input_data: dict) -> dict: return run_client_sync( "outlook", "create_inbox_rule", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create rule.", display_name=input_data["display_name"], @@ -1146,6 +1366,11 @@ def create_outlook_inbox_rule(input_data: dict) -> dict: action_sets=["outlook_settings"], input_schema={ "rule_id": {"type": "string", "description": "Rule ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1156,6 +1381,7 @@ def delete_outlook_inbox_rule(input_data: dict) -> dict: return run_client_sync( "outlook", "delete_inbox_rule", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete rule.", rule_id=input_data["rule_id"], @@ -1166,7 +1392,13 @@ def delete_outlook_inbox_rule(input_data: dict) -> dict: name="list_outlook_categories", description="List the user's master categories (color-coded tags for messages, calendar items, etc.).", action_sets=["outlook_settings"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_outlook_categories(input_data: dict) -> dict: @@ -1175,6 +1407,7 @@ def list_outlook_categories(input_data: dict) -> dict: return run_client_sync( "outlook", "list_categories", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to list categories.", ) @@ -1195,6 +1428,11 @@ def list_outlook_categories(input_data: dict) -> dict: "description": "preset0..preset24.", "example": "preset0", }, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1205,6 +1443,7 @@ def create_outlook_category(input_data: dict) -> dict: return run_client_sync( "outlook", "create_category", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to create category.", display_name=input_data["display_name"], @@ -1218,6 +1457,11 @@ def create_outlook_category(input_data: dict) -> dict: action_sets=["outlook_settings"], input_schema={ "category_id": {"type": "string", "description": "Category ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Outlook account email (or unique fragment, e.g. 'work'). Omit to use the primary account.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1228,6 +1472,7 @@ def delete_outlook_category(input_data: dict) -> dict: return run_client_sync( "outlook", "delete_category", + account=input_data.get("account"), unwrap_envelope=True, fail_message="Failed to delete category.", category_id=input_data["category_id"], diff --git a/app/data/action/integrations/slack/slack_actions.py b/app/data/action/integrations/slack/slack_actions.py index a7cd8f15..a00dd2ea 100644 --- a/app/data/action/integrations/slack/slack_actions.py +++ b/app/data/action/integrations/slack/slack_actions.py @@ -27,6 +27,11 @@ "description": "Optional thread timestamp for replies.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -37,6 +42,7 @@ async def send_slack_message(input_data: dict) -> dict: return await run_client( "slack", "send_message", + account=input_data.get("account"), recipient=input_data["channel"], text=input_data["text"], thread_ts=input_data.get("thread_ts"), @@ -68,6 +74,11 @@ async def send_slack_message(input_data: dict) -> dict: "description": "New Block Kit blocks (optional).", "example": [], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -78,6 +89,7 @@ def update_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "update_message", + account=input_data.get("account"), channel=input_data["channel"], ts=input_data["ts"], text=input_data["text"] if "text" in input_data else None, @@ -96,6 +108,11 @@ def update_slack_message(input_data: dict) -> dict: "example": "C01234567", }, "ts": {"type": "string", "description": "Message timestamp.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -106,6 +123,7 @@ def delete_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "delete_message", + account=input_data.get("account"), channel=input_data["channel"], ts=input_data["ts"], ) @@ -138,6 +156,11 @@ def delete_slack_message(input_data: dict) -> dict: "description": "Reply in a thread (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -148,6 +171,7 @@ def send_slack_ephemeral(input_data: dict) -> dict: return run_client_sync( "slack", "post_ephemeral", + account=input_data.get("account"), channel=input_data["channel"], user=input_data["user"], text=input_data["text"], @@ -182,6 +206,11 @@ def send_slack_ephemeral(input_data: dict) -> dict: "description": "Optional thread reply.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -192,6 +221,7 @@ def schedule_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "schedule_message", + account=input_data.get("account"), channel=input_data["channel"], post_at=input_data["post_at"], text=input_data["text"], @@ -211,6 +241,11 @@ def schedule_slack_message(input_data: dict) -> dict: "description": "Scheduled message ID (from schedule_slack_message response).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -221,6 +256,7 @@ def delete_scheduled_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "delete_scheduled_message", + account=input_data.get("account"), channel=input_data["channel"], scheduled_message_id=input_data["scheduled_message_id"], ) @@ -237,6 +273,11 @@ def delete_scheduled_slack_message(input_data: dict) -> dict: "example": "", }, "limit": {"type": "integer", "description": "Max results.", "example": 100}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -246,6 +287,7 @@ def list_scheduled_slack_messages(input_data: dict) -> dict: return run_client_sync( "slack", "list_scheduled_messages", + account=input_data.get("account"), channel=input_data.get("channel") or None, limit=input_data.get("limit", 100), ) @@ -266,6 +308,11 @@ def list_scheduled_slack_messages(input_data: dict) -> dict: "description": "Message timestamp.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -275,6 +322,7 @@ def get_slack_message_permalink(input_data: dict) -> dict: return run_client_sync( "slack", "get_permalink", + account=input_data.get("account"), channel=input_data["channel"], message_ts=input_data["message_ts"], ) @@ -296,6 +344,11 @@ def get_slack_message_permalink(input_data: dict) -> dict: "example": "", }, "limit": {"type": "integer", "description": "Max messages.", "example": 100}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -305,6 +358,7 @@ def get_slack_thread_replies(input_data: dict) -> dict: return run_client_sync( "slack", "get_thread_replies", + account=input_data.get("account"), channel=input_data["channel"], ts=input_data["ts"], limit=input_data.get("limit", 100), @@ -334,6 +388,11 @@ def get_slack_thread_replies(input_data: dict) -> dict: "description": "Emoji name without colons.", "example": "thumbsup", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -344,6 +403,7 @@ def add_slack_reaction(input_data: dict) -> dict: return run_client_sync( "slack", "add_reaction", + account=input_data.get("account"), channel=input_data["channel"], timestamp=input_data["timestamp"], name=input_data["name"], @@ -366,6 +426,11 @@ def add_slack_reaction(input_data: dict) -> dict: "description": "Emoji name without colons.", "example": "thumbsup", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -376,6 +441,7 @@ def remove_slack_reaction(input_data: dict) -> dict: return run_client_sync( "slack", "remove_reaction", + account=input_data.get("account"), channel=input_data["channel"], timestamp=input_data["timestamp"], name=input_data["name"], @@ -393,6 +459,11 @@ def remove_slack_reaction(input_data: dict) -> dict: "description": "Message timestamp.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -402,6 +473,7 @@ def get_slack_reactions(input_data: dict) -> dict: return run_client_sync( "slack", "get_reactions", + account=input_data.get("account"), channel=input_data["channel"], timestamp=input_data["timestamp"], ) @@ -418,6 +490,11 @@ def get_slack_reactions(input_data: dict) -> dict: "example": "", }, "count": {"type": "integer", "description": "Max results.", "example": 100}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -427,6 +504,7 @@ def list_slack_user_reactions(input_data: dict) -> dict: return run_client_sync( "slack", "list_user_reactions", + account=input_data.get("account"), user=input_data.get("user") or None, count=input_data.get("count", 100), ) @@ -446,6 +524,11 @@ def list_slack_user_reactions(input_data: dict) -> dict: "description": "Message timestamp.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -456,6 +539,7 @@ def pin_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "pin_message", + account=input_data.get("account"), channel=input_data["channel"], timestamp=input_data["timestamp"], ) @@ -472,6 +556,11 @@ def pin_slack_message(input_data: dict) -> dict: "description": "Message timestamp.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -482,6 +571,7 @@ def unpin_slack_message(input_data: dict) -> dict: return run_client_sync( "slack", "unpin_message", + account=input_data.get("account"), channel=input_data["channel"], timestamp=input_data["timestamp"], ) @@ -493,13 +583,21 @@ def unpin_slack_message(input_data: dict) -> dict: action_sets=["slack_messages"], input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_slack_pins(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_pins", channel=input_data["channel"]) + return run_client_sync("slack", "list_pins", + account=input_data.get("account"), + channel=input_data["channel"], + ) # ------------------------------------------------------------------ @@ -517,6 +615,11 @@ def list_slack_pins(input_data: dict) -> dict: "description": "Max channels to return.", "example": 100, }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -526,7 +629,10 @@ def list_slack_pins(input_data: dict) -> dict: def list_slack_channels(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_channels", limit=input_data.get("limit", 100)) + return run_client_sync("slack", "list_channels", + account=input_data.get("account"), + limit=input_data.get("limit", 100), + ) @action( @@ -539,13 +645,21 @@ def list_slack_channels(input_data: dict) -> dict: "description": "Channel ID.", "example": "C1234567", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_channel_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "get_channel_info", channel=input_data["channel"]) + return run_client_sync("slack", "get_channel_info", + account=input_data.get("account"), + channel=input_data["channel"], + ) @action( @@ -559,6 +673,11 @@ def get_slack_channel_info(input_data: dict) -> dict: "example": "C01234567", }, "limit": {"type": "integer", "description": "Max messages.", "example": 50}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -571,6 +690,7 @@ def get_slack_channel_history(input_data: dict) -> dict: return run_client_sync( "slack", "get_channel_history", + account=input_data.get("account"), channel=input_data["channel"], limit=input_data.get("limit", 50), ) @@ -588,6 +708,11 @@ def get_slack_channel_history(input_data: dict) -> dict: "description": "Pagination cursor.", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -597,6 +722,7 @@ def list_slack_channel_members(input_data: dict) -> dict: return run_client_sync( "slack", "list_channel_members", + account=input_data.get("account"), channel=input_data["channel"], limit=input_data.get("limit", 100), cursor=input_data.get("cursor") or None, @@ -618,6 +744,11 @@ def list_slack_channel_members(input_data: dict) -> dict: "description": "Is private?", "example": False, }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -628,6 +759,7 @@ def create_slack_channel(input_data: dict) -> dict: return run_client_sync( "slack", "create_channel", + account=input_data.get("account"), name=input_data["name"], is_private=input_data.get("is_private", False), ) @@ -648,6 +780,11 @@ def create_slack_channel(input_data: dict) -> dict: "description": "List of user IDs.", "example": ["U123"], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -658,6 +795,7 @@ def invite_to_slack_channel(input_data: dict) -> dict: return run_client_sync( "slack", "invite_to_channel", + account=input_data.get("account"), channel=input_data["channel"], users=input_data["users"], ) @@ -673,6 +811,11 @@ def invite_to_slack_channel(input_data: dict) -> dict: "description": "List of user IDs.", "example": ["U123"], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -680,7 +823,10 @@ def invite_to_slack_channel(input_data: dict) -> dict: def open_slack_dm(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "open_dm", users=input_data["users"]) + return run_client_sync("slack", "open_dm", + account=input_data.get("account"), + users=input_data["users"], + ) @action( @@ -689,6 +835,11 @@ def open_slack_dm(input_data: dict) -> dict: action_sets=["slack_conversations", "slack"], input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -696,7 +847,10 @@ def open_slack_dm(input_data: dict) -> dict: def archive_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "archive_channel", channel=input_data["channel"]) + return run_client_sync("slack", "archive_channel", + account=input_data.get("account"), + channel=input_data["channel"], + ) @action( @@ -705,6 +859,11 @@ def archive_slack_channel(input_data: dict) -> dict: action_sets=["slack_conversations"], input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -712,7 +871,10 @@ def archive_slack_channel(input_data: dict) -> dict: def unarchive_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "unarchive_channel", channel=input_data["channel"]) + return run_client_sync("slack", "unarchive_channel", + account=input_data.get("account"), + channel=input_data["channel"], + ) @action( @@ -722,6 +884,11 @@ def unarchive_slack_channel(input_data: dict) -> dict: input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, "name": {"type": "string", "description": "New channel name.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -732,6 +899,7 @@ def rename_slack_channel(input_data: dict) -> dict: return run_client_sync( "slack", "rename_channel", + account=input_data.get("account"), channel=input_data["channel"], name=input_data["name"], ) @@ -744,6 +912,11 @@ def rename_slack_channel(input_data: dict) -> dict: input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, "topic": {"type": "string", "description": "New topic.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -754,6 +927,7 @@ def set_slack_channel_topic(input_data: dict) -> dict: return run_client_sync( "slack", "set_channel_topic", + account=input_data.get("account"), channel=input_data["channel"], topic=input_data["topic"], ) @@ -766,6 +940,11 @@ def set_slack_channel_topic(input_data: dict) -> dict: input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, "purpose": {"type": "string", "description": "New purpose.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -776,6 +955,7 @@ def set_slack_channel_purpose(input_data: dict) -> dict: return run_client_sync( "slack", "set_channel_purpose", + account=input_data.get("account"), channel=input_data["channel"], purpose=input_data["purpose"], ) @@ -787,6 +967,11 @@ def set_slack_channel_purpose(input_data: dict) -> dict: action_sets=["slack_conversations", "slack"], input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -794,7 +979,10 @@ def set_slack_channel_purpose(input_data: dict) -> dict: def join_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "join_channel", channel=input_data["channel"]) + return run_client_sync("slack", "join_channel", + account=input_data.get("account"), + channel=input_data["channel"], + ) @action( @@ -803,6 +991,11 @@ def join_slack_channel(input_data: dict) -> dict: action_sets=["slack_conversations"], input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -810,7 +1003,10 @@ def join_slack_channel(input_data: dict) -> dict: def leave_slack_channel(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "leave_channel", channel=input_data["channel"]) + return run_client_sync("slack", "leave_channel", + account=input_data.get("account"), + channel=input_data["channel"], + ) @action( @@ -820,6 +1016,11 @@ def leave_slack_channel(input_data: dict) -> dict: input_schema={ "channel": {"type": "string", "description": "Channel ID.", "example": ""}, "user": {"type": "string", "description": "User ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -830,6 +1031,7 @@ def kick_user_from_slack_channel(input_data: dict) -> dict: return run_client_sync( "slack", "kick_user", + account=input_data.get("account"), channel=input_data["channel"], user=input_data["user"], ) @@ -841,6 +1043,11 @@ def kick_user_from_slack_channel(input_data: dict) -> dict: action_sets=["slack_conversations"], input_schema={ "channel": {"type": "string", "description": "Conversation ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -848,7 +1055,10 @@ def kick_user_from_slack_channel(input_data: dict) -> dict: def close_slack_conversation(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "close_conversation", channel=input_data["channel"]) + return run_client_sync("slack", "close_conversation", + account=input_data.get("account"), + channel=input_data["channel"], + ) # ------------------------------------------------------------------ @@ -891,6 +1101,11 @@ def close_slack_conversation(input_data: dict) -> dict: "description": "Override filename (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -901,6 +1116,7 @@ def upload_slack_file(input_data: dict) -> dict: return run_client_sync( "slack", "upload_file_v2", + account=input_data.get("account"), file_path=input_data["file_path"], channel_id=input_data.get("channel_id") or None, initial_comment=input_data.get("initial_comment") or None, @@ -932,6 +1148,11 @@ def upload_slack_file(input_data: dict) -> dict: }, "count": {"type": "integer", "description": "Max results.", "example": 100}, "page": {"type": "integer", "description": "Page number.", "example": 1}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -941,6 +1162,7 @@ def list_slack_files(input_data: dict) -> dict: return run_client_sync( "slack", "list_files", + account=input_data.get("account"), channel=input_data.get("channel") or None, user=input_data.get("user") or None, types=input_data.get("types") or None, @@ -955,13 +1177,21 @@ def list_slack_files(input_data: dict) -> dict: action_sets=["slack_files", "slack"], input_schema={ "file_id": {"type": "string", "description": "File ID.", "example": "F0123ABC"}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_file_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "get_file_info", file_id=input_data["file_id"]) + return run_client_sync("slack", "get_file_info", + account=input_data.get("account"), + file_id=input_data["file_id"], + ) @action( @@ -970,6 +1200,11 @@ def get_slack_file_info(input_data: dict) -> dict: action_sets=["slack_files"], input_schema={ "file_id": {"type": "string", "description": "File ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -977,7 +1212,10 @@ def get_slack_file_info(input_data: dict) -> dict: def delete_slack_file(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "delete_file", file_id=input_data["file_id"]) + return run_client_sync("slack", "delete_file", + account=input_data.get("account"), + file_id=input_data["file_id"], + ) # ------------------------------------------------------------------ @@ -995,6 +1233,11 @@ def delete_slack_file(input_data: dict) -> dict: "description": "Max users to return.", "example": 100, }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={ "status": {"type": "string", "example": "success"}, @@ -1004,7 +1247,10 @@ def delete_slack_file(input_data: dict) -> dict: def list_slack_users(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) + return run_client_sync("slack", "list_users", + account=input_data.get("account"), + limit=input_data.get("limit", 100), + ) @action( @@ -1017,6 +1263,11 @@ def list_slack_users(input_data: dict) -> dict: "description": "User ID.", "example": "U1234567", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1024,7 +1275,9 @@ def get_slack_user_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "get_user_info", user_id=input_data["slack_user_id"] + "slack", "get_user_info", + account=input_data.get("account"), + user_id=input_data["slack_user_id"], ) @@ -1038,13 +1291,21 @@ def get_slack_user_info(input_data: dict) -> dict: "description": "Email address.", "example": "alice@example.com", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def lookup_slack_user_by_email(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "lookup_user_by_email", email=input_data["email"]) + return run_client_sync("slack", "lookup_user_by_email", + account=input_data.get("account"), + email=input_data["email"], + ) @action( @@ -1053,13 +1314,21 @@ def lookup_slack_user_by_email(input_data: dict) -> dict: action_sets=["slack_users"], input_schema={ "user": {"type": "string", "description": "User ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_user_presence(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "get_user_presence", user=input_data["user"]) + return run_client_sync("slack", "get_user_presence", + account=input_data.get("account"), + user=input_data["user"], + ) @action( @@ -1072,6 +1341,11 @@ def get_slack_user_presence(input_data: dict) -> dict: "description": "auto or away.", "example": "auto", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1080,7 +1354,9 @@ def set_slack_user_presence(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "set_user_presence", presence=input_data["presence"] + "slack", "set_user_presence", + account=input_data.get("account"), + presence=input_data["presence"], ) @@ -1104,6 +1380,11 @@ def set_slack_user_presence(input_data: dict) -> dict: "description": "Include user list per group.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1113,6 +1394,7 @@ def list_slack_usergroups(input_data: dict) -> dict: return run_client_sync( "slack", "list_usergroups", + account=input_data.get("account"), include_disabled=bool(input_data.get("include_disabled", False)), include_count=bool(input_data.get("include_count", False)), include_users=bool(input_data.get("include_users", False)), @@ -1144,6 +1426,11 @@ def list_slack_usergroups(input_data: dict) -> dict: "description": "Default channels (optional).", "example": [], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1154,6 +1441,7 @@ def create_slack_usergroup(input_data: dict) -> dict: return run_client_sync( "slack", "create_usergroup", + account=input_data.get("account"), name=input_data["name"], handle=input_data.get("handle") or None, description=input_data.get("description") or None, @@ -1187,6 +1475,11 @@ def create_slack_usergroup(input_data: dict) -> dict: "description": "New default channels (optional).", "example": [], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1197,6 +1490,7 @@ def update_slack_usergroup(input_data: dict) -> dict: return run_client_sync( "slack", "update_usergroup", + account=input_data.get("account"), usergroup=input_data["usergroup"], name=input_data["name"] if "name" in input_data else None, handle=input_data["handle"] if "handle" in input_data else None, @@ -1216,6 +1510,11 @@ def update_slack_usergroup(input_data: dict) -> dict: "description": "Include disabled users.", "example": False, }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1225,6 +1524,7 @@ def list_slack_usergroup_users(input_data: dict) -> dict: return run_client_sync( "slack", "list_usergroup_users", + account=input_data.get("account"), usergroup=input_data["usergroup"], include_disabled=bool(input_data.get("include_disabled", False)), ) @@ -1241,6 +1541,11 @@ def list_slack_usergroup_users(input_data: dict) -> dict: "description": "List of user IDs to set as members.", "example": [], }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1251,6 +1556,7 @@ def set_slack_usergroup_users(input_data: dict) -> dict: return run_client_sync( "slack", "update_usergroup_users", + account=input_data.get("account"), usergroup=input_data["usergroup"], users=input_data["users"], ) @@ -1262,6 +1568,11 @@ def set_slack_usergroup_users(input_data: dict) -> dict: action_sets=["slack_users"], input_schema={ "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1270,7 +1581,9 @@ def enable_slack_usergroup(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "enable_usergroup", usergroup=input_data["usergroup"] + "slack", "enable_usergroup", + account=input_data.get("account"), + usergroup=input_data["usergroup"], ) @@ -1280,6 +1593,11 @@ def enable_slack_usergroup(input_data: dict) -> dict: action_sets=["slack_users"], input_schema={ "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1288,7 +1606,9 @@ def disable_slack_usergroup(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "disable_usergroup", usergroup=input_data["usergroup"] + "slack", "disable_usergroup", + account=input_data.get("account"), + usergroup=input_data["usergroup"], ) @@ -1301,13 +1621,19 @@ def disable_slack_usergroup(input_data: dict) -> dict: name="get_slack_auth_info", description="Get info about the authenticated Slack bot/user (team, user, bot_id).", action_sets=["slack_workspace", "slack"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def get_slack_auth_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "auth_test") + return run_client_sync("slack", "auth_test", account=input_data.get("account")) @action( @@ -1320,6 +1646,11 @@ def get_slack_auth_info(input_data: dict) -> dict: "description": "Team ID (optional, defaults to current).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1327,7 +1658,9 @@ def get_slack_team_info(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "get_team_info", team=input_data.get("team") or None + "slack", "get_team_info", + account=input_data.get("account"), + team=input_data.get("team") or None, ) @@ -1342,6 +1675,11 @@ def get_slack_team_info(input_data: dict) -> dict: "example": "project update", }, "count": {"type": "integer", "description": "Max results.", "example": 20}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1351,6 +1689,7 @@ def search_slack_messages(input_data: dict) -> dict: return run_client_sync( "slack", "search_messages", + account=input_data.get("account"), query=input_data["query"], count=input_data.get("count", 20), ) @@ -1362,6 +1701,11 @@ def search_slack_messages(input_data: dict) -> dict: action_sets=["slack_workspace", "slack"], input_schema={ "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1369,7 +1713,9 @@ def list_slack_bookmarks(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "list_bookmarks", channel_id=input_data["channel_id"] + "slack", "list_bookmarks", + account=input_data.get("account"), + channel_id=input_data["channel_id"], ) @@ -1399,6 +1745,11 @@ def list_slack_bookmarks(input_data: dict) -> dict: "description": "Emoji shortcode (optional).", "example": ":bookmark:", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1409,6 +1760,7 @@ def add_slack_bookmark(input_data: dict) -> dict: return run_client_sync( "slack", "add_bookmark", + account=input_data.get("account"), channel_id=input_data["channel_id"], title=input_data["title"], type=input_data.get("type", "link"), @@ -1435,6 +1787,11 @@ def add_slack_bookmark(input_data: dict) -> dict: "description": "New emoji (optional).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1445,6 +1802,7 @@ def edit_slack_bookmark(input_data: dict) -> dict: return run_client_sync( "slack", "edit_bookmark", + account=input_data.get("account"), channel_id=input_data["channel_id"], bookmark_id=input_data["bookmark_id"], title=input_data["title"] if "title" in input_data else None, @@ -1460,6 +1818,11 @@ def edit_slack_bookmark(input_data: dict) -> dict: input_schema={ "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, "bookmark_id": {"type": "string", "description": "Bookmark ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1470,6 +1833,7 @@ def remove_slack_bookmark(input_data: dict) -> dict: return run_client_sync( "slack", "remove_bookmark", + account=input_data.get("account"), channel_id=input_data["channel_id"], bookmark_id=input_data["bookmark_id"], ) @@ -1495,6 +1859,11 @@ def remove_slack_bookmark(input_data: dict) -> dict: "description": "User ID (optional, defaults to self).", "example": "", }, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1505,6 +1874,7 @@ def add_slack_reminder(input_data: dict) -> dict: return run_client_sync( "slack", "add_reminder", + account=input_data.get("account"), text=input_data["text"], time=input_data["time"], user=input_data.get("user") or None, @@ -1515,13 +1885,19 @@ def add_slack_reminder(input_data: dict) -> dict: name="list_slack_reminders", description="List the authenticated user's Slack reminders.", action_sets=["slack_workspace"], - input_schema={}, + input_schema={ + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, + }, output_schema={"status": {"type": "string", "example": "success"}}, ) def list_slack_reminders(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "list_reminders") + return run_client_sync("slack", "list_reminders", account=input_data.get("account")) @action( @@ -1530,6 +1906,11 @@ def list_slack_reminders(input_data: dict) -> dict: action_sets=["slack_workspace"], input_schema={ "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, ) @@ -1537,7 +1918,9 @@ def get_slack_reminder(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "get_reminder_info", reminder=input_data["reminder"] + "slack", "get_reminder_info", + account=input_data.get("account"), + reminder=input_data["reminder"], ) @@ -1547,6 +1930,11 @@ def get_slack_reminder(input_data: dict) -> dict: action_sets=["slack_workspace"], input_schema={ "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1555,7 +1943,9 @@ def complete_slack_reminder(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync return run_client_sync( - "slack", "complete_reminder", reminder=input_data["reminder"] + "slack", "complete_reminder", + account=input_data.get("account"), + reminder=input_data["reminder"], ) @@ -1565,6 +1955,11 @@ def complete_slack_reminder(input_data: dict) -> dict: action_sets=["slack_workspace"], input_schema={ "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, + "account": { + "type": "string", + "description": "Optional Slack workspace name/ID (or unique fragment, e.g. 'work'). Omit to use the primary workspace.", + "example": "", + }, }, output_schema={"status": {"type": "string", "example": "success"}}, parallelizable=False, @@ -1572,7 +1967,10 @@ def complete_slack_reminder(input_data: dict) -> dict: def delete_slack_reminder(input_data: dict) -> dict: from app.data.action.integrations._helpers import run_client_sync - return run_client_sync("slack", "delete_reminder", reminder=input_data["reminder"]) + return run_client_sync("slack", "delete_reminder", + account=input_data.get("account"), + reminder=input_data["reminder"], + ) # ================================================================== diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index f486cc60..f96aef3b 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -39,6 +39,7 @@ }, ) async def send_message(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS message = input_data["message"] wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -65,7 +66,7 @@ async def send_message(input_data: dict) -> dict: except Exception: pass - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 # Return 'success' for test compatibility, but keep 'ok' in production if needed status = "success" if simulated_mode else "ok" return { diff --git a/app/data/action/send_message_with_attachment.py b/app/data/action/send_message_with_attachment.py index 1546252d..fe28094f 100644 --- a/app/data/action/send_message_with_attachment.py +++ b/app/data/action/send_message_with_attachment.py @@ -59,6 +59,8 @@ }, ) async def send_message_with_attachment(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + message = input_data["message"] file_paths = input_data.get("file_paths", []) wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -93,7 +95,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: if simulated_mode: return { "status": "success", - "fire_at_delay": 10800 if wait_for_user_reply else 0, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0, "wait_for_user_reply": wait_for_user_reply, "files_sent": len(file_paths), } @@ -105,7 +107,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: message, file_paths, session_id=session_id ) - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 files_sent = result.get("files_sent", 0) errors = result.get("errors") diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 4b6980da..4ffdab0f 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -2831,6 +2831,16 @@ disconnect_integration(integration_id, account_id?) The user can also `/ disconnect [account_id]`. +### Multiple accounts per integration + +Gmail, Google Calendar, Google Drive, Google Docs, YouTube, Outlook, LinkedIn, Notion, HubSpot, and Slack support more than one connected account. Each account can have a user-given nickname ("work", "personal") and one account is always the *primary*. + +- Most actions on these integrations take an optional `account` param: an email/workspace-id/hub-domain, a unique substring of one, or the nickname. **If the user's phrasing names or qualifies an account at all** ("my school calendar", "post from my work LinkedIn", "check the beta Slack"), extract that qualifier and pass it as `account` — resolution is safe against a wrong/fuzzy guess (it errors with a clear account list instead of silently succeeding on the wrong account). Only omit `account` / default to primary when the user's message gives no such qualifier. +- The five Google services (Gmail/Calendar/Drive/Docs/YouTube) share ONE alias namespace per underlying Google account — a nickname set on one applies to all of them automatically. +- If `account` doesn't resolve (typo, ambiguous substring, wrong integration), the action's error lists every connected account for that integration — read it back to the user and let them clarify; don't guess. +- To see what's connected (including each account's alias and which is primary), call `check_integration_status(integration_id)` — its `accounts` array has `{id, display, alias, is_primary}` per account. There's no separate "list accounts" action. +- **You cannot set an alias or change the primary account yourself** — no agent action exists for that. Direct the user to Settings → Integrations → Manage → set the nickname / click "Set primary" → "Save changes". + ### Common failure modes ``` diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 6e3ae69f..1562fd72 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -15,7 +15,12 @@ InputComponentProtocol, FootageComponentProtocol, ) -from app.ui_layer.components.types import ChatMessage, ChatMessageOption, ActionItem +from app.ui_layer.components.types import ( + ChatMessage, + ChatMessageOption, + ChatMessageQuestion, + ActionItem, +) if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -540,6 +545,17 @@ def _handle_shutdown(self, event: UIEvent) -> None: # Helper methods # ───────────────────────────────────────────────────────────────────── + def can_prompt_user(self) -> bool: + """ + Whether a user is present and able to answer an interactive prompt + (e.g. a clarifying-question stepper) right now. + + Defaults to False: an adapter must explicitly declare interactive + capability, so actions that park a task waiting on an answer fail + closed on adapters that can't deliver one. + """ + return False + async def _display_chat_message( self, label: str, @@ -547,6 +563,7 @@ async def _display_chat_message( style: str, task_session_id: Optional[str] = None, options: Optional[List[ChatMessageOption]] = None, + questions: Optional[List[ChatMessageQuestion]] = None, client_id: Optional[str] = None, ) -> None: """ @@ -558,6 +575,7 @@ async def _display_chat_message( style: Style identifier task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons + questions: Optional batch of clarifying questions to ask the user client_id: Optional client-generated UUID for reconciling with optimistic UI """ import time @@ -570,6 +588,7 @@ async def _display_chat_message( timestamp=time.time(), task_session_id=task_session_id, options=options, + questions=questions, client_id=client_id, ) ) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 8c44c52d..91d89004 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -92,6 +92,8 @@ connect_integration_oauth, connect_integration_interactive, disconnect_integration, + set_primary_integration_account, + set_integration_account_alias, # WhatsApp QR code flow start_whatsapp_qr_session, check_whatsapp_session_status, @@ -230,6 +232,13 @@ def _init_storage(self) -> None: ) for o in stored.options ] + questions = None + if stored.questions: + from app.ui_layer.components.types import ChatMessageQuestion + + questions = [ + ChatMessageQuestion.from_dict(q) for q in stored.questions + ] self._messages.append( ChatMessage( sender=stored.sender, @@ -241,6 +250,9 @@ def _init_storage(self) -> None: task_session_id=stored.task_session_id, options=options, option_selected=stored.option_selected, + questions=questions, + question_answers=stored.question_answers, + questions_declined=stored.questions_declined, ) ) except Exception: @@ -251,6 +263,11 @@ async def append_message(self, message: ChatMessage) -> None: """Append message and broadcast to clients.""" self._messages.append(message) + # Serialized once, shared by the storage insert and the broadcast + questions_data = ( + [q.to_dict() for q in message.questions] if message.questions else None + ) + # Persist to storage if self._storage: try: @@ -284,6 +301,9 @@ async def append_message(self, message: ChatMessage) -> None: attachments=attachments_data, task_session_id=message.task_session_id, options=options_data, + questions=questions_data, + question_answers=message.question_answers, + questions_declined=message.questions_declined, ) self._storage.insert_message(stored) except Exception: @@ -328,6 +348,14 @@ async def append_message(self, message: ChatMessage) -> None: if message.option_selected: message_data["optionSelected"] = message.option_selected + # Include clarifying-question batch if present + if questions_data: + message_data["questions"] = questions_data + if message.question_answers: + message_data["questionAnswers"] = message.question_answers + if message.questions_declined: + message_data["questionsDeclined"] = message.questions_declined + await self._adapter._broadcast( { "type": "chat_message", @@ -394,6 +422,13 @@ def get_messages_before( ) for o in s.options ] + questions = None + if s.questions: + from app.ui_layer.components.types import ChatMessageQuestion + + questions = [ + ChatMessageQuestion.from_dict(q) for q in s.questions + ] messages.append( ChatMessage( sender=s.sender, @@ -402,8 +437,12 @@ def get_messages_before( timestamp=s.timestamp, message_id=s.message_id, attachments=attachments, + task_session_id=s.task_session_id, options=options, option_selected=s.option_selected, + questions=questions, + question_answers=s.question_answers, + questions_declined=s.questions_declined, ) ) return messages @@ -1098,6 +1137,10 @@ def metrics_collector(self) -> MetricsCollector: """Get the metrics collector for dashboard data.""" return self._metrics_collector + def can_prompt_user(self) -> bool: + """A user can answer an interactive prompt when a browser tab is connected.""" + return len(self._ws_clients) > 0 + async def submit_message( self, message: str, @@ -1641,6 +1684,15 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: message_id = data.get("messageId", "") await self._handle_option_click(value, session_id, message_id) + elif msg_type == "question_answers_submit": + session_id = data.get("sessionId", "") + message_id = data.get("messageId", "") + answers = data.get("answers") + declined = bool(data.get("declined", False)) + await self._handle_question_answers_submit( + session_id, message_id, answers, declined + ) + # Settings operations elif msg_type == "settings_get": await self._handle_settings_get() @@ -1929,6 +1981,17 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: account_id = data.get("account_id") await self._handle_integration_disconnect(integration_id, account_id) + elif msg_type == "integration_set_primary": + integration_id = data.get("id", "") + account_id = data.get("account_id", "") + await self._handle_integration_set_primary(integration_id, account_id) + + elif msg_type == "integration_set_alias": + integration_id = data.get("id", "") + account_id = data.get("account_id", "") + alias = data.get("alias", "") + await self._handle_integration_set_alias(integration_id, account_id, alias) + # Generic per-integration config (replaces the old bespoke jira/github settings handlers) elif msg_type == "integration_get_config": integration_id = data.get("id") @@ -4046,6 +4109,110 @@ async def _handle_option_click( f"[OPTION_CLICK] Error handling option click: {e}", exc_info=True ) + async def _handle_question_answers_submit( + self, + session_id: str, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> None: + """Handle the user resolving a batch of agent-asked clarifying questions. + + Persists the resolution, then resumes the parked task the same way a + normal reply to a waiting task does (see ``submit_message``'s + ``reply_context`` handling) — no separate resume path needed. + + First submission wins: the storage UPDATE only matches an unresolved + row, so a duplicate submit (e.g. from a second browser tab that still + showed the live stepper) is dropped instead of resuming the task twice. + """ + try: + resolved_answers = answers if not declined else None + storage = self._chat._storage if self._chat else None + + in_memory = None + if self._chat and message_id: + for m in self._chat._messages: + if m.message_id == message_id: + in_memory = m + break + + already_resolved_in_memory = in_memory is not None and ( + in_memory.question_answers is not None + or in_memory.questions_declined + ) + if storage: + claimed = False + try: + claimed = storage.update_question_answers( + message_id, resolved_answers, declined + ) + except Exception: + # Storage unavailable: fall back to the in-memory guard. + claimed = not already_resolved_in_memory + # update_question_answers returns False for a missing row too; + # only treat it as a duplicate when memory confirms resolution + # or has no unresolved copy to contradict it. + if not claimed and (in_memory is None or already_resolved_in_memory): + logger.info( + f"[QUESTION_ANSWERS] Ignoring duplicate submit for {message_id}" + ) + return + elif already_resolved_in_memory: + return + + question_text_by_id: Dict[str, str] = {} + if in_memory is not None: + if in_memory.questions: + question_text_by_id = {q.id: q.text for q in in_memory.questions} + in_memory.question_answers = resolved_answers + in_memory.questions_declined = declined + elif storage: + # Message aged out of the in-memory window (e.g. answered via + # paginated history after a restart): recover the question + # texts from storage so the resume text isn't raw ids. + try: + stored = storage.get_message(message_id) + if stored and stored.questions: + question_text_by_id = { + q.get("id", ""): q.get("text", "") + for q in stored.questions + } + except Exception: + pass + + # Collapse the stepper on every connected client — the submitting + # tab already resolved optimistically, but other tabs would keep + # showing a live stepper and could submit again. + await self._broadcast( + { + "type": "question_answers_resolved", + "data": { + "messageId": message_id, + "answers": resolved_answers, + "declined": declined, + }, + } + ) + + if resolved_answers: + lines = [ + f"{i + 1}. {question_text_by_id.get(qid, qid)} → {answer}" + for i, (qid, answer) in enumerate(resolved_answers.items()) + ] + resume_text = "Answered clarifying questions:\n" + "\n".join(lines) + else: + resume_text = "The user declined to answer." + + await self.submit_message( + resume_text, reply_context={"sessionId": session_id} + ) + except Exception as e: + logger.error( + f"[QUESTION_ANSWERS] Error handling question answers: {e}", + exc_info=True, + ) + # ───────────────────────────────────────────────────────────────────── # Settings Operation Handlers # ───────────────────────────────────────────────────────────────────── @@ -6942,6 +7109,62 @@ async def _do_disconnect() -> None: asyncio.create_task(_do_disconnect()) + async def _handle_integration_set_primary( + self, integration_id: str, account_id: str + ) -> None: + """Promote a connected account to primary for this integration.""" + try: + success, message = await set_primary_integration_account( + integration_id, account_id + ) + await self._broadcast( + { + "type": "integration_set_primary_result", + "data": { + "success": success, + "message": message, + "id": integration_id, + }, + } + ) + if success: + await self._handle_integration_list() + except Exception as e: + await self._broadcast( + { + "type": "integration_set_primary_result", + "data": {"success": False, "error": str(e), "id": integration_id}, + } + ) + + async def _handle_integration_set_alias( + self, integration_id: str, account_id: str, alias: str + ) -> None: + """Set (or, with an empty alias, clear) a connected account's alias.""" + try: + success, message = set_integration_account_alias( + integration_id, account_id, alias + ) + await self._broadcast( + { + "type": "integration_set_alias_result", + "data": { + "success": success, + "message": message, + "id": integration_id, + }, + } + ) + if success: + await self._handle_integration_list() + except Exception as e: + await self._broadcast( + { + "type": "integration_set_alias_result", + "data": {"success": False, "error": str(e), "id": integration_id}, + } + ) + # ========================== # Generic per-integration config # ========================== @@ -8039,6 +8262,12 @@ async def _handle_chat_history( ] if m.option_selected: msg_data["optionSelected"] = m.option_selected + if m.questions: + msg_data["questions"] = [q.to_dict() for q in m.questions] + if m.question_answers: + msg_data["questionAnswers"] = m.question_answers + if m.questions_declined: + msg_data["questionsDeclined"] = m.questions_declined messages_data.append(msg_data) await self._broadcast( @@ -8748,6 +8977,21 @@ def _get_initial_state(self) -> Dict[str, Any]: if m.option_selected else {} ), + **( + {"questions": [q.to_dict() for q in m.questions]} + if m.questions + else {} + ), + **( + {"questionAnswers": m.question_answers} + if m.question_answers + else {} + ), + **( + {"questionsDeclined": m.questions_declined} + if m.questions_declined + else {} + ), } for m in self._chat.get_messages() ], diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 10dfcb3d..361b53f0 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -104,6 +104,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { sendMessage, sendCommand, sendOptionClick, + sendQuestionAnswers, openFile, openFolder, lastSeenMessageId, @@ -726,6 +727,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { onOpenFolder={openFolder} onReply={handleChatReply} onOptionClick={sendOptionClick} + onQuestionAnswers={sendQuestionAnswers} /> ) diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css new file mode 100644 index 00000000..3b93ae33 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css @@ -0,0 +1,267 @@ +.stepper { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: var(--space-2); +} + +.nav { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.backButton { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.backButton:hover { + color: var(--text-primary); + background: var(--bg-primary); +} + +.navDot { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 0; +} + +.navDot:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.navDotActive { + border-color: var(--text-primary); + color: var(--text-primary); + font-weight: var(--font-semibold); +} + +.navDotDone { + background: var(--bg-primary); +} + +.navReview { + font-size: var(--text-xs); + color: var(--text-secondary); + margin-left: var(--space-1); +} + +.dismissButton { + margin-left: auto; + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.dismissButton:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.question { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.questionText { + font-size: var(--text-sm); + color: var(--text-primary); + padding-bottom: var(--space-1); +} + +.choiceButton { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.choiceButton:hover { + background: var(--bg-primary); +} + +/* inset shadow instead of a border so selection doesn't shift layout */ +.choiceButtonSelected { + background: var(--bg-primary); + box-shadow: inset 0 0 0 1px var(--text-primary); +} + +.choiceIndex { + font-weight: var(--font-semibold); + color: var(--text-secondary); +} + +.checkboxRow { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + border-radius: var(--radius-sm); +} + +.checkboxRow:hover { + background: var(--bg-primary); +} + +.checkbox { + width: 16px; + height: 16px; + accent-color: var(--text-primary); + cursor: pointer; +} + +.otherRow { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.otherInput { + flex: 1; + padding: var(--space-1) 0; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--text-sm); + font-family: inherit; +} + +.otherInput:focus { + outline: none; +} + +.otherSend { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-medium); + cursor: pointer; + align-self: center; +} + +.otherSend:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--text-primary); +} + +.otherSend:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.review { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.reviewRow { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + text-align: left; +} + +.reviewRow:hover { + background: var(--bg-primary); +} + +.submitButton { + margin-top: var(--space-1); + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: var(--text-primary); + color: var(--bg-primary); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + cursor: pointer; +} + +.submitButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.resolved { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: 2px; +} + +.resolvedRow { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-sm); + padding: var(--space-1) 0; +} + +.resolvedQuestion { + color: var(--text-secondary); + font-size: var(--text-xs); +} + +.resolvedAnswer { + color: var(--text-primary); +} + +.declinedNote { + color: var(--text-secondary); + font-size: var(--text-sm); + font-style: italic; +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx new file mode 100644 index 00000000..2671f3df --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx @@ -0,0 +1,248 @@ +import { useState, type KeyboardEvent } from 'react' +import type { ChatMessageQuestion } from '../../types' +import styles from './QuestionStepper.module.css' + +export interface QuestionStepperProps { + messageId: string + sessionId?: string + questions: ChatMessageQuestion[] + answers?: Record + declined?: boolean + onSubmit: (messageId: string, sessionId: string | undefined, answers: Record | undefined, declined: boolean) => void +} + +// Per-question draft kept structured (which boxes are checked vs. what was +// free-typed) so navigating between questions never has to reverse-engineer +// state from the joined answer string — choice values containing ", " would +// mis-parse, and a free-typed value equal to a choice would be reclassified. +interface QuestionDraft { + checked: string[] + other: string +} + +const draftText = (d: QuestionDraft) => + [...d.checked, ...(d.other ? [d.other] : [])].join(', ') + +export function QuestionStepper({ messageId, sessionId, questions, answers, declined, onSubmit }: QuestionStepperProps) { + const resolved = !!answers || !!declined + const [step, setStep] = useState(0) + const [drafts, setDrafts] = useState>({}) + const [otherText, setOtherText] = useState('') + const [checked, setChecked] = useState>(new Set()) + + // Esc abandons the whole batch — scoped to this stepper via bubbling from + // its own focused controls, not a window-wide listener (which would decline + // whichever batch happened to be mounted regardless of what's focused). + // The free-text inputs intercept Esc to clear themselves instead, and the + // Dismiss button covers the case where nothing in the stepper has focus. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) + } + + if (resolved) { + return ( +
+ {declined ? ( + Declined to answer. + ) : ( + questions.map(q => ( +
+ {q.text} + {answers?.[q.id]} +
+ )) + )} +
+ ) + } + + const single = questions.length === 1 + const isReview = !single && step === questions.length + const current = questions[step] + + const answerTextFor = (qid: string): string | undefined => { + const d = drafts[qid] + return d === undefined ? undefined : draftText(d) + } + + // Navigating to a question rehydrates its saved draft (if any) so a + // previously-given answer shows exactly as it was entered. + // `source` lets callers that just updated the drafts pass the fresh map, + // since the `drafts` in this closure is one render behind. + const goToStep = (i: number, source: Record = drafts) => { + setStep(i) + const q = questions[i] + if (!q) return // i === questions.length: moving into the review step + const d = source[q.id] + setChecked(new Set(d?.checked ?? [])) + setOtherText(d?.other ?? '') + } + + const saveCurrent = (d: QuestionDraft) => { + const next = { ...drafts, [current.id]: d } + setDrafts(next) + if (single) { + onSubmit(messageId, sessionId, { [current.id]: draftText(d) }, false) + } else { + goToStep(step + 1, next) + } + } + + const toggleChecked = (value: string) => { + const next = new Set(checked) + if (next.has(value)) next.delete(value) + else next.add(value) + setChecked(next) + } + + const confirmMultiSelect = () => { + const other = otherText.trim() + if (checked.size === 0 && !other) return + saveCurrent({ checked: [...checked], other }) + } + + const submitOther = () => { + const other = otherText.trim() + if (!other) return + saveCurrent({ checked: [], other }) + } + + const submitAll = () => { + const all: Record = {} + for (const q of questions) { + const text = answerTextFor(q.id) + if (text !== undefined) all[q.id] = text + } + onSubmit(messageId, sessionId, all, false) + } + + const clearOnEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() // clear the field, don't decline the batch + setOtherText('') + } + } + + return ( +
+
+ {!single && step > 0 && ( + + )} + {!single && questions.map((q, i) => ( + + ))} + {!single && ( + {isReview ? 'Review' : `${step + 1} of ${questions.length}`} + )} + +
+ + {isReview ? ( +
+ {questions.map(q => ( + + ))} + +
+ ) : ( +
+ {!single && {current.text}} + + {current.multiSelect ? ( + <> + {current.choices.map(c => ( + + ))} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmMultiSelect(); clearOnEscape(e) }} + placeholder="Other (optional)..." + /> +
+ + + ) : ( + <> + {current.choices.map((c, i) => { + const selected = checked.has(c.value) + return ( + + ) + })} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submitOther(); clearOnEscape(e) }} + placeholder={current.choices.length > 0 ? 'Or type your own answer...' : 'Type your answer...'} + /> + +
+ + )} +
+ )} +
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/index.ts b/app/ui_layer/browser/frontend/src/components/ui/index.ts index f0193089..c5f67a71 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/index.ts +++ b/app/ui_layer/browser/frontend/src/components/ui/index.ts @@ -14,6 +14,9 @@ export { MarkdownContent } from './MarkdownContent' export { AttachmentDisplay } from './AttachmentDisplay' +export { QuestionStepper } from './QuestionStepper' +export type { QuestionStepperProps } from './QuestionStepper' + export { AttachmentPreviewModal } from './AttachmentPreviewModal' export type { AttachmentPreviewModalProps, AttachmentPreviewItem } from './AttachmentPreviewModal' diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 28cc09d4..84bb9de4 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -19,6 +19,7 @@ import { addOptimistic as messagesAddOptimistic, setLoadingOlder as messagesSetLoadingOlder, markOptionSelected as messagesMarkOptionSelected, + markQuestionsAnswered as messagesMarkQuestionsAnswered, clear as messagesClear, } from '../store/slices/messagesSlice' import { @@ -219,6 +220,13 @@ interface WebSocketContextType extends WebSocketState { pullOllamaModel: (model: string) => void // Option click (interactive buttons in chat) sendOptionClick: (value: string, sessionId?: string, messageId?: string) => void + // Clarifying-question batch answers (agent-asked Q&A stepper in chat) + sendQuestionAnswers: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void // Agent profile picture uploadAgentProfilePicture: (name: string, mimeType: string, contentBase64: string) => void removeAgentProfilePicture: () => void @@ -498,6 +506,21 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + const sendQuestionAnswers = useCallback(( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => { + // Optimistically resolve the stepper locally, same reasoning as sendOptionClick. + dispatch(messagesMarkQuestionsAnswered({ messageId, answers, declined })) + if (client.isConnected) { + client.sendString(JSON.stringify({ + type: 'question_answers_submit', messageId, sessionId, answers, declined, + })) + } + }, []) + const uploadAgentProfilePicture = useCallback( (name: string, mimeType: string, contentBase64: string) => { if (client.isConnected) { @@ -771,6 +794,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { enhancePrompt, clearEnhancedPrompt, sendOptionClick, + sendQuestionAnswers, uploadAgentProfilePicture, removeAgentProfilePicture, // Living UI methods diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index 8d3b79f9..e7a96ba0 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -1,6 +1,6 @@ import React, { memo, useState, useMemo, useRef, useEffect } from 'react' import { Reply, Copy, Check } from 'lucide-react' -import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton } from '../../components/ui' +import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton, QuestionStepper } from '../../components/ui' import type { Attachment, ChatMessage as ChatMessageType } from '../../types' import { useWebSocket } from '../../contexts/WebSocketContext' import styles from './ChatPage.module.css' @@ -15,6 +15,12 @@ interface ChatMessageProps { fullContent: string ) => void onOptionClick?: (value: string, sessionId?: string, messageId?: string) => void + onQuestionAnswers?: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void } // Parse reply context from message content @@ -36,6 +42,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ onOpenFolder, onReply, onOptionClick, + onQuestionAnswers, }: ChatMessageProps) { const [isHovered, setIsHovered] = useState(false) const [copied, setCopied] = useState(false) @@ -51,10 +58,14 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, [selected]) const { agentProfilePictureUrl } = useWebSocket() - // Show reply for agent messages, except those presenting options that - // require the user to make an explicit choice via the option buttons. + // Show reply for agent messages, except those presenting options/questions + // that require the user to make an explicit choice via the buttons. const hasPendingOptions = !!(message.options && message.options.length > 0) - const canReply = message.style === 'agent' && onReply && !hasPendingOptions + const hasPendingQuestions = !!( + message.questions && message.questions.length > 0 + && !message.questionAnswers && !message.questionsDeclined + ) + const canReply = message.style === 'agent' && onReply && !hasPendingOptions && !hasPendingQuestions const canCopy = message.style === 'user' || message.style === 'agent' // Parse reply context for user messages @@ -126,6 +137,16 @@ export const ChatMessageItem = memo(function ChatMessageItem({ ))} )} + {message.questions && message.questions.length > 0 && ( + onQuestionAnswers?.(msgId, sid, ans, dec)} + /> + )} {message.attachments && message.attachments.length > 0 && (
@@ -192,5 +213,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, (prev, next) => prev.message.messageId === next.message.messageId && prev.message.optionSelected === next.message.optionSelected + && prev.message.questionAnswers === next.message.questionAnswers + && prev.message.questionsDeclined === next.message.questionsDeclined && prev.message.content === next.message.content ) diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx index 9a96e337..a9ab939f 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx @@ -391,6 +391,37 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // Manage modal state const [showManageModal, setShowManageModal] = useState(false) const [managingIntegration, setManagingIntegration] = useState(null) + // Mirrors managingIntegration for the same reason as selectedIntegrationRef + // above — used to refresh the accounts list after an "Add account" connect. + const managingIntegrationRef = React.useRef(null) + useEffect(() => { + managingIntegrationRef.current = managingIntegration + }, [managingIntegration]) + + // Staged (not-yet-saved) account changes for the Manage modal. Alias edits, + // "Set primary", and "Disconnect" only update this local state — nothing + // reaches the backend until "Save changes" is clicked. "Add account" is the + // one exception: it's a real OAuth grant with Google/etc, so it always + // applies immediately (there's nothing to stage — the token already exists + // the moment the user approves it externally). + const [pendingAliases, setPendingAliases] = useState>({}) + const [pendingPrimary, setPendingPrimary] = useState(null) + const [pendingDisconnects, setPendingDisconnects] = useState>(new Set()) + + const resetPendingAccountChanges = () => { + setPendingAliases({}) + setPendingPrimary(null) + setPendingDisconnects(new Set()) + } + + // Disconnect results normally close the Manage modal (single-account + // flow). During a staged batch save with pending disconnects, the modal + // should stay open and just refresh instead — this ref is the simplest + // way to tell the shared result handler which behavior applies, since the + // WS transport has no per-request correlation to key off of. Cleared a + // few seconds after firing, long enough for local disconnect calls to + // resolve. + const isSavingAccountChangesRef = React.useRef(false) // Slow operation overlay — shown during long disconnects (WhatsApp Web's // bridge teardown can take 20–30 seconds; without this the user has no @@ -452,6 +483,13 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool if (just && just.has_config && (just.config_fields?.length ?? 0) > 0) { send('integration_info', { id: just.id }) } + // Add-account (from the Manage modal) doesn't go through + // selectedIntegration — refresh managingIntegration's accounts + // list directly so the new account shows up without a reload. + const managing = managingIntegrationRef.current + if (managing && d.id === managing.id) { + send('integration_info', { id: d.id }) + } } else { setConnectError(d.error || d.message || 'Connection failed') } @@ -462,13 +500,38 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // operation it was tracking. setPendingOp(prev => (prev && d.id && prev.id === d.id) ? null : prev) if (d.success) { - showToast('success', d.message || 'Disconnected successfully') - setShowManageModal(false) - setManagingIntegration(null) + if (isSavingAccountChangesRef.current) { + // Part of a staged "Save changes" batch — keep the modal open + // and just refresh its accounts list instead of closing it. + if (d.id) send('integration_info', { id: d.id }) + } else { + showToast('success', d.message || 'Disconnected successfully') + setShowManageModal(false) + setManagingIntegration(null) + } } else { showToast('error', d.error || 'Failed to disconnect') } }), + onMessage('integration_set_primary_result', (data: unknown) => { + const d = data as { success: boolean; message?: string; error?: string; id?: string } + if (d.success) { + showToast('success', d.message || 'Primary account updated') + // Stay on the Manage modal — refresh its accounts list in place. + if (d.id) send('integration_info', { id: d.id }) + } else { + showToast('error', d.error || d.message || 'Failed to set primary account') + } + }), + onMessage('integration_set_alias_result', (data: unknown) => { + const d = data as { success: boolean; message?: string; error?: string; id?: string } + if (d.success) { + showToast('success', d.message || 'Nickname saved') + if (d.id) send('integration_info', { id: d.id }) + } else { + showToast('error', d.error || d.message || 'Failed to set alias') + } + }), onMessage('integration_info', (data: unknown) => { const d = data as { success: boolean; integration?: Integration; error?: string } if (d.success && d.integration) { @@ -633,9 +696,17 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool } const handleOpenManage = (integration: Integration) => { + resetPendingAccountChanges() send('integration_info', { id: integration.id }) } + // Closing the modal without saving discards any staged alias/primary/ + // disconnect edits — only "Save changes" commits them. + const handleCloseManage = () => { + setShowManageModal(false) + resetPendingAccountChanges() + } + const handleConnectToken = () => { if (!selectedIntegration) return setIsConnecting(true) @@ -653,6 +724,15 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool send('integration_connect_oauth', { id: selectedIntegration.id }) } + // "Add account" in the Manage modal — same OAuth connect flow as the + // initial Connect button, just keyed off managingIntegration instead of + // selectedIntegration. On success the connect_result handler below + // refreshes managingIntegration so the new account shows up immediately. + const handleAddAccount = () => { + if (!managingIntegration) return + send('integration_connect_oauth', { id: managingIntegration.id }) + } + const handleConnectInteractive = () => { if (!selectedIntegration) return setIsConnecting(true) @@ -665,6 +745,96 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // 20–30 seconds for WhatsApp Web). Add other slow integrations here. const SLOW_DISCONNECT_IDS = new Set(['whatsapp_web']) + // Integrations whose backend credential storage supports more than one + // connected account — see craftos_integrations/accounts.py and each + // integration's list_accounts()/set_primary()/set_alias() overrides. + // "Add account" and the alias/primary controls are hidden for everything + // else since their storage still overwrites the single credential file + // on reconnect. + const MULTI_ACCOUNT_IDS = new Set([ + 'gmail', + 'google_calendar', + 'google_drive', + 'google_docs', + 'google_youtube', + 'outlook', + 'linkedin', + 'notion', + 'hubspot', + 'slack', + ]) + + // Stage a primary-account change — applied only on "Save changes". + const handleSetPrimary = (accountId: string) => { + setPendingPrimary(accountId) + } + + // Stage an alias edit — applied only on "Save changes". + const handleAliasInputChange = (accountId: string, value: string) => { + setPendingAliases(prev => ({ ...prev, [accountId]: value })) + } + + // Toggle an account between "marked for removal" and normal — applied + // only on "Save changes". Un-staging a pending primary choice if that + // same account gets marked for removal (disconnecting your chosen new + // primary makes no sense). + const handleToggleStagedDisconnect = (accountId: string) => { + setPendingDisconnects(prev => { + const next = new Set(prev) + if (next.has(accountId)) { + next.delete(accountId) + } else { + next.add(accountId) + } + return next + }) + setPendingPrimary(prev => (prev === accountId ? null : prev)) + } + + const hasPendingAccountChanges = + pendingDisconnects.size > 0 || + pendingPrimary !== null || + Object.entries(pendingAliases).some( + ([id, val]) => + val.trim() !== + (managingIntegration?.accounts.find(a => a.id === id)?.alias ?? ''), + ) + + const handleSaveAccountChanges = () => { + if (!managingIntegration) return + const id = managingIntegration.id + const realPrimaryId = managingIntegration.accounts.find(a => a.is_primary)?.id + + if (pendingDisconnects.size > 0) { + isSavingAccountChangesRef.current = true + setTimeout(() => { + isSavingAccountChangesRef.current = false + }, 4000) + } + + pendingDisconnects.forEach(accountId => { + send('integration_disconnect', { id, account_id: accountId }) + }) + + if ( + pendingPrimary && + pendingPrimary !== realPrimaryId && + !pendingDisconnects.has(pendingPrimary) + ) { + send('integration_set_primary', { id, account_id: pendingPrimary }) + } + + Object.entries(pendingAliases).forEach(([accountId, alias]) => { + if (pendingDisconnects.has(accountId)) return + const current = managingIntegration.accounts.find(a => a.id === accountId)?.alias ?? '' + if (alias.trim() !== current) { + send('integration_set_alias', { id, account_id: accountId, alias: alias.trim() }) + } + }) + + resetPendingAccountChanges() + } + const handleDisconnect = (accountId?: string) => { if (!managingIntegration) return const targetId = managingIntegration.id @@ -1117,11 +1287,11 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool {/* Manage Modal */} {showManageModal && managingIntegration && ( -
setShowManageModal(false)}> +
e.stopPropagation()}>

Manage {managingIntegration.name}

-
@@ -1131,18 +1301,104 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool

No accounts connected

) : (
- {managingIntegration.accounts.map(account => ( -
- {account.display} - + )} + {supportsMultiAccount ? ( + + ) : ( + + )} +
+
+ ) + })} +
+ )} + {MULTI_ACCOUNT_IDS.has(managingIntegration.id) && ( +
+ +
+ {hasPendingAccountChanges && ( + -
- ))} + )} + +
)} {/* Configure — schema-driven form, only shown for integrations diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css index 9cd6cb40..14b3febe 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Settings/SettingsPage.module.css @@ -617,16 +617,90 @@ .accountItem { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + align-items: stretch; + gap: var(--space-2); padding: var(--space-3); background: var(--bg-tertiary); border-radius: var(--radius-md); } .accountName { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 0; + max-width: 100%; +} + +.accountNameTop { + display: flex; + align-items: center; + gap: var(--space-2); + max-width: 100%; + min-width: 0; font-size: var(--text-sm); color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Real account identity (email, workspace id, ...), shown under the alias + so setting a nickname never hides which account it actually points to. */ +.accountIdentity { + max-width: 100%; + font-size: var(--text-xs); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.accountControls { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.aliasInput { + width: 160px; + min-width: 0; + padding: var(--space-2) var(--space-3); + font-size: var(--text-sm); + color: var(--text-primary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); +} + +.aliasInput:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Account staged for removal on "Save changes" but not yet applied — dimmed + and outlined so it reads as "pending", not gone. */ +.accountItemPendingRemoval { + opacity: 0.6; + outline: 1px dashed rgba(239, 68, 68, 0.4); +} + +.accountsFooter { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); +} + +.accountsSaveBar { + display: flex; + align-items: center; + gap: var(--space-2); + margin-left: auto; } /* Danger Zone */ diff --git a/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts index c958bc6f..90209e7d 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts @@ -11,6 +11,12 @@ export interface IntegrationField { export interface IntegrationAccount { display: string id: string + // Present only for integrations whose backend supports multiple accounts + // (Gmail, Calendar, Drive, Docs, YouTube, Outlook, LinkedIn, Notion, + // HubSpot, Slack) — see craftos_integrations/accounts.py. Absent/undefined + // for single-account integrations. + alias?: string | null + is_primary?: boolean } // Schema for a single config input rendered by the Configure section in diff --git a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts index c2e23a3f..7dd4c25d 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts @@ -68,6 +68,20 @@ const messagesSlice = createSlice({ entry.optionSelected = value } }, + markQuestionsAnswered( + state, + action: PayloadAction<{ messageId: string; answers?: Record; declined?: boolean }> + ) { + const { messageId, answers, declined } = action.payload + const entry = state.entities[messageId] + if (entry && !entry.questionAnswers && !entry.questionsDeclined) { + if (declined) { + entry.questionsDeclined = true + } else { + entry.questionAnswers = answers + } + } + }, }, }) @@ -79,6 +93,7 @@ export const { clear, setLoadingOlder, markOptionSelected, + markQuestionsAnswered, } = messagesSlice.actions export const messagesAdapter = adapter @@ -104,3 +119,12 @@ register('chat_history', (data, dispatch) => { register('chat_clear', (_data, dispatch) => { dispatch(clear()) }) + +// Another client resolved a question batch — collapse the stepper here too. +// markQuestionsAnswered is a no-op if this tab already resolved it optimistically. +register('question_answers_resolved', (data, dispatch) => { + const d = data as { messageId?: string; answers?: Record | null; declined?: boolean } | undefined + if (d?.messageId) { + dispatch(markQuestionsAnswered({ messageId: d.messageId, answers: d.answers ?? undefined, declined: !!d.declined })) + } +}) diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index a5f21f3e..c41d6e96 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -18,6 +18,13 @@ export interface ChatMessageOption { style?: 'primary' | 'danger' | 'default' } +export interface ChatMessageQuestion { + id: string + text: string + choices: ChatMessageOption[] + multiSelect?: boolean +} + export interface ChatMessage { sender: string content: string @@ -28,6 +35,9 @@ export interface ChatMessage { taskSessionId?: string // Links message to a task session for reply feature options?: ChatMessageOption[] optionSelected?: string // Value of the option that was selected + questions?: ChatMessageQuestion[] // Batch of clarifying questions the agent is waiting on + questionAnswers?: Record // Answer text per question id, once submitted + questionsDeclined?: boolean // True if the user escaped the batch without answering clientId?: string // Client-generated UUID for reconciling optimistic pending messages with server echo pending?: boolean // True while an optimistic message is awaiting server acknowledgment } diff --git a/app/ui_layer/components/types.py b/app/ui_layer/components/types.py index 85fa9c9b..97d1c693 100644 --- a/app/ui_layer/components/types.py +++ b/app/ui_layer/components/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, List +from typing import Optional, List, Dict import time @@ -45,6 +45,48 @@ class ChatMessageOption: style: str = "default" +@dataclass +class ChatMessageQuestion: + """ + Data structure for one clarifying question in a batch asked by the agent. + + Attributes: + id: Stable identifier for this question within the batch (e.g. "q1") + text: The question text shown to the user + choices: Multiple-choice options; the frontend always adds an + implicit free-text "Other" option after these + multi_select: If True, the user can pick more than one choice + (checkboxes); if False, picking one answers the question (radio) + """ + + id: str + text: str + choices: List[ChatMessageOption] = field(default_factory=list) + multi_select: bool = False + + def to_dict(self) -> Dict: + """Wire/storage shape (camelCase keys, matches the frontend type).""" + return { + "id": self.id, + "text": self.text, + "choices": [{"label": c.label, "value": c.value} for c in self.choices], + "multiSelect": self.multi_select, + } + + @classmethod + def from_dict(cls, data: Dict) -> "ChatMessageQuestion": + """Inverse of to_dict, tolerant of missing keys.""" + return cls( + id=data.get("id", ""), + text=data.get("text", ""), + choices=[ + ChatMessageOption(label=c.get("label", ""), value=c.get("value", "")) + for c in data.get("choices", []) + ], + multi_select=data.get("multiSelect", False), + ) + + @dataclass class ChatMessage: """ @@ -62,6 +104,9 @@ class ChatMessage: task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons option_selected: Value of the option that was selected, if any + questions: Optional batch of clarifying questions the agent is waiting on + question_answers: Answer text per question id, once submitted + questions_declined: True if the user escaped out of the question batch """ sender: str @@ -73,6 +118,9 @@ class ChatMessage: task_session_id: Optional[str] = None options: Optional[List[ChatMessageOption]] = None option_selected: Optional[str] = None + questions: Optional[List[ChatMessageQuestion]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None # Client-generated UUID from the sender; echoed back so the browser can # reconcile optimistic-pending messages with the server-acknowledged copy. client_id: Optional[str] = None diff --git a/app/ui_layer/settings/__init__.py b/app/ui_layer/settings/__init__.py index 1e76cb0e..ebf5ebbd 100644 --- a/app/ui_layer/settings/__init__.py +++ b/app/ui_layer/settings/__init__.py @@ -34,6 +34,8 @@ connect_oauth as connect_integration_oauth, connect_interactive as connect_integration_interactive, disconnect as disconnect_integration, + set_primary_account as set_primary_integration_account, + set_account_alias as set_integration_account_alias, get_integration_accounts, get_integration_auth_type, get_integration_fields, @@ -151,6 +153,8 @@ "connect_integration_oauth", "connect_integration_interactive", "disconnect_integration", + "set_primary_integration_account", + "set_integration_account_alias", "get_integration_auth_type", "get_integration_fields", # WhatsApp QR code flow diff --git a/app/usage/chat_storage.py b/app/usage/chat_storage.py index 9ec4ef84..c49919ee 100644 --- a/app/usage/chat_storage.py +++ b/app/usage/chat_storage.py @@ -35,6 +35,9 @@ class StoredChatMessage: task_session_id: Optional[str] = None options: Optional[List[Dict[str, Any]]] = None option_selected: Optional[str] = None + questions: Optional[List[Dict[str, Any]]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" @@ -53,6 +56,12 @@ def to_dict(self) -> Dict[str, Any]: result["options"] = self.options if self.option_selected: result["optionSelected"] = self.option_selected + if self.questions: + result["questions"] = self.questions + if self.question_answers: + result["questionAnswers"] = self.question_answers + if self.questions_declined: + result["questionsDeclined"] = self.questions_declined return result @@ -131,6 +140,24 @@ def _init_db(self) -> None: ADD COLUMN option_selected TEXT """) logger.info("[ChatStorage] Migrated: added option_selected column") + if "questions" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions TEXT + """) + logger.info("[ChatStorage] Migrated: added questions column") + if "question_answers" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN question_answers TEXT + """) + logger.info("[ChatStorage] Migrated: added question_answers column") + if "questions_declined" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions_declined INTEGER + """) + logger.info("[ChatStorage] Migrated: added questions_declined column") conn.commit() @@ -149,8 +176,8 @@ def insert_message(self, message: StoredChatMessage) -> int: cursor.execute( """ INSERT OR REPLACE INTO chat_messages - (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( message.message_id, @@ -162,6 +189,9 @@ def insert_message(self, message: StoredChatMessage) -> int: message.task_session_id, json.dumps(message.options) if message.options else None, message.option_selected, + json.dumps(message.questions) if message.questions else None, + json.dumps(message.question_answers) if message.question_answers else None, + message.questions_declined, ), ) conn.commit() @@ -186,7 +216,7 @@ def get_messages( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp ASC LIMIT ? OFFSET ? @@ -206,6 +236,9 @@ def get_messages( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -225,7 +258,7 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: # Get last N messages ordered by timestamp DESC, then reverse cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp DESC LIMIT ? @@ -245,6 +278,9 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -287,6 +323,78 @@ def update_option_selected(self, message_id: str, option_value: str) -> bool: conn.commit() return cursor.rowcount > 0 + def update_question_answers( + self, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> bool: + """ + Record the resolution of a clarifying-question batch on a message. + + Args: + message_id: The message ID to update. + answers: Answer text per question id, or None if declined. + declined: True if the user escaped out of the batch instead of answering. + + Returns: + True if the message was updated, False if not found or already + resolved. The WHERE clause only matches an unresolved row, making + this the atomic first-submission-wins guard: a second submit for + the same batch (e.g. from a stale browser tab) returns False and + the caller must not resume the task again. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE chat_messages SET question_answers = ?, questions_declined = ? + WHERE message_id = ? AND question_answers IS NULL + AND (questions_declined IS NULL OR questions_declined = 0) + """, + (json.dumps(answers) if answers else None, declined, message_id), + ) + conn.commit() + return cursor.rowcount > 0 + + def get_message(self, message_id: str) -> Optional[StoredChatMessage]: + """ + Get a single message by ID. + + Args: + message_id: The message ID to look up. + + Returns: + The stored message, or None if not found. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined + FROM chat_messages + WHERE message_id = ? + """, + (message_id,), + ) + row = cursor.fetchone() + if row is None: + return None + return StoredChatMessage( + message_id=row[0], + sender=row[1], + content=row[2], + style=row[3], + timestamp=row[4], + attachments=json.loads(row[5]) if row[5] else None, + task_session_id=row[6], + options=json.loads(row[7]) if row[7] else None, + option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, + ) + def delete_message(self, message_id: str) -> bool: """ Delete a message by ID. @@ -324,7 +432,7 @@ def get_messages_before( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages WHERE timestamp < ? ORDER BY timestamp DESC @@ -345,6 +453,9 @@ def get_messages_before( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] diff --git a/craftos_integrations/__init__.py b/craftos_integrations/__init__.py index 9f3d4cce..de188768 100644 --- a/craftos_integrations/__init__.py +++ b/craftos_integrations/__init__.py @@ -90,6 +90,8 @@ async def main(): list_metadata, parse_status_accounts, send_message, + set_account_alias, + set_primary_account, status, update_config, ) @@ -141,6 +143,8 @@ async def main(): "list_connected", "list_all", "disconnect", + "set_primary_account", + "set_account_alias", "status", # Metadata + connect dispatchers "get_metadata", diff --git a/craftos_integrations/accounts.py b/craftos_integrations/accounts.py new file mode 100644 index 00000000..aa3db53f --- /dev/null +++ b/craftos_integrations/accounts.py @@ -0,0 +1,279 @@ +"""Generic multi-account primitives, shared by every integration that +supports more than one connected account (Gmail, Outlook, Slack, ...). + +An "account" here means one saved credential file for a given integration: +the bare ``.json`` is always the *primary* account (so a pre-existing +single-account install needs no migration); every other connected account +gets ``__.json`` (see +``credentials_store.account_filename``). + +Each integration supplies one thing this module doesn't know on its own: +``identity_of(cred) -> str`` — the human-identifying string pulled out of +its own credential dataclass (an email, a Slack team ID, a HubSpot hub ID, +...). Everything else here — listing, resolving a user-typed hint, placing +a freshly-authenticated credential, aliasing, promoting to primary — is +generic file manipulation that never needs to know what that string means. + +Aliases (user-given names like "work"/"personal") are stored separately in +``account_aliases.json``, keyed by ``(platform_id, identity)`` — decoupled +from every credential dataclass so adding aliasing never requires touching +one. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Callable, List, Optional, Tuple, Type, TypeVar + +from .credentials_store import ( + _credentials_dir, + account_filename, + has_credential, + list_account_files, + load_credential, + remove_credential, +) + +T = TypeVar("T") +IdentityFn = Callable[[T], str] + +_ALIASES_FILE = "account_aliases.json" + + +# ════════════════════════════════════════════════════════════════════════ +# Aliases — flat JSON dict, decoupled from any credential dataclass +# ════════════════════════════════════════════════════════════════════════ + + +def _alias_store() -> dict: + path = _credentials_dir() / _ALIASES_FILE + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _save_alias_store(data: dict) -> None: + path = _credentials_dir() / _ALIASES_FILE + path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _alias_key(platform_id: str, identity: str) -> str: + return f"{platform_id}:{identity.strip().lower()}" + + +def get_alias(platform_id: str, identity: str) -> Optional[str]: + return _alias_store().get(_alias_key(platform_id, identity)) + + +def set_alias(platform_id: str, identity: str, alias: str) -> None: + """Empty/whitespace-only ``alias`` clears any existing alias.""" + data = _alias_store() + key = _alias_key(platform_id, identity) + if alias and alias.strip(): + data[key] = alias.strip() + else: + data.pop(key, None) + _save_alias_store(data) + + +def remove_alias(platform_id: str, identity: str) -> None: + data = _alias_store() + if data.pop(_alias_key(platform_id, identity), None) is not None: + _save_alias_store(data) + + +# ════════════════════════════════════════════════════════════════════════ +# Listing + resolving +# ════════════════════════════════════════════════════════════════════════ + + +@dataclass +class Account: + identity: str # provider-native identifier (email, team id, hub id, ...) + filename: str # credential file on disk, relative to .credentials/ + alias: Optional[str] = None + is_primary: bool = False + + @property + def display(self) -> str: + return self.alias or self.identity + + +def list_accounts( + platform_id: str, stem: str, cred_class: Type[T], identity_of: IdentityFn +) -> List[Account]: + """Every connected account for this integration, primary first.""" + accounts: List[Account] = [] + primary_file = f"{stem}.json" + if has_credential(primary_file): + cred = load_credential(primary_file, cred_class) + if cred: + identity = identity_of(cred) + accounts.append( + Account(identity, primary_file, get_alias(platform_id, identity), True) + ) + for fname in list_account_files(stem): + cred = load_credential(fname, cred_class) + if cred: + identity = identity_of(cred) + accounts.append(Account(identity, fname, get_alias(platform_id, identity), False)) + return accounts + + +def resolve_account( + platform_id: str, + stem: str, + cred_class: Type[T], + identity_of: IdentityFn, + account: Optional[str], + display_name: str, +) -> Tuple[Optional[str], Optional[str]]: + """Resolve a user/agent-supplied hint to a credential filename. + + The hint may be empty (→ primary), an exact identity or alias match + (case-insensitive), or a substring unique to exactly one connected + account's identity or alias. Returns ``(filename, None)`` on success or + ``(None, error)`` — the error always lists connected accounts (by alias + where set, else identity) so the caller can self-correct without a + separate lookup. + """ + accounts = list_accounts(platform_id, stem, cred_class, identity_of) + + if not account: + if not accounts: + return None, f"{display_name}: Not connected. Connect an account first." + primary = next((a for a in accounts if a.is_primary), accounts[0]) + return primary.filename, None + + needle = account.strip().lower() + for a in accounts: + if a.identity.lower() == needle or (a.alias and a.alias.lower() == needle): + return a.filename, None + + matches = [ + a + for a in accounts + if needle in a.identity.lower() or (a.alias and needle in a.alias.lower()) + ] + if len(matches) == 1: + return matches[0].filename, None + + connected = ", ".join(a.display for a in accounts) or "none" + if not matches: + return None, f"No {display_name} account matches '{account}'. Connected: {connected}." + return None, ( + f"'{account}' matches multiple {display_name} accounts: " + f"{', '.join(a.display for a in matches)}. Use the full name or email." + ) + + +def resolve_save_target( + stem: str, cred_class, identity_of: IdentityFn, identity: str +) -> str: + """Decide which file a freshly-authenticated credential should be saved + to: update an existing account in place if its identity already matches + one on disk, fill the empty primary slot if nothing is connected yet, or + allocate a new secondary file. Every integration's login()/invite() + calls this right before ``save_credential``. + """ + primary_file = f"{stem}.json" + for fname in [primary_file, *list_account_files(stem)]: + if not has_credential(fname): + continue + cred = load_credential(fname, cred_class) + if cred and identity_of(cred).lower() == identity.lower(): + return fname + if not has_credential(primary_file): + return primary_file + return account_filename(stem, identity) + + +# ════════════════════════════════════════════════════════════════════════ +# Removing + promoting +# ════════════════════════════════════════════════════════════════════════ + + +def remove_all_accounts(stem: str) -> None: + """Delete every connected account (primary + all secondaries) for this + integration — no promotion, since nothing is left to promote into. + Used by "logout everything" flows.""" + remove_credential(f"{stem}.json") + for fname in list_account_files(stem): + remove_credential(fname) + + +def remove_account(stem: str, target_filename: str) -> bool: + """Remove one connected account. If it was the primary and another + account remains, promotes one of them into the now-empty bare file so + the integration stays connected instead of silently dropping to + "not connected" while a secondary file still exists. + """ + primary_file = f"{stem}.json" + was_primary = target_filename == primary_file + removed = remove_credential(target_filename) + if was_primary: + remaining = list_account_files(stem) + if remaining: + _fill_empty_primary(stem, remaining[0]) + return removed + + +def _fill_empty_primary(stem: str, source_filename: str) -> None: + """Move a secondary account's raw content into the (already-empty) + bare primary file, then remove the now-duplicate secondary file.""" + dir_ = _credentials_dir() + source_path = dir_ / source_filename + data = json.loads(source_path.read_text(encoding="utf-8")) + (dir_ / f"{stem}.json").write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") + remove_credential(source_filename) + + +def promote_to_primary(stem: str, target_filename: str) -> bool: + """Make ``target_filename`` the primary account by swapping its raw file + content with the bare ``.json``. + + Filenames are opaque identifiers once written — an account's real + identity always lives in the file *content* (read via ``identity_of``), + so a secondary file keeping its now-stale, mismatched name after a swap + is cosmetic only; nothing in ``resolve_account``/``list_accounts`` ever + reads meaning from a filename. Returns False if ``target_filename`` is + already the primary or doesn't exist. + """ + primary_file = f"{stem}.json" + if target_filename == primary_file or not has_credential(target_filename): + return False + dir_ = _credentials_dir() + primary_path, target_path = dir_ / primary_file, dir_ / target_filename + primary_data = ( + json.loads(primary_path.read_text(encoding="utf-8")) if primary_path.exists() else None + ) + target_data = json.loads(target_path.read_text(encoding="utf-8")) + if primary_data is not None: + target_path.write_text(json.dumps(primary_data, indent=2, default=str), encoding="utf-8") + else: + remove_credential(target_filename) + primary_path.write_text(json.dumps(target_data, indent=2, default=str), encoding="utf-8") + return True + + +# ════════════════════════════════════════════════════════════════════════ +# Structured account listing for get_integration_info (bypasses the +# fragile "- name (id)" status-string parsing for integrations that +# support aliasing/primary — see service.get_integration_info). +# ════════════════════════════════════════════════════════════════════════ + + +def accounts_to_dicts(accounts: List[Account]) -> List[dict]: + return [ + { + "id": a.identity, + "display": a.display, + "alias": a.alias, + "is_primary": a.is_primary, + } + for a in accounts + ] diff --git a/craftos_integrations/base.py b/craftos_integrations/base.py index a39a6ef1..81b466be 100644 --- a/craftos_integrations/base.py +++ b/craftos_integrations/base.py @@ -45,6 +45,11 @@ def __init__(self) -> None: self._connected = False self._listening = False self._message_callback: Optional[MessageCallback] = None + # Which connected account (email or unique fragment) this instance + # talks to; None means "primary". Set by the registry right after + # construction (see registry.get_client). Only integrations that + # support multiple accounts (currently the Google ones) read this. + self._account: Optional[str] = None @property def is_connected(self) -> bool: @@ -136,6 +141,29 @@ async def status(self) -> Tuple[bool, str]: ... async def invite(self, args: List[str]) -> Tuple[bool, str]: return False, "Invite not available for this integration. Use 'login' instead." + # ----- Optional multi-account support ----- + # Override these three on handlers whose credential storage supports + # more than one connected account (see craftos_integrations/accounts.py + # for the shared implementation each override delegates to). Handlers + # that don't override keep the default "not supported" behavior below, + # and service.get_integration_info() falls back to parsing status()'s + # text for a single account, exactly as before this existed. + + def list_accounts(self) -> Optional[List[Dict[str, Any]]]: + """Structured per-account info: ``[{id, display, alias, is_primary}, ...]``. + + Return ``None`` (the default) to signal "this integration doesn't + support multiple accounts" — callers fall back to text-parsing + ``status()``. Multi-account handlers return the real list instead. + """ + return None + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return False, "This integration does not support multiple accounts." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return False, "This integration does not support multiple accounts." + @property def subcommands(self) -> List[str]: return ["login", "logout", "status"] diff --git a/craftos_integrations/credentials_store.py b/craftos_integrations/credentials_store.py index 1929c674..932ba097 100644 --- a/craftos_integrations/credentials_store.py +++ b/craftos_integrations/credentials_store.py @@ -24,10 +24,11 @@ import json import os +import re import stat from dataclasses import asdict, fields from pathlib import Path -from typing import Optional, Type, TypeVar +from typing import List, Optional, Type, TypeVar from .config import ConfigStore from .logger import get_logger @@ -116,6 +117,43 @@ def remove_credential(filename: str) -> bool: return _remove(filename, "credential") +# ════════════════════════════════════════════════════════════════════════ +# Multi-account credential filenames +# +# The bare ``.json`` file (e.g. ``gmail.json``) always holds the +# *primary* account — untouched, so existing single-account users need no +# migration. Additional accounts get their own ``__.json`` +# file. Double underscore is deliberate: it can never collide with the +# single-underscore config pairing (``gmail_config.json``), so a glob for +# secondary account files never picks up a config file. +# ════════════════════════════════════════════════════════════════════════ + +_SANITIZE_RE = re.compile(r"[^a-z0-9]+") + + +def sanitize_account(email: str) -> str: + """Lowercase an email into a filename-safe fragment: alan@x.com -> alan_x_com.""" + return _SANITIZE_RE.sub("_", email.strip().lower()).strip("_") + + +def account_filename(stem: str, email: str) -> str: + """Secondary-account filename for ``stem`` (e.g. 'gmail') + an email.""" + return f"{stem}__{sanitize_account(email)}.json" + + +def list_account_files(stem: str) -> List[str]: + """Bare filenames of every *secondary* account file for ``stem``. + + Does not include the primary (bare ``.json``) file — callers that + want the full account list should check ``has_credential(f"{stem}.json")`` + separately (see ``_google_common.list_google_accounts``). + """ + prefix = f"{stem}__" + return sorted( + p.name for p in _credentials_dir().glob(f"{prefix}*.json") + ) + + # ════════════════════════════════════════════════════════════════════════ # Config API — same on-disk layout, different filename convention # ════════════════════════════════════════════════════════════════════════ diff --git a/craftos_integrations/integrations/_google_common.py b/craftos_integrations/integrations/_google_common.py index aca98236..f3a7ff19 100644 --- a/craftos_integrations/integrations/_google_common.py +++ b/craftos_integrations/integrations/_google_common.py @@ -39,16 +39,15 @@ import time from dataclasses import dataclass -from typing import Dict, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple from .. import ( IntegrationSpec, OAuthFlow, - has_credential, load_credential, - remove_credential, save_credential, ) +from .. import accounts as acc from ..config import ConfigStore from ..helpers import request as http_request from ..logger import get_logger @@ -138,10 +137,126 @@ def make_google_oauth(scopes: str) -> OAuthFlow: userinfo_url=GOOGLE_USERINFO_URL, scopes=f"{scopes} {USERINFO_SCOPES}".strip(), use_pkce=True, - extra_auth_params={"access_type": "offline", "prompt": "consent"}, + # select_account forces Google's account chooser every time. Without + # it, the OAuth popup silently reuses whichever Google account is + # already signed into the system browser — so "add account" would + # just re-authenticate the same primary account and overwrite it + # instead of letting the user pick a second one. + extra_auth_params={"access_type": "offline", "prompt": "consent select_account"}, ) +# ════════════════════════════════════════════════════════════════════════ +# Multi-account support — thin Google-flavored wrappers around the generic +# ``accounts.py`` layer (shared with every other multi-account integration). +# The bare ``spec.cred_file`` (e.g. ``gmail.json``) always holds the +# *primary* account — untouched, so existing single-account users need no +# migration. Additional accounts get their own ``__.json``. +# ════════════════════════════════════════════════════════════════════════ + + +def _cred_stem(spec: IntegrationSpec) -> str: + cred_file = spec.cred_file + return cred_file[:-5] if cred_file.endswith(".json") else cred_file + + +def _nice_name(spec: IntegrationSpec) -> str: + return spec.name.replace("_", " ").title() + + +def _identity(cred: "GoogleCredential") -> str: + return cred.email + + +# Aliases are shared across the whole Google family under one namespace — +# the same Google account (email) is frequently connected to several of +# Gmail/Calendar/Drive/Docs/YouTube, and a nickname set once ("work", +# "personal") should carry over to all of them rather than needing to be +# re-typed per service. Credential FILES stay separate per service (each +# is its own OAuth grant/scope); only the alias *lookup key* is shared. +_ALIAS_NAMESPACE = "google" +_LEGACY_ALIAS_PLATFORM_IDS = ( + "gmail", + "google_calendar", + "google_drive", + "google_docs", + "google_youtube", +) + + +def _migrate_legacy_alias(identity: str, current: Optional[str]) -> Optional[str]: + """One-time-per-service, idempotent migration: aliases used to be keyed + per Google service (``gmail:``, ``google_calendar:``, ...). + Sweeps every legacy key for ``identity`` and removes it; the first + legacy alias found fills the shared namespace only if it isn't already + set (``current`` is the alias already read under the shared namespace, + so a user-chosen shared alias is never overwritten by a stale legacy + one). Called on every listing, but becomes a cheap no-op once no + legacy keys remain for this identity. + """ + result = current + for legacy_platform in _LEGACY_ALIAS_PLATFORM_IDS: + legacy_alias = acc.get_alias(legacy_platform, identity) + if legacy_alias: + if result is None: + acc.set_alias(_ALIAS_NAMESPACE, identity, legacy_alias) + result = legacy_alias + acc.remove_alias(legacy_platform, identity) + return result + + +def _list_accounts_migrated(spec: IntegrationSpec): + """``accounts.list_accounts`` under the shared namespace, sweeping any + still-legacy-keyed alias for each identity found along the way.""" + accounts = acc.list_accounts(_ALIAS_NAMESPACE, _cred_stem(spec), GoogleCredential, _identity) + for a in accounts: + a.alias = _migrate_legacy_alias(a.identity, a.alias) + return accounts + + +def list_google_accounts(spec: IntegrationSpec) -> List[Tuple[str, str]]: + """``[(email, filename), ...]`` for every connected account, primary first.""" + return [(a.identity, a.filename) for a in _list_accounts_migrated(spec)] + + +def resolve_account( + spec: IntegrationSpec, account: Optional[str] +) -> Tuple[Optional[str], Optional[str]]: + """Resolve an agent-supplied account hint (email, alias, or unique + substring of either) to a credential filename. See ``accounts.resolve_account``.""" + _list_accounts_migrated(spec) # ensure aliases are migrated before resolving by alias + return acc.resolve_account( + _ALIAS_NAMESPACE, _cred_stem(spec), GoogleCredential, _identity, account, _nice_name(spec) + ) + + +def run_google_list_accounts(spec: IntegrationSpec) -> List[Dict[str, Any]]: + """Structured account list for ``IntegrationHandler.list_accounts()``.""" + return acc.accounts_to_dicts(_list_accounts_migrated(spec)) + + +async def run_google_set_primary( + spec: IntegrationSpec, display_name: str, account_id: str +) -> Tuple[bool, str]: + fname, err = resolve_account(spec, account_id) + if err: + return False, err + if not acc.promote_to_primary(_cred_stem(spec), fname): + return False, f"{account_id} is already the primary {display_name} account." + return True, f"{account_id} is now the primary {display_name} account." + + +def run_google_set_alias(spec: IntegrationSpec, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = resolve_account(spec, account_id) + if err: + return False, err + cred = load_credential(fname, GoogleCredential) + if not cred: + return False, f"Could not load credential for {account_id}." + acc.set_alias(_ALIAS_NAMESPACE, cred.email, alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {cred.email}." + + # ════════════════════════════════════════════════════════════════════════ # Shared login / logout / status helpers — called by per-service handlers # ════════════════════════════════════════════════════════════════════════ @@ -152,51 +267,75 @@ async def run_google_login( oauth: OAuthFlow, display_name: str, ) -> Tuple[bool, str]: - """Run the OAuth flow and persist the credential to the spec's file. - Each per-service handler's ``login()`` calls into this — one place to - change if Google ever changes the auth shape.""" + """Run the OAuth flow and persist the credential. + + Re-authing an already-connected email overwrites that account's file. + A brand new email fills the primary file if it's empty, otherwise gets + its own secondary file — so "Connect" on an already-connected service + is just "add another account". + """ result = await oauth.run() if "error" in result and not result.get("access_token"): return False, f"{display_name} OAuth failed: {result['error']}" info = result.get("userinfo", {}) - save_credential( - spec.cred_file, - GoogleCredential( - access_token=result["access_token"], - refresh_token=result.get("refresh_token", ""), - token_expiry=time.time() + result.get("expires_in", 3600), - client_id=ConfigStore.get_oauth("GOOGLE_CLIENT_ID"), - client_secret=ConfigStore.get_oauth("GOOGLE_CLIENT_SECRET"), - email=info.get("email", ""), - ), + email = info.get("email", "") + cred = GoogleCredential( + access_token=result["access_token"], + refresh_token=result.get("refresh_token", ""), + token_expiry=time.time() + result.get("expires_in", 3600), + client_id=ConfigStore.get_oauth("GOOGLE_CLIENT_ID"), + client_secret=ConfigStore.get_oauth("GOOGLE_CLIENT_SECRET"), + email=email, ) - return True, f"{display_name} connected as {info.get('email')}" + + target_file = acc.resolve_save_target(_cred_stem(spec), GoogleCredential, _identity, email) + save_credential(target_file, cred) + return True, f"{display_name} connected as {email}" async def run_google_logout( spec: IntegrationSpec, display_name: str, + account: Optional[str] = None, ) -> Tuple[bool, str]: - """Standard logout: just remove the credential file. Server-side - invalidation isn't necessary for Google — the refresh token expires - naturally and access tokens are short-lived.""" - if not has_credential(spec.cred_file): + """Remove one account (``account`` given) or every account (``None`` — + current "logout everything" semantics). Server-side invalidation isn't + necessary for Google — the refresh token expires naturally and access + tokens are short-lived. + + Removing the primary account while secondaries remain promotes one of + them into the bare file, so the integration stays connected rather than + silently dropping to "not connected" while a secondary file still exists. + """ + accounts = list_google_accounts(spec) + if not accounts: return False, f"No {display_name} credentials found." - remove_credential(spec.cred_file) - return True, f"Removed {display_name} credential." + + if not account: + acc.remove_all_accounts(_cred_stem(spec)) + return True, f"Removed {display_name} credential." + + fname, err = resolve_account(spec, account) + if err: + return False, err + + acc.remove_account(_cred_stem(spec), fname) + return True, f"Removed {display_name} account." async def run_google_status( spec: IntegrationSpec, display_name: str, ) -> Tuple[bool, str]: - """Standard status: connected/not-connected based on credential file.""" - if not has_credential(spec.cred_file): + """Connected/not-connected + one ``- email (email)`` line per connected + account (parsed by ``service.parse_status_accounts``).""" + accounts = list_google_accounts(spec) + if not accounts: return True, f"{display_name}: Not connected" - cred = load_credential(spec.cred_file, GoogleCredential) - email = cred.email if cred else "unknown" - return True, f"{display_name}: Connected\n - {email}" + lines = [f"{display_name}: Connected"] + lines.extend(f" - {email} ({email})" for email, _ in accounts) + return True, "\n".join(lines) # ════════════════════════════════════════════════════════════════════════ @@ -221,13 +360,29 @@ class GmailClient(BasePlatformClient, GoogleApiClientMixin): spec: IntegrationSpec # subclass provides this _cred: Optional[GoogleCredential] # subclass declares in __init__ + # Which connected account this instance talks to (None = primary). Set + # externally by registry.get_client(platform_id, account) — see base.py. + _account: Optional[str] + _cred_file: Optional[str] = None # resolved filename, cached alongside _cred def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected for this service. + + Deliberately does not resolve ``self._account`` here: an ambiguous or + not-found account hint shouldn't be reported as "not connected" (which + would hide the precise, self-correcting error). Callers that pass a + specific ``account`` get that detail from ``_load()`` when the actual + method call runs and raises. + """ + return bool(list_google_accounts(self.spec)) def _load(self) -> GoogleCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, GoogleCredential) + fname, err = resolve_account(self.spec, getattr(self, "_account", None)) + if err: + raise RuntimeError(err) + self._cred_file = fname + self._cred = load_credential(fname, GoogleCredential) if self._cred is None: raise RuntimeError( f"No {self.spec.name} credentials. Connect the integration first." @@ -262,7 +417,7 @@ def refresh_access_token(self) -> Optional[str]: data = result["result"] cred.access_token = data["access_token"] cred.token_expiry = time.time() + data.get("expires_in", 3600) - 60 - save_credential(self.spec.cred_file, cred) + save_credential(self._cred_file or self.spec.cred_file, cred) self._cred = cred return cred.access_token @@ -294,4 +449,9 @@ def _auth_header(self) -> Dict[str, str]: "run_google_login", "run_google_logout", "run_google_status", + "run_google_list_accounts", + "run_google_set_primary", + "run_google_set_alias", + "list_google_accounts", + "resolve_account", ] diff --git a/craftos_integrations/integrations/gmail/INTEGRATION.md b/craftos_integrations/integrations/gmail/INTEGRATION.md index 691e8522..e576a9d8 100644 --- a/craftos_integrations/integrations/gmail/INTEGRATION.md +++ b/craftos_integrations/integrations/gmail/INTEGRATION.md @@ -5,7 +5,8 @@ Send and read mail from the user's connected Google account. Part of the Google ## Essentials - **The integration knows the user's own email address** (`cred.email`). NEVER ask the user for it. Read the connected credential or call `check_integration_status("google")` if you need it. -- **`From` is always the connected account.** You cannot spoof sender on send. +- **Multiple Gmail accounts may be connected**, each with an optional nickname ("work", "personal"). Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my work Gmail", "the school one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts; relay that to the user instead of guessing. +- **`From` is always the account the call resolved to.** You cannot spoof a different sender within one account. - **Self-emails are auto-filtered on incoming events** — the agent's own outgoing mail doesn't loop back as new mail. - **Identity format:** plain email-address strings (e.g. `alice@example.com`). Multiple recipients: depends on action; read the schema. - **Message IDs are Gmail-opaque strings.** Don't construct them; always pull from `list_gmail` / search results. diff --git a/craftos_integrations/integrations/gmail/__init__.py b/craftos_integrations/integrations/gmail/__init__.py index 6cff823c..858f4dd0 100644 --- a/craftos_integrations/integrations/gmail/__init__.py +++ b/craftos_integrations/integrations/gmail/__init__.py @@ -43,8 +43,11 @@ GoogleApiClientMixin, GoogleCredential, make_google_oauth, + run_google_list_accounts, run_google_login, run_google_logout, + run_google_set_alias, + run_google_set_primary, run_google_status, ) @@ -112,11 +115,20 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return await run_google_login(self.spec, self.oauth, "Gmail") async def logout(self, args: List[str]) -> Tuple[bool, str]: - return await run_google_logout(self.spec, "Gmail") + return await run_google_logout(self.spec, "Gmail", args[0] if args else None) async def status(self) -> Tuple[bool, str]: return await run_google_status(self.spec, "Gmail") + def list_accounts(self): + return run_google_list_accounts(self.spec) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return await run_google_set_primary(self.spec, "Gmail", account_id) + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return run_google_set_alias(self.spec, account_id, alias) + # ----------------------------------------------------------------- # Client - Gmail listener + REST methods diff --git a/craftos_integrations/integrations/google_calendar/INTEGRATION.md b/craftos_integrations/integrations/google_calendar/INTEGRATION.md index f4440020..5af36e8b 100644 --- a/craftos_integrations/integrations/google_calendar/INTEGRATION.md +++ b/craftos_integrations/integrations/google_calendar/INTEGRATION.md @@ -10,4 +10,5 @@ Schedule events, check free/busy, and create Google Meet links on the user's Goo - **Times are ISO 8601 with timezone** (e.g. `2026-05-20T09:00:00-04:00` or `...Z`). The integration knows the user's email (`cred.email`) but NOT their default timezone — if the user gives a bare time ("3pm"), the router must establish the timezone first. - **Recurring events expand on read.** `list_events` returns `singleEvents=true`-expanded instances, each with its own `id`. Deleting one instance does not affect the series. - **Meet links require `with_video_meeting=True`** on create (or a `conferenceData.createRequest` block). The returned `meetLink` is the share URL; don't construct meeting URLs by hand. -- **Session-level facts:** `cred.email` is the connected account. Never ask the user for "your email" to invite themselves. +- **Multiple Google accounts may be connected**, each with an optional nickname ("work", "personal") — shared across Gmail/Calendar/Docs/Drive/YouTube, so a nickname set once applies to all of them. Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my school calendar", "the work one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts — relay that to the user instead of guessing. +- **Session-level facts:** `cred.email` is the *primary* connected account (unless `account` is passed). Never ask the user for "your email" to invite themselves. diff --git a/craftos_integrations/integrations/google_calendar/__init__.py b/craftos_integrations/integrations/google_calendar/__init__.py index 1e88e613..f4a42cf9 100644 --- a/craftos_integrations/integrations/google_calendar/__init__.py +++ b/craftos_integrations/integrations/google_calendar/__init__.py @@ -28,8 +28,11 @@ GoogleApiClientMixin, GoogleCredential, make_google_oauth, + run_google_list_accounts, run_google_login, run_google_logout, + run_google_set_alias, + run_google_set_primary, run_google_status, ) @@ -66,11 +69,20 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return await run_google_login(self.spec, self.oauth, "Google Calendar") async def logout(self, args: List[str]) -> Tuple[bool, str]: - return await run_google_logout(self.spec, "Google Calendar") + return await run_google_logout(self.spec, "Google Calendar", args[0] if args else None) async def status(self) -> Tuple[bool, str]: return await run_google_status(self.spec, "Google Calendar") + def list_accounts(self): + return run_google_list_accounts(self.spec) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return await run_google_set_primary(self.spec, "Google Calendar", account_id) + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return run_google_set_alias(self.spec, account_id, alias) + # ----------------------------------------------------------------- # Client - Calendar REST methods (no listener; Calendar isn't push-based) diff --git a/craftos_integrations/integrations/google_docs/INTEGRATION.md b/craftos_integrations/integrations/google_docs/INTEGRATION.md index a8108bdc..735b1108 100644 --- a/craftos_integrations/integrations/google_docs/INTEGRATION.md +++ b/craftos_integrations/integrations/google_docs/INTEGRATION.md @@ -9,5 +9,6 @@ Create, read, edit, and search Google Docs on the user's Drive. Part of the Goog - **`append_to_google_doc` is not idempotent.** It reads the current end-index, then inserts. If the append errored but actually succeeded server-side, retrying duplicates the text. Verify with `get_google_doc_text` before retrying. - **`get_google_doc_text` flattens body text only.** Tables, images, and embedded objects are dropped. For structured reads use `get_google_doc` and walk the returned content tree. - **`replace_google_doc_text` is `replaceAllText`** — every occurrence in the body is swapped at once, with no preview. Confirm scope with the user before broad replacements. -- **Session-level facts:** `cred.email` is the connected Google account. Never ask the user for it. +- **Multiple Google accounts may be connected**, each with an optional nickname ("work", "personal") — shared across Gmail/Calendar/Docs/Drive/YouTube, so a nickname set once applies to all of them. Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my work docs", "the school one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts — relay that to the user instead of guessing. +- **Session-level facts:** `cred.email` is the *primary* connected Google account (unless `account` is passed). Never ask the user for it. - **Scope warning:** the integration uses the broad `auth/drive` scope (not the narrower `drive.file`) so it can see docs the user owns even when not created by the integration. This is why the OAuth consent screen may warn the user about an "unverified app." diff --git a/craftos_integrations/integrations/google_docs/__init__.py b/craftos_integrations/integrations/google_docs/__init__.py index 29665e18..2399ddff 100644 --- a/craftos_integrations/integrations/google_docs/__init__.py +++ b/craftos_integrations/integrations/google_docs/__init__.py @@ -35,8 +35,11 @@ GoogleApiClientMixin, GoogleCredential, make_google_oauth, + run_google_list_accounts, run_google_login, run_google_logout, + run_google_set_alias, + run_google_set_primary, run_google_status, ) @@ -82,11 +85,20 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return await run_google_login(self.spec, self.oauth, "Google Docs") async def logout(self, args: List[str]) -> Tuple[bool, str]: - return await run_google_logout(self.spec, "Google Docs") + return await run_google_logout(self.spec, "Google Docs", args[0] if args else None) async def status(self) -> Tuple[bool, str]: return await run_google_status(self.spec, "Google Docs") + def list_accounts(self): + return run_google_list_accounts(self.spec) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return await run_google_set_primary(self.spec, "Google Docs", account_id) + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return run_google_set_alias(self.spec, account_id, alias) + # ----------------------------------------------------------------- # Client - Docs REST methods (no listener) diff --git a/craftos_integrations/integrations/google_drive/INTEGRATION.md b/craftos_integrations/integrations/google_drive/INTEGRATION.md index 70520f27..34e03e2c 100644 --- a/craftos_integrations/integrations/google_drive/INTEGRATION.md +++ b/craftos_integrations/integrations/google_drive/INTEGRATION.md @@ -10,4 +10,5 @@ List, search, move, share, and delete files and folders in the user's Drive. Par - **Folders are files with `mimeType: "application/vnd.google-apps.folder"`.** Filtering by mimeType is the canonical way to separate them in search results. - **Sharing requires an email address, not a name or handle.** `share_drive_file` takes `emailAddress` + a role enum (`reader`, `commenter`, `writer`, `owner` — case-sensitive). Google's permission sync can lag a few seconds — don't assume the recipient sees it instantly. - **Move = re-parent.** `move_drive_file` adds new parent IDs and removes old ones in a single call. To move within Drive there's no "rename path"; the file just changes its `parents` array. -- **Session-level facts:** `cred.email` is the connected account. Never ask the user for it. +- **Multiple Google accounts may be connected**, each with an optional nickname ("work", "personal") — shared across Gmail/Calendar/Docs/Drive/YouTube, so a nickname set once applies to all of them. Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my work Drive", "the school one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts — relay that to the user instead of guessing. +- **Session-level facts:** `cred.email` is the *primary* connected account (unless `account` is passed). Never ask the user for it. diff --git a/craftos_integrations/integrations/google_drive/__init__.py b/craftos_integrations/integrations/google_drive/__init__.py index 2fb017d9..0ff56ea7 100644 --- a/craftos_integrations/integrations/google_drive/__init__.py +++ b/craftos_integrations/integrations/google_drive/__init__.py @@ -28,8 +28,11 @@ GoogleApiClientMixin, GoogleCredential, make_google_oauth, + run_google_list_accounts, run_google_login, run_google_logout, + run_google_set_alias, + run_google_set_primary, run_google_status, ) @@ -66,11 +69,20 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return await run_google_login(self.spec, self.oauth, "Google Drive") async def logout(self, args: List[str]) -> Tuple[bool, str]: - return await run_google_logout(self.spec, "Google Drive") + return await run_google_logout(self.spec, "Google Drive", args[0] if args else None) async def status(self) -> Tuple[bool, str]: return await run_google_status(self.spec, "Google Drive") + def list_accounts(self): + return run_google_list_accounts(self.spec) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return await run_google_set_primary(self.spec, "Google Drive", account_id) + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return run_google_set_alias(self.spec, account_id, alias) + # ----------------------------------------------------------------- # Client - Drive REST methods (no listener; Drive isn't push-based) diff --git a/craftos_integrations/integrations/google_youtube/INTEGRATION.md b/craftos_integrations/integrations/google_youtube/INTEGRATION.md index 11e2cac1..44f00ff0 100644 --- a/craftos_integrations/integrations/google_youtube/INTEGRATION.md +++ b/craftos_integrations/integrations/google_youtube/INTEGRATION.md @@ -13,4 +13,5 @@ Search YouTube, manage the user's subscriptions and playlists, post comments, an - **`unsubscribe_from_youtube_channel` takes the SUBSCRIPTION ID,** not the channel ID. Get it from `list_my_youtube_subscriptions`. Passing a channel ID fails server-side. - **`rate_youtube_video` enum is `like` | `dislike` | `none`.** `"none"` is how you clear an existing rating — not deletion. - **Comments are top-level only.** `post_youtube_comment` does not support replies-to-comments. `get_youtube_video_comments` returns top-level comments most-recent first; thread expansion is not exposed. -- **Session-level facts:** `cred.email` is the connected Google account; the user's own channel info is one `get_my_youtube_channel` call away — don't ask the user for their channel name or subscriber count. +- **Multiple Google accounts may be connected**, each with an optional nickname ("work", "personal") — shared across Gmail/Calendar/Docs/Drive/YouTube, so a nickname set once applies to all of them. Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my work channel", "the school one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts — relay that to the user instead of guessing. +- **Session-level facts:** `cred.email` is the *primary* connected Google account; the user's own channel info is one `get_my_youtube_channel` call away — don't ask the user for their channel name or subscriber count. diff --git a/craftos_integrations/integrations/google_youtube/__init__.py b/craftos_integrations/integrations/google_youtube/__init__.py index e2006fbb..97282954 100644 --- a/craftos_integrations/integrations/google_youtube/__init__.py +++ b/craftos_integrations/integrations/google_youtube/__init__.py @@ -32,8 +32,11 @@ GoogleApiClientMixin, GoogleCredential, make_google_oauth, + run_google_list_accounts, run_google_login, run_google_logout, + run_google_set_alias, + run_google_set_primary, run_google_status, ) @@ -70,11 +73,20 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return await run_google_login(self.spec, self.oauth, "YouTube") async def logout(self, args: List[str]) -> Tuple[bool, str]: - return await run_google_logout(self.spec, "YouTube") + return await run_google_logout(self.spec, "YouTube", args[0] if args else None) async def status(self) -> Tuple[bool, str]: return await run_google_status(self.spec, "YouTube") + def list_accounts(self): + return run_google_list_accounts(self.spec) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + return await run_google_set_primary(self.spec, "YouTube", account_id) + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + return run_google_set_alias(self.spec, account_id, alias) + # ----------------------------------------------------------------- # Client - YouTube REST methods (no listener) diff --git a/craftos_integrations/integrations/hubspot/INTEGRATION.md b/craftos_integrations/integrations/hubspot/INTEGRATION.md index 7e9c7f34..069a7b5e 100644 --- a/craftos_integrations/integrations/hubspot/INTEGRATION.md +++ b/craftos_integrations/integrations/hubspot/INTEGRATION.md @@ -12,6 +12,7 @@ REST integration against `api.hubapi.com`. CRM (contacts/companies/deals/tickets - **Move a deal/ticket via the stage property.** Don't look for a `move_stage` endpoint — update `dealstage` (deals) or `hs_pipeline_stage` (tickets) to the target stage ID. The `move_hubspot_deal_stage` / `close_hubspot_ticket` actions wrap this. - **Engagement associations.** Tasks/notes/calls/emails/meetings need an associated contact/company/deal/ticket to be useful. The `associated_object_type` + `associated_object_id` args on the create-engagement actions wire this up via the default-association API. Passing only one without the other is silently no-op. - **Auth: Bearer token works for both Private App and OAuth.** The client doesn't branch — `Authorization: Bearer ` is identical for both. The `auth_kind` field on the credential is purely informational (shown in `/hubspot status`). +- **Multiple HubSpot portals may be connected**, each with an optional nickname. Every action takes an optional `account` param (hub domain, hub ID, unique substring, or nickname). **If the user's message qualifies which portal at all** ("the client portal", "my main HubSpot"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing connected portals; relay that instead of guessing. - **Token refresh is automatic for OAuth credentials.** Access tokens expire after ~30 minutes; the client checks `token_expiry` on every request via `_get_valid_access_token` and exchanges the stored `refresh_token` for a fresh access token (60s before actual expiry, to absorb clock skew + in-flight calls). Refresh requires `HUBSPOT_SHARED_CLIENT_ID` + `HUBSPOT_SHARED_CLIENT_SECRET` to be configured — same credentials used at initial OAuth. If a refresh fails (refresh_token revoked, network error), the client logs and returns the stale token; the next API call will surface HubSpot's 401 to the user, who should re-run `/hubspot invite`. Private App tokens (`auth_kind == "token"`) skip the refresh path entirely — they don't expire. - **Rate limits are per-portal.** Standard tier: 100 requests / 10 seconds / portal across all integrations. Enterprise: 150 / 10s. Burst capacity exists but isn't documented. 429 responses include `Retry-After` — respect it. - **OAuth flow requires a registered HubSpot app.** Set `HUBSPOT_SHARED_CLIENT_ID` + `HUBSPOT_SHARED_CLIENT_SECRET` env vars (or wire them through `agent_core.embedded_credentials` for shipping builds) before the `invite` flow works. Without them, `invite` returns `{"error": "OAuth not configured: missing HUBSPOT_SHARED_CLIENT_ID"}` — the Private App token path (`login`) still works unchanged. diff --git a/craftos_integrations/integrations/hubspot/__init__.py b/craftos_integrations/integrations/hubspot/__init__.py index d08d725d..8665f740 100644 --- a/craftos_integrations/integrations/hubspot/__init__.py +++ b/craftos_integrations/integrations/hubspot/__init__.py @@ -25,13 +25,12 @@ IntegrationHandler, IntegrationSpec, OAuthFlow, - has_credential, load_credential, register_client, register_handler, - remove_credential, save_credential, ) +from ... import accounts as acc from ...config import ConfigStore from ...helpers import Result, arequest from ...helpers import request as http_request @@ -101,6 +100,26 @@ class HubSpotConfig: platform_id="hubspot", ) +# Multi-account: the bare "hubspot.json" is always the primary portal (no +# migration needed for existing single-account installs); additional +# portals get "hubspot__.json" — see accounts.py. hub_id (the +# portal ID) is used as identity rather than hub_domain since it's always +# present and immutable; hub_domain is a better display label but can be +# blank for some Private App tokens. +# +# Note: HubSpot's OAuth authorize screen has no documented +# prompt=select_account equivalent — if the user is already authorized to +# only one portal in their browser session, "add account" may silently +# re-authorize that same portal (matched by hub_id, so it updates in place +# rather than corrupting anything). Connecting a genuinely different +# portal may require the user to already be logged into that portal's +# HubSpot account in their browser first. +_STEM = "hubspot" + + +def _identity(cred: "HubSpotCredential") -> str: + return cred.hub_id + # ----------------------------------------------------------------- # Handler — auth flows @@ -191,18 +210,16 @@ async def invite(self, args: List[str]) -> Tuple[bool, str]: import time as _time - save_credential( - self.spec.cred_file, - HubSpotCredential( - access_token=access_token, - refresh_token=refresh_token, - token_expiry=_time.time() + expires_in if expires_in else 0.0, - hub_id=str(meta.get("hub_id", "")), - hub_domain=meta.get("hub_domain", ""), - user_email=meta.get("user", ""), - auth_kind="oauth", - ), + cred = HubSpotCredential( + access_token=access_token, + refresh_token=refresh_token, + token_expiry=_time.time() + expires_in if expires_in else 0.0, + hub_id=str(meta.get("hub_id", "")), + hub_domain=meta.get("hub_domain", ""), + user_email=meta.get("user", ""), + auth_kind="oauth", ) + self._save_account(cred) label = meta.get("hub_domain") or meta.get("hub_id") or "HubSpot" return True, f"HubSpot connected via OAuth: {label}" @@ -228,42 +245,91 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return False, f"HubSpot auth failed: {ping['error']}" meta = ping.get("result") or {} - save_credential( - self.spec.cred_file, - HubSpotCredential( - access_token=token, - hub_id=str(meta.get("portalId", "")), - hub_domain=meta.get("uiDomain", ""), - auth_kind="token", - ), + cred = HubSpotCredential( + access_token=token, + hub_id=str(meta.get("portalId", "")), + hub_domain=meta.get("uiDomain", ""), + auth_kind="token", ) + self._save_account(cred) label = meta.get("uiDomain") or meta.get("portalId") or "HubSpot" return True, f"HubSpot connected: {label}" + def _save_account(self, cred: "HubSpotCredential") -> None: + target_file = acc.resolve_save_target(_STEM, HubSpotCredential, _identity, cred.hub_id) + save_credential(target_file, cred) + # Pre-fill a friendly alias from the portal domain so a freshly + # connected portal doesn't display as a bare numeric hub_id. + if cred.hub_domain and not acc.get_alias(HUBSPOT.platform_id, cred.hub_id): + acc.set_alias(HUBSPOT.platform_id, cred.hub_id, cred.hub_domain) + async def logout(self, args: List[str]) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + account = args[0] if args else None + accounts = acc.list_accounts(self.spec.platform_id, _STEM, HubSpotCredential, _identity) + if not accounts: return False, "No HubSpot credentials found." - try: - from ...manager import get_external_comms_manager - manager = get_external_comms_manager() - if manager: - await manager.stop_platform(self.spec.platform_id) - except Exception: - pass - remove_credential(self.spec.cred_file) - return True, "Removed HubSpot credential." + if not account: + acc.remove_all_accounts(_STEM) + remaining = [] + else: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, HubSpotCredential, _identity, account, "HubSpot" + ) + if err: + return False, err + acc.remove_account(_STEM, fname) + remaining = acc.list_accounts(self.spec.platform_id, _STEM, HubSpotCredential, _identity) + + if not remaining: + try: + from ...manager import get_external_comms_manager + + manager = get_external_comms_manager() + if manager: + await manager.stop_platform(self.spec.platform_id) + except Exception: + pass + + return True, "Removed HubSpot credential." if not account else "Removed HubSpot account." async def status(self) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, HubSpotCredential, _identity) + if not accounts: return True, "HubSpot: Not connected" - cred = load_credential(self.spec.cred_file, HubSpotCredential) + lines = ["HubSpot: Connected"] + for a in accounts: + cred = load_credential(a.filename, HubSpotCredential) + via = "OAuth" if cred and cred.auth_kind == "oauth" else "Private App token" + email = f" ({cred.user_email})" if cred and cred.user_email else "" + lines.append(f" - {a.display}{email} via {via} ({a.identity})") + return True, "\n".join(lines) + + def list_accounts(self): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, HubSpotCredential, _identity) + return acc.accounts_to_dicts(accounts) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, HubSpotCredential, _identity, account_id, "HubSpot" + ) + if err: + return False, err + if not acc.promote_to_primary(_STEM, fname): + return False, f"{account_id} is already the primary HubSpot account." + return True, f"{account_id} is now the primary HubSpot account." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, HubSpotCredential, _identity, account_id, "HubSpot" + ) + if err: + return False, err + cred = load_credential(fname, HubSpotCredential) if not cred: - return True, "HubSpot: Not connected" - label = cred.hub_domain or cred.hub_id or "unknown portal" - via = "OAuth" if cred.auth_kind == "oauth" else "Private App token" - email = f" ({cred.user_email})" if cred.user_email else "" - return True, f"HubSpot: Connected\n - {label}{email} via {via}" + return False, f"Could not load credential for {account_id}." + acc.set_alias(self.spec.platform_id, cred.hub_id, alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {cred.hub_id}." # ----------------------------------------------------------------- @@ -292,13 +358,27 @@ class HubSpotClient(BasePlatformClient): def __init__(self) -> None: super().__init__() self._cred: Optional[HubSpotCredential] = None + self._cred_file: Optional[str] = None def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected. Deliberately does not resolve + self._account here — see GoogleApiClientMixin.has_credentials for why.""" + return bool(acc.list_accounts(self.spec.platform_id, _STEM, HubSpotCredential, _identity)) def _load(self) -> HubSpotCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, HubSpotCredential) + fname, err = acc.resolve_account( + self.spec.platform_id, + _STEM, + HubSpotCredential, + _identity, + getattr(self, "_account", None), + "HubSpot", + ) + if err: + raise RuntimeError(err) + self._cred_file = fname + self._cred = load_credential(fname, HubSpotCredential) if self._cred is None: raise RuntimeError("No HubSpot credentials. Use /hubspot login first.") return self._cred @@ -373,7 +453,7 @@ def _refresh_access_token(self) -> Optional[str]: cred.refresh_token = data.get("refresh_token") or cred.refresh_token # Refresh 60s before actual expiry to avoid races with in-flight calls. cred.token_expiry = time.time() + data.get("expires_in", 1800) - 60 - save_credential(self.spec.cred_file, cred) + save_credential(self._cred_file or self.spec.cred_file, cred) logger.info("[HUBSPOT] Access token refreshed.") return new_token diff --git a/craftos_integrations/integrations/linkedin/INTEGRATION.md b/craftos_integrations/integrations/linkedin/INTEGRATION.md index 65f85cb1..a467a237 100644 --- a/craftos_integrations/integrations/linkedin/INTEGRATION.md +++ b/craftos_integrations/integrations/linkedin/INTEGRATION.md @@ -5,7 +5,8 @@ Official LinkedIn API integration. Profile, posts, search, organisation analytic ## Essentials - **Recipient is a LinkedIn URN, not a username or numeric ID.** Format: `urn:li:person:`. The integration handles URL-encoding internally — pass the raw URN string verbatim. -- **The integration knows the user's own `linkedin_id`** (the `sub` claim from the OAuth userinfo response). NEVER ask the user for it; the integration auto-constructs `urn:li:person:` for self-references. +- **The integration knows the user's own `linkedin_id`** (the `sub` claim from the OAuth userinfo response, for the resolved account). NEVER ask the user for it; the integration auto-constructs `urn:li:person:` for self-references. +- **Multiple LinkedIn accounts may be connected**, each with an optional nickname. Every action — including posting/social ones (`create_linkedin_post`, `like`/`unlike_linkedin_post`, `comment_on_linkedin_post`, `reshare_linkedin_post`, `send_linkedin_message`, `follow`/`unfollow_linkedin_organization`) — takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("post from my work LinkedIn", "the personal one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts; relay that instead of guessing. - **Many endpoints need elevated API access.** Search-people, search-jobs, and messaging often return a `"note"` field warning that LinkedIn restricts access to non-partner apps. Surface that note to the user — they likely need a different API tier; retrying won't help. - **Posts have a 3000-character limit.** Truncate or split before calling `create_linkedin_post`; don't let LinkedIn truncate silently. - **Access tokens last ~60 days** with automatic refresh. A 401 usually means revocation (the user disconnected the app), not expiry — direct them to reconnect. diff --git a/craftos_integrations/integrations/linkedin/__init__.py b/craftos_integrations/integrations/linkedin/__init__.py index ae9f62f8..f5384d44 100644 --- a/craftos_integrations/integrations/linkedin/__init__.py +++ b/craftos_integrations/integrations/linkedin/__init__.py @@ -13,13 +13,12 @@ IntegrationHandler, IntegrationSpec, OAuthFlow, - has_credential, load_credential, register_client, register_handler, - remove_credential, save_credential, ) +from ... import accounts as acc from ...config import ConfigStore from ...helpers import Result, request as http_request from ...logger import get_logger @@ -29,6 +28,18 @@ LINKEDIN_API_BASE = "https://api.linkedin.com/v2" LINKEDIN_OAUTH_BASE = "https://www.linkedin.com/oauth/v2" +# Multi-account: the bare "linkedin.json" is always the primary account (no +# migration needed for existing single-account installs); additional +# accounts get "linkedin__.json" — see craftos_integrations/accounts.py. +_STEM = "linkedin" + + +def _identity(cred: "LinkedInCredential") -> str: + # email is the human-friendly identity when available; older credential + # files (saved before this field existed) fall back to the opaque + # OpenID `sub` claim so they keep resolving correctly. + return cred.email or cred.linkedin_id + @dataclass class LinkedInCredential: @@ -39,6 +50,7 @@ class LinkedInCredential: client_secret: str = "" linkedin_id: str = "" user_id: str = "" + email: str = "" LINKEDIN = IntegrationSpec( @@ -80,6 +92,12 @@ class LinkedInHandler(IntegrationHandler): token_url="https://www.linkedin.com/oauth/v2/accessToken", userinfo_url="https://api.linkedin.com/v2/userinfo", scopes="openid profile email w_member_social", + # prompt=login forces LinkedIn's login form every time instead of + # silently reusing an already-signed-in browser session — the + # closest LinkedIn equivalent to Google's prompt=select_account. + # Without it, "add account" would just re-authenticate the same + # member and overwrite it instead of letting the user switch. + extra_auth_params={"prompt": "login"}, ) async def login(self, args: List[str]) -> Tuple[bool, str]: @@ -88,32 +106,75 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return False, f"LinkedIn OAuth failed: {result['error']}" info = result.get("userinfo", {}) + email = info.get("email", "") + linkedin_id = info.get("sub", "") + identity = email or linkedin_id + + target_file = acc.resolve_save_target(_STEM, LinkedInCredential, _identity, identity) save_credential( - self.spec.cred_file, + target_file, LinkedInCredential( access_token=result["access_token"], refresh_token=result.get("refresh_token", ""), token_expiry=time.time() + result.get("expires_in", 3600), client_id=ConfigStore.get_oauth("LINKEDIN_CLIENT_ID"), client_secret=ConfigStore.get_oauth("LINKEDIN_CLIENT_SECRET"), - linkedin_id=info.get("sub", ""), - user_id=info.get("sub", ""), + linkedin_id=linkedin_id, + user_id=linkedin_id, + email=email, ), ) - return True, f"LinkedIn connected as {info.get('name')} ({info.get('email')})" + return True, f"LinkedIn connected as {info.get('name')} ({email})" async def logout(self, args: List[str]) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + account = args[0] if args else None + accounts = acc.list_accounts(self.spec.platform_id, _STEM, LinkedInCredential, _identity) + if not accounts: return False, "No LinkedIn credentials found." - remove_credential(self.spec.cred_file) - return True, "Removed LinkedIn credential." + if not account: + acc.remove_all_accounts(_STEM) + return True, "Removed LinkedIn credential." + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, LinkedInCredential, _identity, account, "LinkedIn" + ) + if err: + return False, err + acc.remove_account(_STEM, fname) + return True, "Removed LinkedIn account." async def status(self) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, LinkedInCredential, _identity) + if not accounts: return True, "LinkedIn: Not connected" - cred = load_credential(self.spec.cred_file, LinkedInCredential) - lid = cred.linkedin_id if cred else "unknown" - return True, f"LinkedIn: Connected\n - {lid}" + lines = ["LinkedIn: Connected"] + lines.extend(f" - {a.identity} ({a.identity})" for a in accounts) + return True, "\n".join(lines) + + def list_accounts(self): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, LinkedInCredential, _identity) + return acc.accounts_to_dicts(accounts) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, LinkedInCredential, _identity, account_id, "LinkedIn" + ) + if err: + return False, err + if not acc.promote_to_primary(_STEM, fname): + return False, f"{account_id} is already the primary LinkedIn account." + return True, f"{account_id} is now the primary LinkedIn account." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, LinkedInCredential, _identity, account_id, "LinkedIn" + ) + if err: + return False, err + cred = load_credential(fname, LinkedInCredential) + if not cred: + return False, f"Could not load credential for {account_id}." + acc.set_alias(self.spec.platform_id, _identity(cred), alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {_identity(cred)}." # ----------------------------------------------------------------- @@ -129,13 +190,27 @@ class LinkedInClient(BasePlatformClient): def __init__(self): super().__init__() self._cred: Optional[LinkedInCredential] = None + self._cred_file: Optional[str] = None def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected. Deliberately does not resolve + self._account here — see GoogleApiClientMixin.has_credentials for why.""" + return bool(acc.list_accounts(self.spec.platform_id, _STEM, LinkedInCredential, _identity)) def _load(self) -> LinkedInCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, LinkedInCredential) + fname, err = acc.resolve_account( + self.spec.platform_id, + _STEM, + LinkedInCredential, + _identity, + getattr(self, "_account", None), + "LinkedIn", + ) + if err: + raise RuntimeError(err) + self._cred_file = fname + self._cred = load_credential(fname, LinkedInCredential) if self._cred is None: raise RuntimeError("No LinkedIn credentials. Use /linkedin login first.") return self._cred @@ -190,7 +265,7 @@ def refresh_access_token(self) -> Optional[str]: data = result["result"] cred.access_token = data["access_token"] cred.token_expiry = time.time() + data.get("expires_in", 5184000) - 86400 - save_credential(self.spec.cred_file, cred) + save_credential(self._cred_file or self.spec.cred_file, cred) self._cred = cred return cred.access_token diff --git a/craftos_integrations/integrations/notion/INTEGRATION.md b/craftos_integrations/integrations/notion/INTEGRATION.md index faea411e..7f5ec005 100644 --- a/craftos_integrations/integrations/notion/INTEGRATION.md +++ b/craftos_integrations/integrations/notion/INTEGRATION.md @@ -10,3 +10,4 @@ REST integration via an internal integration token (or OAuth). Query and edit pa - **Page content is Notion block JSON, not markdown.** `append_notion_page_content` expects rich Notion block objects (paragraph, heading_1, bulleted_list_item, etc.) — passing markdown silently fails. If the user gives markdown, the router must convert. - **Database properties are typed nested objects, not flat strings.** Before `update_notion_page` on a database row, call `get_notion_database_schema` to learn each property's type (title vs rich_text vs select vs date), then build the correctly-shaped object. - **An integration only sees pages it's been explicitly shared with.** "Notion can't find the page" usually means the user hasn't invited the integration to that page — direct them to the page's "..." → "Add connections" menu, not a retry. +- **Multiple Notion workspaces may be connected**, each with an optional nickname. Every action takes an optional `account` param (workspace name, unique substring, or nickname). **If the user's message qualifies which workspace at all** ("the work Notion", "my personal one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing connected workspaces; relay that instead of guessing. diff --git a/craftos_integrations/integrations/notion/__init__.py b/craftos_integrations/integrations/notion/__init__.py index a5206d45..c1f89136 100644 --- a/craftos_integrations/integrations/notion/__init__.py +++ b/craftos_integrations/integrations/notion/__init__.py @@ -12,13 +12,12 @@ IntegrationHandler, IntegrationSpec, OAuthFlow, - has_credential, load_credential, register_client, register_handler, - remove_credential, save_credential, ) +from ... import accounts as acc from ...helpers import request as http_request from ...logger import get_logger @@ -57,6 +56,14 @@ def _notion_call( @dataclass class NotionCredential: token: str = "" + workspace_name: str = "" + # Stable per-integration identifier (OAuth: bot_id from the token + # response; token login: the bot user's own `id` from /users/me). + # workspace_name is a mutable human label Notion can omit/duplicate, so + # it's not safe as the sole resolver key on its own — bot_id guarantees + # two genuinely distinct workspaces never collide into "identical + # identity" and silently overwrite one another. + bot_id: str = "" NOTION = IntegrationSpec( @@ -66,6 +73,23 @@ class NotionCredential: platform_id="notion", ) +# Multi-account: the bare "notion.json" is always the primary account (no +# migration needed for existing single-account installs); additional +# workspaces get "notion__.json" — see accounts.py. +# Notion's OAuth authorize screen always shows a workspace picker/ +# confirmation on every run (unlike Google, it never silently reuses a +# prior grant), so no prompt=select_account-equivalent is needed here. +_STEM = "notion" + + +def _identity(cred: "NotionCredential") -> str: + # bot_id is guaranteed unique per connected workspace; workspace_name + # alone is not (two workspaces can share a display name, which would + # make resolve_save_target wrongly treat them as "the same account" and + # overwrite one). Only credentials saved before bot_id existed fall + # back to workspace_name. + return cred.bot_id or cred.workspace_name or "default" + # ----------------------------------------------------------------- # Handler @@ -116,8 +140,10 @@ async def invite(self, args: List[str]) -> Tuple[bool, str]: return False, f"Notion OAuth failed: {result['error']}" token = result.get("access_token", "") - ws_name = result.get("raw", {}).get("workspace_name", "default") - save_credential(self.spec.cred_file, NotionCredential(token=token)) + raw = result.get("raw", {}) + ws_name = raw.get("workspace_name", "default") + bot_id = raw.get("bot_id", "") + self._save_account(token, ws_name, bot_id) return True, f"Notion connected via CraftOS integration: {ws_name}" async def login(self, args: List[str]) -> Tuple[bool, str]: @@ -134,19 +160,70 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return False, f"Notion auth failed: {data['error']}" ws_name = data.get("bot", {}).get("workspace_name", "default") - save_credential(self.spec.cred_file, NotionCredential(token=token)) + bot_id = data.get("id", "") + self._save_account(token, ws_name, bot_id) return True, f"Notion connected: {ws_name}" + def _save_account(self, token: str, ws_name: str, bot_id: str) -> None: + cred = NotionCredential(token=token, workspace_name=ws_name, bot_id=bot_id) + identity = _identity(cred) + target_file = acc.resolve_save_target(_STEM, NotionCredential, _identity, identity) + save_credential(target_file, cred) + # Pre-fill a friendly alias from the workspace name so a freshly + # connected account doesn't display as an opaque bot id — the user + # can still rename it later. + if ws_name and ws_name != "default" and not acc.get_alias(NOTION.platform_id, identity): + acc.set_alias(NOTION.platform_id, identity, ws_name) + async def logout(self, args: List[str]) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + account = args[0] if args else None + accounts = acc.list_accounts(self.spec.platform_id, _STEM, NotionCredential, _identity) + if not accounts: return False, "No Notion credentials found." - remove_credential(self.spec.cred_file) - return True, "Removed Notion credential." + if not account: + acc.remove_all_accounts(_STEM) + return True, "Removed Notion credential." + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, NotionCredential, _identity, account, "Notion" + ) + if err: + return False, err + acc.remove_account(_STEM, fname) + return True, "Removed Notion account." async def status(self) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, NotionCredential, _identity) + if not accounts: return True, "Notion: Not connected" - return True, "Notion: Connected" + lines = ["Notion: Connected"] + lines.extend(f" - {a.display} ({a.identity})" for a in accounts) + return True, "\n".join(lines) + + def list_accounts(self): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, NotionCredential, _identity) + return acc.accounts_to_dicts(accounts) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, NotionCredential, _identity, account_id, "Notion" + ) + if err: + return False, err + if not acc.promote_to_primary(_STEM, fname): + return False, f"{account_id} is already the primary Notion account." + return True, f"{account_id} is now the primary Notion account." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, NotionCredential, _identity, account_id, "Notion" + ) + if err: + return False, err + cred = load_credential(fname, NotionCredential) + if not cred: + return False, f"Could not load credential for {account_id}." + acc.set_alias(self.spec.platform_id, _identity(cred), alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {_identity(cred)}." # ----------------------------------------------------------------- @@ -162,13 +239,27 @@ class NotionClient(BasePlatformClient): def __init__(self): super().__init__() self._cred: Optional[NotionCredential] = None + self._cred_file: Optional[str] = None def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected. Deliberately does not resolve + self._account here — see GoogleApiClientMixin.has_credentials for why.""" + return bool(acc.list_accounts(self.spec.platform_id, _STEM, NotionCredential, _identity)) def _load(self) -> NotionCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, NotionCredential) + fname, err = acc.resolve_account( + self.spec.platform_id, + _STEM, + NotionCredential, + _identity, + getattr(self, "_account", None), + "Notion", + ) + if err: + raise RuntimeError(err) + self._cred_file = fname + self._cred = load_credential(fname, NotionCredential) if self._cred is None: raise RuntimeError("No Notion credentials. Use /notion login first.") return self._cred diff --git a/craftos_integrations/integrations/outlook/INTEGRATION.md b/craftos_integrations/integrations/outlook/INTEGRATION.md index 05db362b..34a99b4f 100644 --- a/craftos_integrations/integrations/outlook/INTEGRATION.md +++ b/craftos_integrations/integrations/outlook/INTEGRATION.md @@ -5,7 +5,8 @@ Microsoft 365 / Outlook.com mail integration via Microsoft Graph. ## Essentials - **The integration knows the user's own email address** (`cred.email`). NEVER ask the user — read it from the connected credential or `check_integration_status`. -- **`From` is always the connected account.** Can't be spoofed on send. +- **Multiple Outlook accounts may be connected**, each with an optional nickname ("work", "personal"). Every action takes an optional `account` param (email, unique email substring, or nickname). **If the user's message qualifies which account at all** ("my work Outlook", "the school one"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing the connected accounts; relay that instead of guessing. +- **`From` is always the account the call resolved to.** Can't be spoofed within one account. - **Self-emails are auto-filtered on incoming events** (case-insensitive match on sender) — own sends don't echo back. - **Identity format:** plain email-address strings. - **Message IDs are Microsoft Graph opaque IDs** (`AAMk...`). Pull from list/search; never construct. diff --git a/craftos_integrations/integrations/outlook/__init__.py b/craftos_integrations/integrations/outlook/__init__.py index 47de28b2..93c82e9e 100644 --- a/craftos_integrations/integrations/outlook/__init__.py +++ b/craftos_integrations/integrations/outlook/__init__.py @@ -15,13 +15,12 @@ IntegrationSpec, OAuthFlow, PlatformMessage, - has_credential, load_credential, register_client, register_handler, - remove_credential, save_credential, ) +from ... import accounts as acc from ...config import ConfigStore from ...helpers import Result, arequest, request as http_request from ...logger import get_logger @@ -35,6 +34,15 @@ POLL_INTERVAL = 5 RETRY_DELAY = 10 +# Multi-account: the bare "outlook.json" is always the primary account (no +# migration needed for existing single-account installs); additional +# accounts get "outlook__.json" — see craftos_integrations/accounts.py. +_STEM = "outlook" + + +def _identity(cred: "OutlookCredential") -> str: + return cred.email + @dataclass class OutlookCredential: @@ -75,7 +83,12 @@ class OutlookHandler(IntegrationHandler): userinfo_url="https://graph.microsoft.com/v1.0/me", scopes=OUTLOOK_SCOPES, use_pkce=True, - extra_auth_params={"response_mode": "query"}, + # select_account forces Microsoft's account chooser every time. + # Without it the OAuth popup silently reuses whichever Microsoft + # account is already signed into the system browser, so "add + # account" would just re-authenticate the primary and overwrite it + # instead of letting the user pick a second one. + extra_auth_params={"response_mode": "query", "prompt": "select_account"}, ) async def login(self, args: List[str]) -> Tuple[bool, str]: @@ -86,8 +99,9 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: info = result.get("userinfo", {}) user_email = info.get("mail") or info.get("userPrincipalName", "") + target_file = acc.resolve_save_target(_STEM, OutlookCredential, _identity, user_email) save_credential( - self.spec.cred_file, + target_file, OutlookCredential( access_token=result["access_token"], refresh_token=result.get("refresh_token", ""), @@ -99,17 +113,54 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: return True, f"Outlook connected as {user_email}" async def logout(self, args: List[str]) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + account = args[0] if args else None + accounts = acc.list_accounts(self.spec.platform_id, _STEM, OutlookCredential, _identity) + if not accounts: return False, "No Outlook credentials found." - remove_credential(self.spec.cred_file) - return True, "Removed Outlook credential." + if not account: + acc.remove_all_accounts(_STEM) + return True, "Removed Outlook credential." + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, OutlookCredential, _identity, account, "Outlook" + ) + if err: + return False, err + acc.remove_account(_STEM, fname) + return True, "Removed Outlook account." async def status(self) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, OutlookCredential, _identity) + if not accounts: return True, "Outlook: Not connected" - cred = load_credential(self.spec.cred_file, OutlookCredential) - email = cred.email if cred else "unknown" - return True, f"Outlook: Connected\n - {email}" + lines = ["Outlook: Connected"] + lines.extend(f" - {a.identity} ({a.identity})" for a in accounts) + return True, "\n".join(lines) + + def list_accounts(self): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, OutlookCredential, _identity) + return acc.accounts_to_dicts(accounts) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, OutlookCredential, _identity, account_id, "Outlook" + ) + if err: + return False, err + if not acc.promote_to_primary(_STEM, fname): + return False, f"{account_id} is already the primary Outlook account." + return True, f"{account_id} is now the primary Outlook account." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, OutlookCredential, _identity, account_id, "Outlook" + ) + if err: + return False, err + cred = load_credential(fname, OutlookCredential) + if not cred: + return False, f"Could not load credential for {account_id}." + acc.set_alias(self.spec.platform_id, cred.email, alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {cred.email}." # ----------------------------------------------------------------- @@ -125,16 +176,30 @@ class OutlookClient(BasePlatformClient): def __init__(self): super().__init__() self._cred: Optional[OutlookCredential] = None + self._cred_file: Optional[str] = None self._poll_task: Optional[asyncio.Task] = None self._seen_message_ids: set = set() self._last_poll_time: Optional[str] = None def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected. Deliberately does not resolve + self._account here — see GoogleApiClientMixin.has_credentials for why.""" + return bool(acc.list_accounts(self.spec.platform_id, _STEM, OutlookCredential, _identity)) def _load(self) -> OutlookCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, OutlookCredential) + fname, err = acc.resolve_account( + self.spec.platform_id, + _STEM, + OutlookCredential, + _identity, + getattr(self, "_account", None), + "Outlook", + ) + if err: + raise RuntimeError(err) + self._cred_file = fname + self._cred = load_credential(fname, OutlookCredential) if self._cred is None: raise RuntimeError("No Outlook credentials. Use /outlook login first.") return self._cred @@ -168,7 +233,7 @@ def refresh_access_token(self) -> Optional[str]: cred.access_token = data["access_token"] cred.refresh_token = data.get("refresh_token", cred.refresh_token) cred.token_expiry = time.time() + data.get("expires_in", 3600) - 60 - save_credential(self.spec.cred_file, cred) + save_credential(self._cred_file or self.spec.cred_file, cred) self._cred = cred return cred.access_token diff --git a/craftos_integrations/integrations/slack/INTEGRATION.md b/craftos_integrations/integrations/slack/INTEGRATION.md index 502db45b..6a1169e6 100644 --- a/craftos_integrations/integrations/slack/INTEGRATION.md +++ b/craftos_integrations/integrations/slack/INTEGRATION.md @@ -7,6 +7,7 @@ Bot-token integration. Send/receive messages, list channels, search history, upl - **Channel ID prefix tells you what it is:** `C...` = public channel, `G...` = private channel/group, `D...` = direct message channel, `U...` = user ID (NOT a channel — can't send to it directly). The Slack API never accepts channel NAMES — always IDs. Use `list_slack_channels` to translate. - **DMs need a `D...` channel ID,** not a user ID. Open the DM channel first via `open_slack_dm` to get its `D...` id; sending to a user id is an error. - **Thread replies:** pass `thread_ts` (a float-as-string like `"1234567890.123456"`) to `send_slack_message`. Without it, the message goes to the channel root, not the thread. -- **Session-level facts the integration knows:** `team_name`, `workspace_id`, `bot_user_id`. Resolved at login — use `get_slack_channel_info` / `list_slack_users` instead of asking the user. +- **Multiple Slack workspaces may be connected**, each with an optional nickname. Every action takes an optional `account` param (workspace name, workspace ID, unique substring, or nickname). **If the user's message qualifies which workspace at all** ("the work Slack", "my beta workspace"), extract that word and pass it — don't silently default to primary. Only omit `account` when the user gave no qualifier. An unresolvable/ambiguous `account` returns an error listing connected workspaces; relay that instead of guessing. +- **Session-level facts the integration knows (per connected workspace):** `team_name`, `workspace_id`, `bot_user_id`. Resolved at login — use `get_slack_channel_info` / `list_slack_users` instead of asking the user. - **Error envelope:** Slack returns `{"ok": false, "error": "..."}`. Common: `channel_not_found` or `not_in_channel` means the bot isn't a member of that channel — invite it; don't retry. - **Polling auto-drops broken channels:** if a channel keeps throwing `channel_not_found` during polling, the listener stops monitoring it silently. If incoming events stop arriving from one channel only, confirm the bot is still a member. diff --git a/craftos_integrations/integrations/slack/__init__.py b/craftos_integrations/integrations/slack/__init__.py index 9e7e7892..c17a3cc1 100644 --- a/craftos_integrations/integrations/slack/__init__.py +++ b/craftos_integrations/integrations/slack/__init__.py @@ -15,13 +15,12 @@ IntegrationSpec, OAuthFlow, PlatformMessage, - has_credential, load_credential, register_client, register_handler, - remove_credential, save_credential, ) +from ... import accounts as acc from ...helpers import arequest, request as http_request from ...logger import get_logger @@ -90,6 +89,18 @@ class SlackCredential: platform_id="slack", ) +# Multi-account: the bare "slack.json" is always the primary workspace (no +# migration needed for existing single-account installs); additional +# workspaces get "slack__.json" — see accounts.py. +# workspace_id (Slack's team ID) is used as identity rather than team_name +# since it's immutable and globally unique; team_name is a better display +# label but is mutable and can be user-supplied via /slack login. +_STEM = "slack" + + +def _identity(cred: "SlackCredential") -> str: + return cred.workspace_id + # ----------------------------------------------------------------- # Handler @@ -154,14 +165,7 @@ async def invite(self, args: List[str]) -> Tuple[bool, str]: team_id = team.get("id", "") team_name = team.get("name", team_id) - save_credential( - self.spec.cred_file, - SlackCredential( - bot_token=bot_token, - workspace_id=team_id, - team_name=team_name, - ), - ) + self._save_account(SlackCredential(bot_token=bot_token, workspace_id=team_id, team_name=team_name)) return True, f"Slack connected via CraftOS app: {team_name} ({team_id})" async def login(self, args: List[str]) -> Tuple[bool, str]: @@ -179,28 +183,66 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: team_id = result.get("team_id", "") workspace_name = args[1] if len(args) > 1 else result.get("team", team_id) - save_credential( - self.spec.cred_file, - SlackCredential( - bot_token=bot_token, - workspace_id=team_id, - team_name=workspace_name, - ), + self._save_account( + SlackCredential(bot_token=bot_token, workspace_id=team_id, team_name=workspace_name) ) return True, f"Slack connected: {workspace_name} ({team_id})" + def _save_account(self, cred: "SlackCredential") -> None: + target_file = acc.resolve_save_target(_STEM, SlackCredential, _identity, cred.workspace_id) + save_credential(target_file, cred) + if cred.team_name and not acc.get_alias(SLACK.platform_id, cred.workspace_id): + acc.set_alias(SLACK.platform_id, cred.workspace_id, cred.team_name) + async def logout(self, args: List[str]) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + account = args[0] if args else None + accounts = acc.list_accounts(self.spec.platform_id, _STEM, SlackCredential, _identity) + if not accounts: return False, "No Slack credentials found." - remove_credential(self.spec.cred_file) - return True, "Removed Slack credential." + if not account: + acc.remove_all_accounts(_STEM) + return True, "Removed Slack credential." + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, SlackCredential, _identity, account, "Slack" + ) + if err: + return False, err + acc.remove_account(_STEM, fname) + return True, "Removed Slack account." async def status(self) -> Tuple[bool, str]: - if not has_credential(self.spec.cred_file): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, SlackCredential, _identity) + if not accounts: return True, "Slack: Not connected" - cred = load_credential(self.spec.cred_file, SlackCredential) - name = cred.team_name or cred.workspace_id if cred else "unknown" - return True, f"Slack: Connected\n - {name} ({cred.workspace_id})" + lines = ["Slack: Connected"] + lines.extend(f" - {a.display} ({a.identity})" for a in accounts) + return True, "\n".join(lines) + + def list_accounts(self): + accounts = acc.list_accounts(self.spec.platform_id, _STEM, SlackCredential, _identity) + return acc.accounts_to_dicts(accounts) + + async def set_primary(self, account_id: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, SlackCredential, _identity, account_id, "Slack" + ) + if err: + return False, err + if not acc.promote_to_primary(_STEM, fname): + return False, f"{account_id} is already the primary Slack account." + return True, f"{account_id} is now the primary Slack account." + + def set_alias(self, account_id: str, alias: str) -> Tuple[bool, str]: + fname, err = acc.resolve_account( + self.spec.platform_id, _STEM, SlackCredential, _identity, account_id, "Slack" + ) + if err: + return False, err + cred = load_credential(fname, SlackCredential) + if not cred: + return False, f"Could not load credential for {account_id}." + acc.set_alias(self.spec.platform_id, cred.workspace_id, alias) + return True, f"Alias {'set' if alias.strip() else 'cleared'} for {cred.workspace_id}." # ----------------------------------------------------------------- @@ -222,11 +264,23 @@ def __init__(self): self._catchup_done: bool = False def has_credentials(self) -> bool: - return has_credential(self.spec.cred_file) + """True if *any* account is connected. Deliberately does not resolve + self._account here — see GoogleApiClientMixin.has_credentials for why.""" + return bool(acc.list_accounts(self.spec.platform_id, _STEM, SlackCredential, _identity)) def _load(self) -> SlackCredential: if self._cred is None: - self._cred = load_credential(self.spec.cred_file, SlackCredential) + fname, err = acc.resolve_account( + self.spec.platform_id, + _STEM, + SlackCredential, + _identity, + getattr(self, "_account", None), + "Slack", + ) + if err: + raise RuntimeError(err) + self._cred = load_credential(fname, SlackCredential) if self._cred is None: raise RuntimeError("No Slack credentials. Use /slack login first.") return self._cred diff --git a/craftos_integrations/registry.py b/craftos_integrations/registry.py index e5208405..799f2f85 100644 --- a/craftos_integrations/registry.py +++ b/craftos_integrations/registry.py @@ -13,7 +13,7 @@ import importlib import pkgutil -from typing import Dict, List, Optional, Type +from typing import Dict, List, Optional, Tuple, Type from .base import BasePlatformClient, IntegrationHandler from .logger import get_logger @@ -26,7 +26,10 @@ # ════════════════════════════════════════════════════════════════════════ _client_classes: Dict[str, Type[BasePlatformClient]] = {} -_client_instances: Dict[str, BasePlatformClient] = {} +# Keyed by (platform_id, account_key). account_key is "" for the primary/ +# single-account case, so existing single-arg get_client(pid) callers land on +# the same instance they always did. +_client_instances: Dict[Tuple[str, str], BasePlatformClient] = {} def register_client(cls: Type[BasePlatformClient]) -> Type[BasePlatformClient]: @@ -37,36 +40,50 @@ def register_client(cls: Type[BasePlatformClient]) -> Type[BasePlatformClient]: return cls -def get_client(platform_id: str) -> Optional[BasePlatformClient]: - if platform_id in _client_instances: - return _client_instances[platform_id] +def get_client( + platform_id: str, account: Optional[str] = None +) -> Optional[BasePlatformClient]: + """Get (or build) the client singleton for ``platform_id``. + + ``account`` selects which connected account this client instance talks to + (an email or unique fragment — see ``_google_common.resolve_account``). + ``None``/omitted means the primary account, cached under key ``""`` — + identical behavior to before multi-account support existed. + """ + key = (platform_id, account or "") + if key in _client_instances: + return _client_instances[key] cls = _client_classes.get(platform_id) if cls is None: return None instance = cls() - _client_instances[platform_id] = instance + instance._account = account + _client_instances[key] = instance return instance def get_all_clients() -> Dict[str, BasePlatformClient]: + """One (primary-account) client per registered platform.""" for pid in _client_classes: - if pid not in _client_instances: - _client_instances[pid] = _client_classes[pid]() - return dict(_client_instances) + key = (pid, "") + if key not in _client_instances: + _client_instances[key] = _client_classes[pid]() + return {pid: _client_instances[(pid, "")] for pid in _client_classes} def invalidate_client(platform_id: str) -> None: - """Drop the cached client singleton so the next get_client() rebuilds it. + """Drop every cached client instance for ``platform_id`` (all accounts). Client instances cache the account credential in memory (e.g. the Google mixin's ``_cred``). When an account is connected/disconnected at runtime the credential file on disk changes, but the live instance keeps serving the old account — so integration actions hit the wrong account until the agent is - restarted. Dropping the instance here forces a fresh build (and a fresh - credential read from disk) on next use, mirroring what a restart does. - See issue #314. + restarted. Dropping every cached instance for this platform forces a fresh + build (and a fresh credential read from disk) on next use, mirroring what a + restart does. See issue #314. """ - _client_instances.pop(platform_id, None) + for key in [k for k in _client_instances if k[0] == platform_id]: + _client_instances.pop(key, None) def get_registered_platforms() -> List[str]: diff --git a/craftos_integrations/service.py b/craftos_integrations/service.py index 2847a901..1fb5f806 100644 --- a/craftos_integrations/service.py +++ b/craftos_integrations/service.py @@ -103,6 +103,27 @@ async def disconnect( return success, message +async def set_primary_account(integration: str, account_id: str) -> Tuple[bool, str]: + """Promote ``account_id`` to the primary account (see accounts.py).""" + handler, err = _resolve_handler(integration) + if err: + return False, err + success, message = await handler.set_primary(account_id) + if success: + # Primary/secondary file roles just swapped — drop cached clients so + # the next action/listener rebuild resolves "primary" correctly. + await _reset_platform_for_handler(handler) + return success, message + + +def set_account_alias(integration: str, account_id: str, alias: str) -> Tuple[bool, str]: + """Set (or, with an empty ``alias``, clear) a connected account's alias.""" + handler, err = _resolve_handler(integration) + if err: + return False, err + return handler.set_alias(account_id, alias) + + async def status(integration: str) -> Tuple[bool, str]: """Run the integration's status check.""" handler, err = _resolve_handler(integration) @@ -283,12 +304,20 @@ async def get_integration_info(integration: str) -> Optional[Dict[str, Any]]: return None handler = get_handler(integration) connected = False - accounts: List[Dict[str, str]] = [] + accounts: List[Dict[str, Any]] = [] try: - _, status_msg = await handler.status() - if "Connected" in status_msg and "Not connected" not in status_msg: - connected = True - accounts = parse_status_accounts(status_msg) + # Multi-account-capable handlers (accounts.py-backed) expose the + # real account list directly — alias/is_primary don't fit the old + # "- name (id)" status-string format, so prefer this when available. + structured = handler.list_accounts() + if structured is not None: + accounts = structured + connected = len(accounts) > 0 + else: + _, status_msg = await handler.status() + if "Connected" in status_msg and "Not connected" not in status_msg: + connected = True + accounts = parse_status_accounts(status_msg) except Exception: pass metadata["connected"] = connected diff --git a/tests/test_accounts_generic.py b/tests/test_accounts_generic.py new file mode 100644 index 00000000..44dc9edd --- /dev/null +++ b/tests/test_accounts_generic.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""Generic multi-account layer (craftos_integrations/accounts.py) — tested +against a synthetic credential shape (not Gmail's) to prove it's genuinely +integration-agnostic, not accidentally coupled to an `email` field. +""" + +from dataclasses import dataclass + +import pytest + +from craftos_integrations.config import ConfigStore +from craftos_integrations import credentials_store as cs +from craftos_integrations import accounts as acc +from craftos_integrations import registry + + +@pytest.fixture(autouse=True) +def isolated_credentials_dir(tmp_path, monkeypatch): + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + registry.reset() + yield + + +@dataclass +class FakeCredential: + """Stand-in for a non-Google integration's credential — identity lives + in `workspace_id`, nothing here is named `email`.""" + + token: str = "" + workspace_id: str = "" + + +STEM = "fakeplatform" +PLATFORM_ID = "fake" + + +def identity_of(cred: FakeCredential) -> str: + return cred.workspace_id + + +def _cred(workspace_id: str) -> FakeCredential: + return FakeCredential(token="tok", workspace_id=workspace_id) + + +# ── list / resolve ──────────────────────────────────────────────────────── + + +def test_list_accounts_primary_first_then_secondaries(): + cs.save_credential(f"{STEM}.json", _cred("T-primary")) + cs.save_credential(cs.account_filename(STEM, "T-second"), _cred("T-second")) + + accounts = acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of) + assert [a.identity for a in accounts] == ["T-primary", "T-second"] + assert accounts[0].is_primary and not accounts[1].is_primary + + +def test_resolve_account_exact_substring_ambiguous_not_found(): + cs.save_credential(f"{STEM}.json", _cred("acme-corp")) + cs.save_credential(cs.account_filename(STEM, "acme-personal"), _cred("acme-personal")) + + # default -> primary + fname, err = acc.resolve_account(PLATFORM_ID, STEM, FakeCredential, identity_of, None, "Fake") + assert err is None and fname == f"{STEM}.json" + + # exact + fname, err = acc.resolve_account( + PLATFORM_ID, STEM, FakeCredential, identity_of, "acme-personal", "Fake" + ) + assert err is None and fname == cs.account_filename(STEM, "acme-personal") + + # ambiguous substring (both contain "acme") + fname, err = acc.resolve_account( + PLATFORM_ID, STEM, FakeCredential, identity_of, "acme", "Fake" + ) + assert fname is None and "acme-corp" in err and "acme-personal" in err + + # not found + fname, err = acc.resolve_account( + PLATFORM_ID, STEM, FakeCredential, identity_of, "nope", "Fake" + ) + assert fname is None and "acme-corp" in err and "acme-personal" in err + + +# ── alias ───────────────────────────────────────────────────────────────── + + +def test_alias_set_get_remove_and_resolve_by_alias(): + cs.save_credential(f"{STEM}.json", _cred("T-primary")) + acc.set_alias(PLATFORM_ID, "T-primary", "Work") + + assert acc.get_alias(PLATFORM_ID, "T-primary") == "Work" + + accounts = acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of) + assert accounts[0].display == "Work" + + # resolves by alias, case-insensitive + fname, err = acc.resolve_account(PLATFORM_ID, STEM, FakeCredential, identity_of, "work", "Fake") + assert err is None and fname == f"{STEM}.json" + + # empty alias clears it + acc.set_alias(PLATFORM_ID, "T-primary", "") + assert acc.get_alias(PLATFORM_ID, "T-primary") is None + + acc.set_alias(PLATFORM_ID, "T-primary", "Personal") + acc.remove_alias(PLATFORM_ID, "T-primary") + assert acc.get_alias(PLATFORM_ID, "T-primary") is None + + +# ── resolve_save_target (login/invite placement) ────────────────────────── + + +def test_resolve_save_target_fills_primary_then_allocates_secondary_then_updates_existing(): + # nothing connected -> primary + target = acc.resolve_save_target(STEM, FakeCredential, identity_of, "T-a") + assert target == f"{STEM}.json" + cs.save_credential(target, _cred("T-a")) + + # different identity -> new secondary + target = acc.resolve_save_target(STEM, FakeCredential, identity_of, "T-b") + assert target == cs.account_filename(STEM, "T-b") + cs.save_credential(target, _cred("T-b")) + + # same identity as primary -> update in place, no third file + target = acc.resolve_save_target(STEM, FakeCredential, identity_of, "T-a") + assert target == f"{STEM}.json" + assert len(acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of)) == 2 + + +# ── remove_account (promotes a secondary when primary is removed) ──────── + + +def test_remove_account_promotes_secondary_when_primary_removed(): + cs.save_credential(f"{STEM}.json", _cred("T-a")) + cs.save_credential(cs.account_filename(STEM, "T-b"), _cred("T-b")) + + acc.remove_account(STEM, f"{STEM}.json") + + accounts = acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of) + assert len(accounts) == 1 + assert accounts[0].identity == "T-b" + assert accounts[0].is_primary + assert accounts[0].filename == f"{STEM}.json" # promoted into the bare file + + +def test_remove_account_last_one_leaves_nothing_connected(): + cs.save_credential(f"{STEM}.json", _cred("T-a")) + acc.remove_account(STEM, f"{STEM}.json") + assert acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of) == [] + + +# ── promote_to_primary (explicit "set primary" swap) ────────────────────── + + +def test_promote_to_primary_swaps_contents_and_preserves_both_accounts(): + cs.save_credential(f"{STEM}.json", _cred("T-a")) + secondary_file = cs.account_filename(STEM, "T-b") + cs.save_credential(secondary_file, _cred("T-b")) + + ok = acc.promote_to_primary(STEM, secondary_file) + assert ok + + accounts = acc.list_accounts(PLATFORM_ID, STEM, FakeCredential, identity_of) + by_identity = {a.identity: a for a in accounts} + assert len(accounts) == 2 + assert by_identity["T-b"].is_primary and by_identity["T-b"].filename == f"{STEM}.json" + assert not by_identity["T-a"].is_primary + + # promoting the already-primary account is a no-op + assert acc.promote_to_primary(STEM, f"{STEM}.json") is False diff --git a/tests/test_google_multi_account.py b/tests/test_google_multi_account.py new file mode 100644 index 00000000..907395f8 --- /dev/null +++ b/tests/test_google_multi_account.py @@ -0,0 +1,200 @@ +# -*- coding: utf-8 -*- +"""Multi-account support for the Google integrations (Gmail, Calendar, Drive, +Docs, YouTube) — one smallest-possible check per touched unit: + + - credentials_store: filename sanitization + secondary-file glob doesn't + catch the paired _config.json file. + - resolve_account: exact / substring / ambiguous / not-found / default- + primary resolution. + - run_google_login: first account -> primary file; second -> secondary + file; re-auth of an existing email -> overwrite (no new file). + - registry: two accounts for the same platform cache as separate client + instances; invalidate_client drops all of them. +""" + +import asyncio + +import pytest + +from craftos_integrations.config import ConfigStore +from craftos_integrations import credentials_store as cs +from craftos_integrations.spec import IntegrationSpec +from craftos_integrations.integrations import _google_common as gc +from craftos_integrations import registry +from craftos_integrations.base import BasePlatformClient + + +@pytest.fixture(autouse=True) +def isolated_credentials_dir(tmp_path, monkeypatch): + """Point ConfigStore.project_root at a scratch dir so tests never touch + the real ~/.credentials, and reset the registry between tests.""" + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + registry.reset() + yield + registry.reset() + + +SPEC = IntegrationSpec(name="gmail", cred_class=gc.GoogleCredential, cred_file="gmail.json") + + +def _cred(email: str) -> gc.GoogleCredential: + return gc.GoogleCredential( + access_token="tok", refresh_token="ref", token_expiry=0.0, + client_id="cid", client_secret="csec", email=email, + ) + + +# ── credentials_store ──────────────────────────────────────────────────── + + +def test_sanitize_and_secondary_glob_ignores_config_file(): + assert cs.sanitize_account("Alan@Gmail.com") == "alan_gmail_com" + fname = cs.account_filename("gmail", "alan@gmail.com") + assert fname == "gmail__alan_gmail_com.json" + + cs.save_credential(fname, _cred("alan@gmail.com")) + cs.save_config("gmail_config.json", gc.GoogleCredential()) # decoy, wrong dir key but same prefix risk + + secondaries = cs.list_account_files("gmail") + assert secondaries == [fname] + assert "gmail_config.json" not in secondaries + + +# ── resolve_account ────────────────────────────────────────────────────── + + +def test_resolve_account_default_primary_and_not_connected(): + fname, err = gc.resolve_account(SPEC, None) + assert fname is None and "Not connected" in err + + cs.save_credential(SPEC.cred_file, _cred("alan@gmail.com")) + fname, err = gc.resolve_account(SPEC, None) + assert err is None and fname == SPEC.cred_file + + +def test_resolve_account_exact_and_substring_and_ambiguous(): + cs.save_credential(SPEC.cred_file, _cred("alan@gmail.com")) + cs.save_credential(cs.account_filename("gmail", "alan@corp.com"), _cred("alan@corp.com")) + + # exact (case-insensitive) + fname, err = gc.resolve_account(SPEC, "ALAN@GMAIL.COM") + assert err is None and fname == SPEC.cred_file + + # unique substring + fname, err = gc.resolve_account(SPEC, "corp") + assert err is None and fname == cs.account_filename("gmail", "alan@corp.com") + + # ambiguous substring -> error lists both matches + fname, err = gc.resolve_account(SPEC, "alan") + assert fname is None + assert "alan@gmail.com" in err and "alan@corp.com" in err + + # no match -> error lists connected accounts + fname, err = gc.resolve_account(SPEC, "nobody@example.com") + assert fname is None + assert "alan@gmail.com" in err and "alan@corp.com" in err + + +# ── run_google_login ────────────────────────────────────────────────────── + + +class _StubOAuth: + def __init__(self, email): + self._email = email + + async def run(self): + return { + "access_token": "tok", + "refresh_token": "ref", + "expires_in": 3600, + "userinfo": {"email": self._email}, + } + + +def test_login_first_account_fills_primary_then_secondary_then_overwrites(): + ok, msg = asyncio.run(gc.run_google_login(SPEC, _StubOAuth("alan@gmail.com"), "Gmail")) + assert ok and cs.has_credential(SPEC.cred_file) + assert gc.list_google_accounts(SPEC) == [("alan@gmail.com", SPEC.cred_file)] + + # second, different email -> new secondary file, primary untouched + ok, msg = asyncio.run(gc.run_google_login(SPEC, _StubOAuth("alan@corp.com"), "Gmail")) + assert ok + accounts = dict(gc.list_google_accounts(SPEC)) + assert accounts["alan@gmail.com"] == SPEC.cred_file + assert accounts["alan@corp.com"] == cs.account_filename("gmail", "alan@corp.com") + + # re-auth of the primary email -> same two files, no third one created + asyncio.run(gc.run_google_login(SPEC, _StubOAuth("alan@gmail.com"), "Gmail")) + assert len(gc.list_google_accounts(SPEC)) == 2 + + +def test_logout_removing_primary_promotes_a_secondary(): + asyncio.run(gc.run_google_login(SPEC, _StubOAuth("alan@gmail.com"), "Gmail")) + asyncio.run(gc.run_google_login(SPEC, _StubOAuth("alan@corp.com"), "Gmail")) + + ok, _ = asyncio.run(gc.run_google_logout(SPEC, "Gmail", "alan@gmail.com")) + assert ok + accounts = gc.list_google_accounts(SPEC) + assert accounts == [("alan@corp.com", SPEC.cred_file)] # promoted into the bare file + + +# ── registry: per-account client caching ───────────────────────────────── + + +class _DummyClient(BasePlatformClient): + PLATFORM_ID = "dummy_multi_account" + + def has_credentials(self): + return True + + async def connect(self): + pass + + async def send_message(self, recipient, text, **kwargs): + return {} + + +def test_registry_caches_per_account_and_invalidate_drops_all(): + registry.register_client(_DummyClient) + + primary = registry.get_client("dummy_multi_account") + secondary = registry.get_client("dummy_multi_account", "alan@corp.com") + again_primary = registry.get_client("dummy_multi_account") + + assert primary is not secondary + assert primary is again_primary + assert primary._account is None + assert secondary._account == "alan@corp.com" + + registry.invalidate_client("dummy_multi_account") + assert registry.get_client("dummy_multi_account") is not primary + + +# ── shared alias namespace across the Google family ─────────────────────── + + +def test_alias_shared_across_google_services_with_legacy_migration(): + from craftos_integrations import accounts as acc + from craftos_integrations.integrations.gmail import GMAIL + from craftos_integrations.integrations.google_calendar import GCAL + + cs.save_credential(GMAIL.cred_file, _cred("alan@gmail.com")) + cs.save_credential(GCAL.cred_file, _cred("alan@gmail.com")) + + # Simulate pre-migration state: alias set the old way, per-service. + acc.set_alias("gmail", "alan@gmail.com", "personal") + + # Reading through Gmail migrates it to the shared "google" namespace. + accounts = gc.run_google_list_accounts(GMAIL) + assert accounts[0]["alias"] == "personal" + assert acc.get_alias("google", "alan@gmail.com") == "personal" + assert acc.get_alias("gmail", "alan@gmail.com") is None # legacy key cleaned up + + # Calendar never had its own alias — it inherits via the shared namespace. + cal_accounts = gc.run_google_list_accounts(GCAL) + assert cal_accounts[0]["alias"] == "personal" + + # Setting the alias through Calendar updates the shared value Gmail sees too. + ok, _ = gc.run_google_set_alias(GCAL, "alan@gmail.com", "work") + assert ok + assert gc.run_google_list_accounts(GMAIL)[0]["alias"] == "work"