From 53de309e1efc232c8c17b78addbe47e2b8fdbf82 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Thu, 13 Aug 2026 11:47:25 +0100 Subject: [PATCH 1/2] feat(integrations): multi-account support with aliases and per-account listeners Rebuilt the integration layer as a host-agnostic package (supersedes PR #370; design: docs/plans/multi-account-v2-plan.md). The 10 major integrations now hold a primary account plus any number of additional accounts with nicknames (shared across the Google family). - AccountSet document store: atomic writes, deterministic account resolution, one-time migration of existing credential files - 10 providers / 397 operations; @action wrappers are generated, with the account param injected centrally so no action can bypass it - OAuth account choosers fixed (Google/Outlook select_account) - Manage-accounts modal with staged edits and add-account OAuth - Gmail/Outlook/Slack listeners run per account, triggers account-tagged - Router extracts account qualifiers ("my job email") and sees live account lists just-in-time 516 tests passing, tsc clean. closes #368 --- agent_core/core/prompts/action.py | 21 +- app/agent_base.py | 30 +- app/data/action/integrations/_helpers.py | 296 ++ .../integrations/_integration_essentials.py | 241 +- app/data/action/integrations/_routing.py | 25 +- .../action/integrations/craftbot_adapter.py | 121 + .../google_workspace/gmail_actions.py | 1160 ------ .../google_calendar_actions.py | 1338 ------- .../google_workspace/google_docs_actions.py | 1383 ------- .../google_workspace/google_drive_actions.py | 1246 ------ .../google_youtube_actions.py | 430 -- .../integrations/hubspot/hubspot_actions.py | 3508 ----------------- .../integrations/integration_management.py | 91 +- .../integrations/linkedin/linkedin_actions.py | 814 ---- .../integrations/notion/notion_actions.py | 1136 ------ .../integrations/outlook/outlook_actions.py | 1325 ------- .../integrations/slack/slack_actions.py | 1826 --------- app/data/agent_file_system_template/AGENT.md | 14 + app/integrations.py | 167 + app/living_ui/agent_view.py | 9 +- app/living_ui/integration_bridge.py | 44 +- app/ui_layer/adapters/browser_adapter.py | 372 +- .../pages/Settings/IntegrationsSettings.tsx | 605 ++- .../pages/Settings/SettingsPage.module.css | 185 +- .../frontend/src/pages/Settings/types.ts | 52 + app/ui_layer/commands/builtin/cred.py | 29 +- craftos_integrations/contracts.py | 212 + craftos_integrations/core/__init__.py | 25 + craftos_integrations/core/accounts.py | 485 +++ craftos_integrations/core/listeners.py | 381 ++ craftos_integrations/core/registry.py | 69 + craftos_integrations/core/storage.py | 144 + craftos_integrations/core/system.py | 291 ++ .../integrations/whatsapp_web/bridge.js | 73 +- craftos_integrations/manager.py | 39 +- craftos_integrations/providers/__init__.py | 42 + craftos_integrations/providers/_google.py | 191 + craftos_integrations/providers/_shared.py | 172 + .../providers/gmail/GUIDANCE.md | 24 + .../providers/gmail/__init__.py | 3 + .../providers/gmail/listener.py | 100 + .../providers/gmail/operations.py | 885 +++++ .../providers/gmail/provider.py | 43 + .../providers/google_calendar/GUIDANCE.md | 44 + .../providers/google_calendar/__init__.py | 3 + .../providers/google_calendar/operations.py | 1232 ++++++ .../providers/google_calendar/provider.py | 33 + .../providers/google_docs/GUIDANCE.md | 37 + .../providers/google_docs/__init__.py | 3 + .../providers/google_docs/operations.py | 1046 +++++ .../providers/google_docs/provider.py | 37 + .../providers/google_drive/GUIDANCE.md | 44 + .../providers/google_drive/__init__.py | 3 + .../providers/google_drive/operations.py | 1116 ++++++ .../providers/google_drive/provider.py | 33 + .../providers/google_youtube/GUIDANCE.md | 37 + .../providers/google_youtube/__init__.py | 3 + .../providers/google_youtube/operations.py | 413 ++ .../providers/google_youtube/provider.py | 33 + .../providers/hubspot/GUIDANCE.md | 87 + .../providers/hubspot/__init__.py | 3 + .../providers/hubspot/operations.py | 2161 ++++++++++ .../providers/hubspot/provider.py | 270 ++ .../providers/linkedin/GUIDANCE.md | 46 + .../providers/linkedin/__init__.py | 3 + .../providers/linkedin/operations.py | 680 ++++ .../providers/linkedin/provider.py | 234 ++ .../providers/notion/GUIDANCE.md | 48 + .../providers/notion/__init__.py | 3 + .../providers/notion/operations.py | 1149 ++++++ .../providers/notion/provider.py | 155 + .../providers/outlook/GUIDANCE.md | 39 + .../providers/outlook/__init__.py | 3 + .../providers/outlook/listener.py | 97 + .../providers/outlook/operations.py | 1179 ++++++ .../providers/outlook/provider.py | 216 + .../providers/slack/GUIDANCE.md | 43 + .../providers/slack/__init__.py | 3 + .../providers/slack/listener.py | 120 + .../providers/slack/operations.py | 1688 ++++++++ .../providers/slack/provider.py | 174 + docs/plans/multi-account-v2-plan.md | 471 +++ tests/integrations/__init__.py | 0 tests/integrations/conformance.py | 138 + tests/integrations/conftest.py | 52 + tests/integrations/test_calendar_provider.py | 124 + .../integrations/test_conformance_selftest.py | 78 + tests/integrations/test_craftbot_adapter.py | 147 + tests/integrations/test_docs_provider.py | 87 + tests/integrations/test_drive_provider.py | 84 + tests/integrations/test_google_providers.py | 140 + .../integrations/test_host_listener_wiring.py | 376 ++ tests/integrations/test_hubspot_provider.py | 245 ++ .../test_integration_essentials.py | 110 + tests/integrations/test_isolation.py | 61 + tests/integrations/test_linkedin_provider.py | 255 ++ tests/integrations/test_listener_manager.py | 443 +++ tests/integrations/test_login.py | 281 ++ tests/integrations/test_management_actions.py | 307 ++ tests/integrations/test_migration.py | 132 + tests/integrations/test_mutations.py | 198 + tests/integrations/test_notion_provider.py | 125 + tests/integrations/test_outlook_provider.py | 200 + tests/integrations/test_provider_listeners.py | 444 +++ tests/integrations/test_resolution.py | 70 + tests/integrations/test_slack_provider.py | 138 + tests/integrations/test_storage.py | 86 + tests/integrations/test_system.py | 152 + .../integrations/test_ws_account_handlers.py | 474 +++ tests/integrations/test_youtube_provider.py | 113 + 110 files changed, 23379 insertions(+), 14317 deletions(-) create mode 100644 app/data/action/integrations/craftbot_adapter.py delete mode 100644 app/data/action/integrations/google_workspace/gmail_actions.py delete mode 100644 app/data/action/integrations/google_workspace/google_calendar_actions.py delete mode 100644 app/data/action/integrations/google_workspace/google_docs_actions.py delete mode 100644 app/data/action/integrations/google_workspace/google_drive_actions.py delete mode 100644 app/data/action/integrations/google_workspace/google_youtube_actions.py delete mode 100644 app/data/action/integrations/hubspot/hubspot_actions.py delete mode 100644 app/data/action/integrations/linkedin/linkedin_actions.py delete mode 100644 app/data/action/integrations/notion/notion_actions.py delete mode 100644 app/data/action/integrations/outlook/outlook_actions.py delete mode 100644 app/data/action/integrations/slack/slack_actions.py create mode 100644 app/integrations.py create mode 100644 craftos_integrations/contracts.py create mode 100644 craftos_integrations/core/__init__.py create mode 100644 craftos_integrations/core/accounts.py create mode 100644 craftos_integrations/core/listeners.py create mode 100644 craftos_integrations/core/registry.py create mode 100644 craftos_integrations/core/storage.py create mode 100644 craftos_integrations/core/system.py create mode 100644 craftos_integrations/providers/__init__.py create mode 100644 craftos_integrations/providers/_google.py create mode 100644 craftos_integrations/providers/_shared.py create mode 100644 craftos_integrations/providers/gmail/GUIDANCE.md create mode 100644 craftos_integrations/providers/gmail/__init__.py create mode 100644 craftos_integrations/providers/gmail/listener.py create mode 100644 craftos_integrations/providers/gmail/operations.py create mode 100644 craftos_integrations/providers/gmail/provider.py create mode 100644 craftos_integrations/providers/google_calendar/GUIDANCE.md create mode 100644 craftos_integrations/providers/google_calendar/__init__.py create mode 100644 craftos_integrations/providers/google_calendar/operations.py create mode 100644 craftos_integrations/providers/google_calendar/provider.py create mode 100644 craftos_integrations/providers/google_docs/GUIDANCE.md create mode 100644 craftos_integrations/providers/google_docs/__init__.py create mode 100644 craftos_integrations/providers/google_docs/operations.py create mode 100644 craftos_integrations/providers/google_docs/provider.py create mode 100644 craftos_integrations/providers/google_drive/GUIDANCE.md create mode 100644 craftos_integrations/providers/google_drive/__init__.py create mode 100644 craftos_integrations/providers/google_drive/operations.py create mode 100644 craftos_integrations/providers/google_drive/provider.py create mode 100644 craftos_integrations/providers/google_youtube/GUIDANCE.md create mode 100644 craftos_integrations/providers/google_youtube/__init__.py create mode 100644 craftos_integrations/providers/google_youtube/operations.py create mode 100644 craftos_integrations/providers/google_youtube/provider.py create mode 100644 craftos_integrations/providers/hubspot/GUIDANCE.md create mode 100644 craftos_integrations/providers/hubspot/__init__.py create mode 100644 craftos_integrations/providers/hubspot/operations.py create mode 100644 craftos_integrations/providers/hubspot/provider.py create mode 100644 craftos_integrations/providers/linkedin/GUIDANCE.md create mode 100644 craftos_integrations/providers/linkedin/__init__.py create mode 100644 craftos_integrations/providers/linkedin/operations.py create mode 100644 craftos_integrations/providers/linkedin/provider.py create mode 100644 craftos_integrations/providers/notion/GUIDANCE.md create mode 100644 craftos_integrations/providers/notion/__init__.py create mode 100644 craftos_integrations/providers/notion/operations.py create mode 100644 craftos_integrations/providers/notion/provider.py create mode 100644 craftos_integrations/providers/outlook/GUIDANCE.md create mode 100644 craftos_integrations/providers/outlook/__init__.py create mode 100644 craftos_integrations/providers/outlook/listener.py create mode 100644 craftos_integrations/providers/outlook/operations.py create mode 100644 craftos_integrations/providers/outlook/provider.py create mode 100644 craftos_integrations/providers/slack/GUIDANCE.md create mode 100644 craftos_integrations/providers/slack/__init__.py create mode 100644 craftos_integrations/providers/slack/listener.py create mode 100644 craftos_integrations/providers/slack/operations.py create mode 100644 craftos_integrations/providers/slack/provider.py create mode 100644 docs/plans/multi-account-v2-plan.md create mode 100644 tests/integrations/__init__.py create mode 100644 tests/integrations/conformance.py create mode 100644 tests/integrations/conftest.py create mode 100644 tests/integrations/test_calendar_provider.py create mode 100644 tests/integrations/test_conformance_selftest.py create mode 100644 tests/integrations/test_craftbot_adapter.py create mode 100644 tests/integrations/test_docs_provider.py create mode 100644 tests/integrations/test_drive_provider.py create mode 100644 tests/integrations/test_google_providers.py create mode 100644 tests/integrations/test_host_listener_wiring.py create mode 100644 tests/integrations/test_hubspot_provider.py create mode 100644 tests/integrations/test_integration_essentials.py create mode 100644 tests/integrations/test_isolation.py create mode 100644 tests/integrations/test_linkedin_provider.py create mode 100644 tests/integrations/test_listener_manager.py create mode 100644 tests/integrations/test_login.py create mode 100644 tests/integrations/test_management_actions.py create mode 100644 tests/integrations/test_migration.py create mode 100644 tests/integrations/test_mutations.py create mode 100644 tests/integrations/test_notion_provider.py create mode 100644 tests/integrations/test_outlook_provider.py create mode 100644 tests/integrations/test_provider_listeners.py create mode 100644 tests/integrations/test_resolution.py create mode 100644 tests/integrations/test_slack_provider.py create mode 100644 tests/integrations/test_storage.py create mode 100644 tests/integrations/test_system.py create mode 100644 tests/integrations/test_ws_account_handlers.py create mode 100644 tests/integrations/test_youtube_provider.py diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index d35958a9..37113afa 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -81,7 +81,10 @@ Message Routing: - To reply to the user, send on the platform the incoming message came from — - check its source in the event stream. + check its source in the event stream. An event labeled just "user message" + (no platform tag) was typed in the local CraftBot interface: reply with + send_message, NOT a platform send action, even if earlier turns in this + session came from an external platform. - To act on a platform the user explicitly names, use that platform's send action (load its action set first if needed). - send_message and send_message_with_attachment ONLY records to the local @@ -106,6 +109,22 @@ 3. Read configuration of your own in app/config/. - Only ask the user if all three sources fail to provide the answer. +Multi-Account Integrations: +- Integrations can hold several connected accounts (e.g. a work and a school + Gmail). Every integration action takes an optional "account" input: an + email/identity, the user's nickname for the account, or any unique + fragment of either. Omitted = the primary account. +- When the user names an account in ANY form ("my school calendar", "the + work inbox", "from my personal email"), extract that qualifier into + "account". Never silently default to primary when a qualifier is present. +- If an account hint doesn't resolve, the action returns an error listing + the connected accounts — pick the right one from that list or ask the + user; do not retry the same hint. +- IDs are account-scoped: a message/event/file id returned with + account="work" must be passed back with account="work" on follow-ups. +- For irreversible actions (send, delete, clear) with multiple accounts + connected and no qualifier in the request: ask which account first. + Critical Rules: - The selected action MUST be from the actions list. If none suitable, set action_name to "" (empty string). diff --git a/app/agent_base.py b/app/agent_base.py index 8e8068b4..ba93dfbb 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -2272,12 +2272,20 @@ async def _handle_chat_message(self, payload: Dict): trigger_payload["workflow_skills"] = payload["pre_selected_skills"] # Steer the action-selection LLM to use the right platform-specific - # send action when replying. - platform_hint = "" + # send action when replying. The UI case needs an explicit hint + # too: after a platform exchange in the same session, a bare + # message pattern-matches the previous "reply on " + # instruction and the reply leaks to that platform (observed + # live 2026-08-12: web-chat message answered on WhatsApp). if platform and platform.lower() != "craftbot interface": platform_hint = ( f" from {platform} (reply on {platform}, NOT send_message)" ) + else: + platform_hint = ( + " typed in the CraftBot chat interface (reply with " + "send_message, NOT a platform send action)" + ) if is_third_party: platform_hint += ( " — this is a third-party message; you may use the " @@ -3357,11 +3365,27 @@ async def _initialize_external_libraries(self) -> None: "openai_api_key": os.environ.get("OPENAI_API_KEY", ""), }, ) + # gmail/outlook/slack listening is owned by the ListenerManager + # (multi-account fan-out); the legacy manager must not double-listen. + # The other multi-account providers have no listeners, and the remaining legacy + # integrations keep legacy listening. self._external_comms = await initialize_manager( - on_message=self._handle_external_event + on_message=self._handle_external_event, + exclude_platforms=["gmail", "outlook", "slack"], ) logger.info("[EXT LIBS] External integrations configured + manager started") + try: + from app.integrations import start_listeners + + await start_listeners() + logger.info("[EXT LIBS] integrations listener manager started") + except Exception as e: + import traceback + + logger.warning(f"[EXT LIBS] integrations listener manager failed to start: {e}") + logger.debug(f"[EXT LIBS] Traceback: {traceback.format_exc()}") + # ===================================== # Memory at startup # ===================================== diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index cc3dae2c..b371f43d 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -340,6 +340,302 @@ def my_action(input_data): return client, None +# ════════════════════════════════════════════════════════════════════════ +# multi-account integration routing for the management actions +# +# The 10 multi-account providers (gmail, google_calendar, google_docs, google_drive, +# google_youtube, outlook, linkedin, notion, hubspot, slack) get their +# connection state, OAuth connect, token connect, and disconnect from the +# IntegrationSystem — the legacy single-account credential files are never +# read or written for them, except by the one-time upgrade migration +# (legacy file present, no AccountSet document → imported as the first account; +# see IntegrationSystem._migrate_legacy). +# Legacy handlers remain the METADATA source (display name, icon, auth_type, +# description, token field schemas) for all integrations. +# ════════════════════════════════════════════════════════════════════════ + + +def system_for(integration_id: str): + """Return the IntegrationSystem when it knows this provider id. + + Returns None for legacy integrations (or if bootstrap fails), so + callers fall back to the legacy path unchanged. + """ + try: + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is not None: + return system + except Exception: + pass + return None + + +def accounts_payload(accounts) -> list: + """Serialize AccountInfo objects into the structured action-result shape + (same wire shape the settings UI uses — plan §6).""" + return [ + { + "identity": a.identity, + "alias": a.alias, + "isPrimary": a.is_primary, + "listen": a.listen, + } + for a in accounts + ] + + +def account_lines(accounts) -> list: + """Shared status-text format from plan §6: + ``- {alias or identity} ({identity}) [primary]``.""" + lines = [] + for a in accounts: + line = f"- {a.alias or a.identity} ({a.identity})" + if a.is_primary: + line += " [primary]" + lines.append(line) + return lines + + +def v2_display_name(system, integration_id: str) -> str: + """Display name: legacy handler metadata first (still the metadata + source), falling back to the provider's own display_name.""" + try: + from craftos_integrations import get_metadata + + meta = get_metadata(integration_id) + if meta and meta.get("name"): + return meta["name"] + except Exception: + pass + provider = system.registry.get(integration_id) + return getattr(provider, "display_name", None) or integration_id + + +def list_integrations_merged() -> list: + """Metadata + connection status for every integration, with multi-account provider + ids sourcing their connection state and accounts from the + IntegrationSystem instead of the legacy credential files. Legacy + integrations keep the legacy ``handler.status()`` path unchanged. + """ + import asyncio as _asyncio + + from craftos_integrations import get_integration_info, get_metadata, list_all + + async def _gather(): + out = [] + for name in list_all(): + system = system_for(name) + if system is not None: + info = get_metadata(name) + if info is None: + continue + infos = system.list_accounts(name) + info["accounts"] = accounts_payload(infos) + info["connected"] = bool(infos) + else: + info = await get_integration_info(name) + if info: + out.append(info) + return out + + loop = _asyncio.new_event_loop() + try: + return loop.run_until_complete(_gather()) + finally: + loop.close() + + +def _v2_verify_slack_token(credentials: Dict[str, str]): + """Same verification the legacy SlackHandler.login() runs: prefix check + + ``auth.test`` with the bot token; same credential dict shape.""" + from dataclasses import asdict + + from craftos_integrations.integrations.slack import SlackCredential, _slack_call + + bot_token = (credentials.get("bot_token") or "").strip() + if not bot_token.startswith(("xoxb-", "xoxp-")): + return False, "Invalid token. Expected xoxb-... or xoxp-...", None + + result = _slack_call("POST", "auth.test", {"Authorization": f"Bearer {bot_token}"}) + if "error" in result: + return False, f"Slack auth failed: {result['error']}", None + team_id = result.get("team_id", "") + workspace_name = (credentials.get("workspace_name") or "").strip() or result.get( + "team", team_id + ) + credential = asdict( + SlackCredential( + bot_token=bot_token, + workspace_id=team_id, + team_name=workspace_name, + ) + ) + return True, f"Slack connected: {workspace_name} ({team_id})", credential + + +def _v2_verify_notion_token(credentials: Dict[str, str]): + """Same verification the legacy NotionHandler.login() runs: ``GET + /users/me`` with the integration token; same credential dict shape + ({"token": ...} — token-only, so it lands under the LEGACY sentinel + identity until an OAuth re-auth upgrades it, per plan §7).""" + from dataclasses import asdict + + from craftos_integrations.integrations.notion import ( + NOTION_VERSION, + NotionCredential, + _notion_call, + ) + + token = (credentials.get("token") or "").strip() + data = _notion_call( + "GET", + "/users/me", + {"Authorization": f"Bearer {token}", "Notion-Version": NOTION_VERSION}, + ) + if "error" in data: + return False, f"Notion auth failed: {data['error']}", None + ws_name = data.get("bot", {}).get("workspace_name", "default") + credential = asdict(NotionCredential(token=token)) + return True, f"Notion connected: {ws_name}", credential + + +def _v2_verify_hubspot_token(credentials: Dict[str, str]): + """Same verification the legacy HubSpotHandler.login() runs: 'pat-' + prefix check + ``GET /account-info/v3/details``; same credential dict + shape (hub_id captured for the account identity).""" + from dataclasses import asdict + + from craftos_integrations.helpers import request as http_request + from craftos_integrations.integrations.hubspot import ( + HUBSPOT_API, + HubSpotCredential, + ) + + token = (credentials.get("access_token") or "").strip() + if not token.startswith("pat-"): + return False, "Invalid token. Private App tokens start with 'pat-'.", None + + ping = http_request( + "GET", + f"{HUBSPOT_API}/account-info/v3/details", + headers={"Authorization": f"Bearer {token}"}, + expected=(200,), + ) + if "error" in ping: + return False, f"HubSpot auth failed: {ping['error']}", None + meta = ping.get("result") or {} + credential = asdict( + HubSpotCredential( + access_token=token, + hub_id=str(meta.get("portalId", "")), + hub_domain=meta.get("uiDomain", ""), + auth_kind="token", + ) + ) + label = meta.get("uiDomain") or meta.get("portalId") or "HubSpot" + return True, f"HubSpot connected: {label}", credential + + +_V2_TOKEN_VERIFIERS = { + "slack": _v2_verify_slack_token, + "notion": _v2_verify_notion_token, + "hubspot": _v2_verify_hubspot_token, +} + + +def system_connect_token(system, integration_id: str, credentials: Dict[str, str]): + """Manual-token connect for a multi-account provider: validate the token the same + way the legacy handler's ``login()`` does, then store the credential + through the integration system (``store_credential``) — never through the legacy + single-account save. Returns (success, message). + """ + verifier = _V2_TOKEN_VERIFIERS.get(integration_id) + if verifier is None: + # Mirrors legacy IntegrationHandler.connect_token for field-less + # (OAuth-only) integrations. + return ( + False, + f"Token-based login not supported for " + f"{v2_display_name(system, integration_id)}", + ) + try: + ok, message, credential = verifier(credentials) + except Exception as e: + return False, f"{integration_id} token verification failed: {e}" + if not ok or not credential: + return False, message + + from craftos_integrations.contracts import LEGACY_IDENTITY + + provider = system.registry.get(integration_id) + identity = provider.identity_of(credential) or LEGACY_IDENTITY + system.store_credential(integration_id, identity, credential) + # Slack has a listener; reconcile so a fresh token starts listening + # immediately (no-op when no manager is attached / no listener exists). + system.reconcile_listeners() + return True, message + + +def system_disconnect(system, integration_id: str, account_id=None): + """Disconnect a multi-account provider through the IntegrationSystem. + + - With ``account_id``: remove just that account (alias or identity + hints both resolve). Entirely system-managed — legacy has no notion of a + specific account. + - Without: remove ALL accounts, then run the legacy handler logout + as best-effort double-cleanup. Removing the last account also + deletes the legacy credential file (IntegrationSystem prevents the + upgrade migration from resurrecting it), so the legacy logout + normally reports "no credentials found" — it only does real work + when a stray/corrupt legacy file survived. A legacy failure never + masks a successful account removal. + + Returns (success, message). + """ + import asyncio as _asyncio + + if account_id: + try: + identity = system.remove_account(integration_id, account_id) + return True, f"Removed account '{identity}' from {integration_id}." + except Exception as e: + return False, str(e) + + removed = [] + for info in system.list_accounts(integration_id): + try: + system.remove_account(integration_id, info.identity) + removed.append(info.alias or info.identity) + except Exception: + pass + + legacy_success, legacy_message = False, "" + try: + from craftos_integrations import disconnect as _legacy_disconnect + + loop = _asyncio.new_event_loop() + try: + legacy_success, legacy_message = loop.run_until_complete( + _legacy_disconnect(integration_id) + ) + finally: + loop.close() + except Exception as e: + legacy_message = str(e) + + if removed: + return ( + True, + f"Disconnected {integration_id}: removed " + f"{len(removed)} account(s) ({', '.join(removed)}).", + ) + # Nothing in the integration system — surface the legacy result unchanged (matches the old + # behavior for "not connected" and for stray legacy-only files). + return legacy_success, legacy_message + + async def with_client( integration: str, fn: Callable, *args, **kwargs ) -> Dict[str, Any]: diff --git a/app/data/action/integrations/_integration_essentials.py b/app/data/action/integrations/_integration_essentials.py index 0e69482e..1337bd5e 100644 --- a/app/data/action/integrations/_integration_essentials.py +++ b/app/data/action/integrations/_integration_essentials.py @@ -2,16 +2,32 @@ """Inject just-in-time integration guidance into the routing-time prompt. When a user message mentions an integration by name (e.g. "send a whatsapp -message..."), this helper looks up the integration's ``INTEGRATION.md`` and -extracts its ``## Essentials`` block. That block goes into the routing -prompt so the routing-time LLM has the workflow rules in context BEFORE -deciding what to do — instead of asking the user for info the integration -could look up itself. - -The match is intentionally loose (case-insensitive substring against -integration ids + display names + first tokens). False positives are -cheap (~200 tokens of extra context); false negatives are the whole -reason this exists. +message...") — or by a natural bare word like "calendar" / "docs" — this +helper looks up the integration's guidance and injects it into the routing +prompt, so the routing-time LLM has the workflow rules in context BEFORE +deciding what to do. + +Guidance sources, in order: + 1. ``craftos_integrations/providers//GUIDANCE.md`` — multi-account + providers (the file is already essentials-sized and includes the + multi-account rules: extract account qualifiers like "my school + calendar" into the ``account`` param). + 2. ``craftos_integrations/integrations//INTEGRATION.md`` ``## + Essentials`` block, or ``.md`` — legacy integrations. + +Matching rules: + - Keys match on WORD BOUNDARIES, not substrings — "drive" fires, but + "driver" / "hard drive to the airport" wordplay like "doctor" for + "doc" does not. + - Multi-token ids contribute their meaningful tokens as keys, so bare + "calendar" / "docs" / "drive" / "youtube" work (historically only the + full "google calendar" form matched — the guidance never fired for + the most natural phrasing). + - A bare token may map to several integrations ("calendar" → + google_calendar AND lark_calendar). If connection state is available, + only connected ones are injected; if none are connected (or state is + unavailable, e.g. before the registry is populated), all are — false + positives are cheap, false negatives are the whole reason this exists. """ from __future__ import annotations @@ -20,62 +36,74 @@ from pathlib import Path from typing import Dict, List, Optional -# Project root → ``craftos_integrations/integrations//INTEGRATION.md``. -# This file is at app/data/action/integrations/_integration_essentials.py -# → parents[4] is the project root. -_INTEGRATIONS_ROOT = ( - Path(__file__).resolve().parents[4] / "craftos_integrations" / "integrations" -) - -# Built lazily on first call so we don't import the registry at module load. -_KEYWORD_INDEX: Optional[Dict[str, str]] = None +# Project root → craftos_integrations/{integrations,providers}/... +_PACKAGE_ROOT = Path(__file__).resolve().parents[4] / "craftos_integrations" +_INTEGRATIONS_ROOT = _PACKAGE_ROOT / "integrations" +_PROVIDERS_ROOT = _PACKAGE_ROOT / "providers" +# Tokens too generic to serve as bare keywords ("user" would fire on +# nearly every message; "telegram_user" is still matched via its full id). +_TOKEN_STOPLIST = {"bot", "user", "business", "web", "oauth", "llm", "shared"} -def _build_keyword_index() -> Dict[str, str]: - """Map keyword variants → integration id. - - Scans ``craftos_integrations/integrations/`` and treats each - non-underscore-prefixed subdirectory OR ``.py`` file as an - integration id. Doing the file-system scan (rather than calling - ``integration_registry()``) sidesteps a startup ordering issue - where the registry isn't populated by the time the router fires - its first call. +# Built lazily on first call so we don't import the registry at module load. +_KEYWORD_INDEX: Optional[Dict[str, List[str]]] = None - Shorter ids are processed first so a generic keyword like "lark" - binds to ``lark``, not ``lark_calendar`` (specific integrations - keep their own ids as keys — the generic key just doesn't get - overwritten). - """ - if not _INTEGRATIONS_ROOT.is_dir(): - return {} - integration_ids: List[str] = [] - for child in _INTEGRATIONS_ROOT.iterdir(): - name = child.name - if name.startswith(("_", ".")) or name == "__pycache__": +def _integration_ids() -> List[str]: + """Union of legacy integration ids and multi-account provider ids (fs scan — no + registry import, sidestepping the startup-ordering issue).""" + ids: List[str] = [] + for root in (_INTEGRATIONS_ROOT, _PROVIDERS_ROOT): + if not root.is_dir(): continue - if child.is_dir(): - integration_ids.append(name) - elif child.suffix == ".py": - integration_ids.append(child.stem) - - # Shorter ids first → generic keys (e.g. "lark") land on the simpler one. - integration_ids.sort(key=len) - - index: Dict[str, str] = {} - for integration_id in integration_ids: - keys = {integration_id, integration_id.replace("_", " ")} - first_token = integration_id.split("_", 1)[0] - if first_token != integration_id: - keys.add(first_token) - for key in keys: - key = key.lower().strip() - if key: - index.setdefault(key, integration_id) + for child in root.iterdir(): + name = child.name + if name.startswith(("_", ".")) or name == "__pycache__": + continue + if child.is_dir(): + ids.append(name) + elif child.suffix == ".py": + ids.append(child.stem) + # De-dup, shorter first → generic keys (e.g. "lark") land on the + # simpler id via the setdefault below. + return sorted(set(ids), key=len) + + +def _build_keyword_index() -> Dict[str, List[str]]: + """Map keyword → integration ids it may refer to.""" + index: Dict[str, List[str]] = {} + + def add(key: str, integration_id: str) -> None: + key = key.lower().strip() + if not key: + return + ids = index.setdefault(key, []) + if integration_id not in ids: + ids.append(integration_id) + + for integration_id in _integration_ids(): + add(integration_id, integration_id) + add(integration_id.replace("_", " "), integration_id) + tokens = integration_id.split("_") + if len(tokens) > 1: + for token in tokens: + if token not in _TOKEN_STOPLIST: + add(token, integration_id) + # Natural-language synonyms that no id/token covers ("my job email" + # names gmail/outlook without saying either). Ambiguity is fine — the + # connection filter narrows multi-id keys to connected integrations. + for keyword, ids in { + "email": ("gmail", "outlook"), + "inbox": ("gmail", "outlook"), + "mailbox": ("gmail", "outlook"), + "crm": ("hubspot",), + }.items(): + for integration_id in ids: + add(keyword, integration_id) return index -def _get_keyword_index() -> Dict[str, str]: +def _get_keyword_index() -> Dict[str, List[str]]: global _KEYWORD_INDEX if _KEYWORD_INDEX is None: try: @@ -85,14 +113,73 @@ def _get_keyword_index() -> Dict[str, str]: return _KEYWORD_INDEX -def _extract_essentials(integration_id: str) -> Optional[str]: - """Extract the ``## Essentials`` block from an integration's docs. +def _is_connected(integration_id: str) -> Optional[bool]: + """Best-effort connection check; None = state unavailable.""" + try: + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is not None: + return bool(system.list_accounts(integration_id)) + except Exception: + pass + try: + from craftos_integrations import service as legacy_service + + return bool(legacy_service.is_connected(integration_id)) + except Exception: + return None + + +def _filter_by_connection(ids: List[str]) -> List[str]: + """Prefer connected integrations when several share a keyword; keep + everything if none are (or state can't be read).""" + if len(ids) < 2: + return ids + connected = [i for i in ids if _is_connected(i)] + return connected or ids + + +def _connected_accounts_note(integration_id: str) -> str: + """Live account list for multi-account integrations, appended to the + injected essentials so the router can map natural phrasing ("my job + email") to the right alias/identity on the FIRST call instead of + learning the accounts from a resolution error. Costs a line per + account, only on turns that mention this integration.""" + try: + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is None: + return "" + infos = system.list_accounts(integration_id) + if not infos: + return "" + lines = ", ".join( + i.identity + + (f' (alias: "{i.alias}")' if i.alias else "") + + (" [primary]" if i.is_primary else "") + for i in infos + ) + return ( + f"\nConnected accounts: {lines}. When the user's phrasing points " + f"at one of these (semantically, not just literally), pass its " + f"alias or identity as `account`." + ) + except Exception: + return "" - Looks in two places, in order: - 1. ``/INTEGRATION.md`` (directory-style; used by integrations - that are themselves a directory, e.g. whatsapp_web with its bridge). - 2. ``.md`` (sibling file; used by single-file integrations). - """ + +def _extract_essentials(integration_id: str) -> Optional[str]: + """Load guidance for one integration (provider GUIDANCE.md first).""" + v2_guidance = _PROVIDERS_ROOT / integration_id / "GUIDANCE.md" + if v2_guidance.is_file(): + try: + text = v2_guidance.read_text(encoding="utf-8").strip() + if text: + return text + except OSError: + pass candidates = [ _INTEGRATIONS_ROOT / integration_id / "INTEGRATION.md", _INTEGRATIONS_ROOT / f"{integration_id}.md", @@ -127,24 +214,34 @@ def get_essentials_for_message(message: str) -> str: if not keyword_index: return "" lower = message.lower() - # Longer keys first so e.g. "telegram_user" wins over a bare "telegram". + # Longer keys first so e.g. "google calendar" wins before bare "calendar". sorted_keys = sorted(keyword_index.keys(), key=len, reverse=True) matched_ids: List[str] = [] + matched_keys: List[str] = [] seen: set = set() for key in sorted_keys: - integration_id = keyword_index[key] - if integration_id in seen: + # A generic key inside an already-matched specific one adds noise, + # not signal: "google docs" matched → bare "google" (which maps to + # every google_* id) must not drag in calendar/drive/youtube. + if any(key in matched for matched in matched_keys): + continue + if not re.search(rf"(? List[str]: + """Connected platform ids: multi-account provider ids are decided by the + IntegrationSystem (connected = has at least one account); everything + else keeps the legacy credential-file check.""" + try: + from app.integrations import get_system + + system = get_system() + v2_ids = {p.id for p in system.providers()} + except Exception: + system, v2_ids = None, set() + + out: List[str] = [pid for pid in list_connected() if pid not in v2_ids] + if system is not None: + for pid in sorted(v2_ids): + try: + if system.list_accounts(pid): + out.append(pid) + except Exception: + pass + return out + + def get_messaging_actions_for_connected() -> List[str]: """Action names to expose given current credential state. Deduped, order-preserving.""" seen = set() out: List[str] = [] - for platform_id in list_connected(): + for platform_id in _list_connected_merged(): for name in PLATFORM_CONVERSATION_ACTIONS.get(platform_id, []): if name not in seen: seen.add(name) diff --git a/app/data/action/integrations/craftbot_adapter.py b/app/data/action/integrations/craftbot_adapter.py new file mode 100644 index 00000000..52cb26de --- /dev/null +++ b/app/data/action/integrations/craftbot_adapter.py @@ -0,0 +1,121 @@ +"""Generated agent actions for every integration provider. + +This file replaces the ten hand-maintained action files (gmail, calendar, +docs, drive, youtube, outlook, linkedin, notion, hubspot, slack). At +import time (action discovery) it walks ``default_providers()`` and +registers one ``@action`` per Operation: + + - schema = the operation's input_schema + the injected ``account`` + property. Injection happens HERE, once, for every action — a provider + cannot ship an action that silently ignores account selection (the + defect that sank the previous multi-account attempt). + - execution routes through ``IntegrationSystem.execute()``, which + resolves ``account`` (email / alias / unique fragment, empty = primary + account) to one connected account and runs the operation against that + account's client. + - resolution failures come back as the standard + ``{"status": "error", "message": ...}`` dict, worded so the model can + self-correct (they enumerate the connected accounts). + - the operation's ``destructive`` flag maps to ``irreversible`` so the + activity ledger never silently re-executes sends/deletes after a + crash. +""" + +from __future__ import annotations + +from typing import Any, Dict + +from agent_core import action + +from craftos_integrations.contracts import Operation, Provider + + +def _account_schema(provider: Provider) -> Dict[str, Any]: + name = getattr(provider, "display_name", "") or provider.id + return { + "type": "string", + "description": ( + f"Optional {name} account to act as: an email/identity, the " + f"user's nickname for the account (e.g. 'work'), or any unique " + f"fragment of either. OMIT to use the primary account. Always " + f"set this when the user names an account in any form." + ), + "example": "", + } + + +def _make_handler(provider_id: str, op_name: str): + """Build the action handler AND its exec-able source. + + The action system never calls the registered function directly: the + registry extracts its SOURCE (``inspect.getsource``, or the + ``_mcp_source_code`` attribute when present) and the executor + ``exec()``s that string in a fresh namespace. A closure would lose its + cell variables in that round-trip — every call failed with "name + 'provider_id' is not defined" (observed live 2026-08-12) — so, like + the MCP adapter, the source is generated with the ids baked in as + literals and stored on the function for the registry to pick up. + """ + source = f'''async def handler(input_data: dict) -> dict: + """integration operation {provider_id}/{op_name}.""" + from app.integrations import get_system + + _provider_id = "{provider_id}" + _op_name = "{op_name}" + + # Strip the routing hint and internal parameters (e.g. _session_id); + # everything else is the operation's payload. + payload = {{ + k: v + for k, v in input_data.items() + if k != "account" and not k.startswith("_") + }} + try: + result = await get_system().execute( + _provider_id, _op_name, payload, account=input_data.get("account") + ) + except Exception as e: + # AccountResolutionError / LookupError / anything else -- the + # action contract is an error dict, never a raised exception. + return {{"status": "error", "message": str(e)}} + if result.get("status") != "error": + try: + from app.ui_layer.metrics.collector import MetricsCollector + + collector = MetricsCollector.get_instance() + if collector: + collector.record_integration_call(_provider_id) + except Exception: + pass + return result +''' + namespace: Dict[str, Any] = {} + exec(source, namespace) + handler = namespace["handler"] + handler._mcp_source_code = source + return handler + + +def _register(provider: Provider, op: Operation) -> None: + input_schema = dict(op.input_schema) + input_schema["account"] = _account_schema(provider) + action( + name=op.name, + description=op.description, + action_sets=list(op.tags), + input_schema=input_schema, + output_schema=op.output_schema, + parallelizable=op.parallelizable, + irreversible=op.destructive, + )(_make_handler(provider.id, op.name)) + + +def _register_all() -> None: + from craftos_integrations.providers import default_providers + + for provider in default_providers(): + for op in provider.operations(): + _register(provider, op) + + +_register_all() diff --git a/app/data/action/integrations/google_workspace/gmail_actions.py b/app/data/action/integrations/google_workspace/gmail_actions.py deleted file mode 100644 index 9f08a6ec..00000000 --- a/app/data/action/integrations/google_workspace/gmail_actions.py +++ /dev/null @@ -1,1160 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# Mail — send / list / get / search / reply / forward / lifecycle -# ------------------------------------------------------------------ - - -@action( - name="send_gmail", - irreversible=True, - description="Send an email via Gmail.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "to": { - "type": "string", - "description": ( - "Recipient email address. OMIT to send to the user's own " - "address (the connected account) — never store or guess the " - "user's email." - ), - "example": "user@example.com", - }, - "subject": { - "type": "string", - "description": "Email subject.", - "example": "Meeting Follow-up", - }, - "body": { - "type": "string", - "description": "Email body text.", - "example": "Hi, here are the notes...", - }, - "attachments": { - "type": "array", - "description": "Optional list of file paths to attach.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "send_email", - unwrap_envelope=True, - success_message="Email sent.", - fail_message="Failed to send email.", - # Omitted/empty `to` → the client sends to the account owner. - to=input_data.get("to"), - subject=input_data["subject"], - body=input_data["body"], - attachments=input_data.get("attachments"), - ) - - -@action( - name="list_gmail", - description="List recent emails from Gmail inbox.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "count": { - "type": "integer", - "description": "Number of recent emails to list.", - "example": 5, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "list_emails", - unwrap_envelope=True, - fail_message="Failed to list emails.", - n=input_data.get("count", 5), - ) - - -@action( - name="get_gmail", - description=( - "Get details of a specific Gmail message by ID. " - "When full_body=true the response includes body text and an attachments list " - "(each entry: attachment_id, filename, mimeType, size). " - "Use attachment_id and filename with download_gmail_attachment." - ), - action_sets=["gmail_mail", "gmail"], - input_schema={ - "message_id": { - "type": "string", - "description": "Gmail message ID.", - "example": "18abc123def", - }, - "full_body": { - "type": "boolean", - "description": "Whether to include full email body and attachment metadata.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "get_email", - unwrap_envelope=True, - fail_message="Failed to get email.", - message_id=input_data["message_id"], - full_body=input_data.get("full_body", False), - ) - - -@action( - name="read_top_emails", - description="Read the top N recent emails with details.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "count": { - "type": "integer", - "description": "Number of emails to read.", - "example": 5, - }, - "full_body": { - "type": "boolean", - "description": "Include full body text.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def read_top_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "read_top_emails", - unwrap_envelope=True, - fail_message="Failed to read emails.", - n=input_data.get("count", 5), - full_body=input_data.get("full_body", False), - ) - - -@action( - name="search_gmail", - description="Search Gmail using Gmail's q syntax (e.g. 'from:alice subject:invoice newer_than:7d has:attachment').", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "query": { - "type": "string", - "description": "Gmail q query.", - "example": "from:alice@example.com is:unread", - }, - "max_results": { - "type": "integer", - "description": "Max results.", - "example": 25, - }, - "include_spam_trash": { - "type": "boolean", - "description": "Include Spam/Trash.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "search_messages", - unwrap_envelope=True, - fail_message="Failed to search.", - query=input_data["query"], - max_results=input_data.get("max_results", 25), - include_spam_trash=bool(input_data.get("include_spam_trash", False)), - ) - - -@action( - name="reply_gmail", - irreversible=True, - description="Reply to a Gmail message. Preserves thread + In-Reply-To/References headers. Set reply_all=true to also CC the original To/Cc.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "", - }, - "body": {"type": "string", "description": "Reply text.", "example": ""}, - "reply_all": { - "type": "boolean", - "description": "Reply-all (CC original recipients).", - "example": False, - }, - "attachments": { - "type": "array", - "description": "Optional attachment file paths.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def reply_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "reply_to_message", - unwrap_envelope=True, - fail_message="Failed to reply.", - message_id=input_data["message_id"], - body=input_data["body"], - reply_all=bool(input_data.get("reply_all", False)), - attachments=input_data.get("attachments"), - ) - - -@action( - name="forward_gmail", - irreversible=True, - description="Forward a Gmail message to another address.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "", - }, - "to": { - "type": "string", - "description": "Recipient email.", - "example": "bob@example.com", - }, - "body": { - "type": "string", - "description": "Optional intro text.", - "example": "", - }, - "attachments": { - "type": "array", - "description": "Optional attachment file paths.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def forward_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "forward_message", - unwrap_envelope=True, - fail_message="Failed to forward.", - message_id=input_data["message_id"], - to=input_data["to"], - body=input_data.get("body", ""), - attachments=input_data.get("attachments"), - ) - - -@action( - name="modify_gmail_labels", - description="Add/remove labels on a Gmail message. Common label IDs: INBOX, UNREAD, STARRED, IMPORTANT, TRASH, SPAM, CATEGORY_PERSONAL.", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "add_label_ids": { - "type": "array", - "description": "Label IDs to add.", - "example": ["STARRED"], - }, - "remove_label_ids": { - "type": "array", - "description": "Label IDs to remove.", - "example": ["UNREAD"], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def modify_gmail_labels(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "modify_message_labels", - unwrap_envelope=True, - fail_message="Failed to modify labels.", - message_id=input_data["message_id"], - add_label_ids=input_data.get("add_label_ids"), - remove_label_ids=input_data.get("remove_label_ids"), - ) - - -@action( - name="trash_gmail", - description="Move a Gmail message to Trash (soft delete; recoverable for 30 days).", - action_sets=["gmail_mail", "gmail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def trash_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "trash_message", - unwrap_envelope=True, - fail_message="Failed to trash.", - message_id=input_data["message_id"], - ) - - -@action( - name="untrash_gmail", - description="Recover a Gmail message from Trash.", - action_sets=["gmail_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def untrash_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "untrash_message", - unwrap_envelope=True, - fail_message="Failed to untrash.", - message_id=input_data["message_id"], - ) - - -@action( - name="delete_gmail", - description="Permanently delete a Gmail message. Irreversible. Prefer trash_gmail for soft delete.", - action_sets=["gmail_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "delete_message", - unwrap_envelope=True, - fail_message="Failed to delete.", - message_id=input_data["message_id"], - ) - - -@action( - name="batch_modify_gmail", - description="Bulk add/remove labels across multiple messages in one call.", - action_sets=["gmail_mail"], - input_schema={ - "message_ids": { - "type": "array", - "description": "List of message IDs.", - "example": [], - }, - "add_label_ids": { - "type": "array", - "description": "Label IDs to add.", - "example": [], - }, - "remove_label_ids": { - "type": "array", - "description": "Label IDs to remove.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def batch_modify_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "batch_modify_messages", - unwrap_envelope=True, - fail_message="Failed to batch modify.", - message_ids=input_data["message_ids"], - add_label_ids=input_data.get("add_label_ids"), - remove_label_ids=input_data.get("remove_label_ids"), - ) - - -@action( - name="batch_delete_gmail", - description="Permanently delete multiple messages. Irreversible.", - action_sets=["gmail_mail"], - input_schema={ - "message_ids": { - "type": "array", - "description": "List of message IDs.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def batch_delete_gmail(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "batch_delete_messages", - unwrap_envelope=True, - fail_message="Failed to batch delete.", - message_ids=input_data["message_ids"], - ) - - -# ------------------------------------------------------------------ -# Threads -# ------------------------------------------------------------------ - - -@action( - name="list_gmail_threads", - description="List Gmail conversation threads.", - action_sets=["gmail_threads", "gmail"], - input_schema={ - "query": { - "type": "string", - "description": "Optional Gmail q query.", - "example": "", - }, - "label_ids": { - "type": "array", - "description": "Optional label filter.", - "example": ["INBOX"], - }, - "max_results": { - "type": "integer", - "description": "Max threads.", - "example": 25, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_gmail_threads(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "list_threads", - unwrap_envelope=True, - fail_message="Failed to list threads.", - query=input_data.get("query") or None, - label_ids=input_data.get("label_ids"), - max_results=input_data.get("max_results", 25), - ) - - -@action( - name="get_gmail_thread", - description="Get a thread (conversation) and its messages. Default returns per-message {id, from, to, subject, date, snippet}; set include_metadata for the raw thread.", - action_sets=["gmail_threads", "gmail"], - input_schema={ - "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, - "fmt": { - "type": "string", - "description": "metadata | full | minimal.", - "example": "metadata", - }, - "include_metadata": { - "type": "boolean", - "description": "Return the raw thread resource (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_gmail_thread(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "gmail", - "get_thread", - unwrap_envelope=True, - fail_message="Failed to get thread.", - thread_id=input_data["thread_id"], - fmt=input_data.get("fmt", "metadata"), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - thread = res.get("result") - if isinstance(thread, dict): - lean_messages = [] - for msg in thread.get("messages", []) or []: - if not isinstance(msg, dict): - continue - headers = { - h.get("name", ""): h.get("value", "") - for h in msg.get("payload", {}).get("headers", []) - } - lean_messages.append( - { - "id": msg.get("id"), - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), - "snippet": msg.get("snippet", ""), - } - ) - res = { - **res, - "result": {"id": thread.get("id"), "messages": lean_messages}, - } - return res - - -@action( - name="modify_gmail_thread_labels", - description="Add/remove labels on every message in a thread.", - action_sets=["gmail_threads"], - input_schema={ - "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, - "add_label_ids": { - "type": "array", - "description": "Labels to add.", - "example": [], - }, - "remove_label_ids": { - "type": "array", - "description": "Labels to remove.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def modify_gmail_thread_labels(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "modify_thread_labels", - unwrap_envelope=True, - fail_message="Failed to modify thread labels.", - thread_id=input_data["thread_id"], - add_label_ids=input_data.get("add_label_ids"), - remove_label_ids=input_data.get("remove_label_ids"), - ) - - -@action( - name="trash_gmail_thread", - description="Move an entire Gmail thread to Trash.", - action_sets=["gmail_threads"], - input_schema={ - "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def trash_gmail_thread(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "trash_thread", - unwrap_envelope=True, - fail_message="Failed to trash thread.", - thread_id=input_data["thread_id"], - ) - - -@action( - name="untrash_gmail_thread", - description="Recover a Gmail thread from Trash.", - action_sets=["gmail_threads"], - input_schema={ - "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def untrash_gmail_thread(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "untrash_thread", - unwrap_envelope=True, - fail_message="Failed to untrash thread.", - thread_id=input_data["thread_id"], - ) - - -@action( - name="delete_gmail_thread", - description="Permanently delete a Gmail thread (all messages). Irreversible.", - action_sets=["gmail_threads"], - input_schema={ - "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_gmail_thread(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "delete_thread", - unwrap_envelope=True, - fail_message="Failed to delete thread.", - thread_id=input_data["thread_id"], - ) - - -# ------------------------------------------------------------------ -# Drafts -# ------------------------------------------------------------------ - - -@action( - name="list_gmail_drafts", - description="List Gmail drafts.", - action_sets=["gmail_drafts", "gmail"], - input_schema={ - "max_results": {"type": "integer", "description": "Max drafts.", "example": 25}, - "query": {"type": "string", "description": "Optional q query.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_gmail_drafts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "list_drafts", - unwrap_envelope=True, - fail_message="Failed to list drafts.", - max_results=input_data.get("max_results", 25), - query=input_data.get("query") or None, - ) - - -@action( - name="get_gmail_draft", - description="Get a Gmail draft by ID. Default returns {id, message_id, to, subject, snippet}; set include_metadata for the raw draft.", - action_sets=["gmail_drafts"], - input_schema={ - "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, - "fmt": { - "type": "string", - "description": "metadata | full | minimal.", - "example": "metadata", - }, - "include_metadata": { - "type": "boolean", - "description": "Return the raw draft resource (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_gmail_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "gmail", - "get_draft", - unwrap_envelope=True, - fail_message="Failed to get draft.", - draft_id=input_data["draft_id"], - fmt=input_data.get("fmt", "metadata"), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - draft = res.get("result") - if isinstance(draft, dict): - msg = draft.get("message") or {} - headers = { - h.get("name", ""): h.get("value", "") - for h in msg.get("payload", {}).get("headers", []) - } - res = { - **res, - "result": { - "id": draft.get("id"), - "message_id": msg.get("id"), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "snippet": msg.get("snippet", ""), - }, - } - return res - - -@action( - name="create_gmail_draft", - description="Create a Gmail draft (not sent). Returns the draft ID for later edit/send.", - action_sets=["gmail_drafts", "gmail"], - input_schema={ - "to": {"type": "string", "description": "Recipient.", "example": ""}, - "subject": {"type": "string", "description": "Subject.", "example": ""}, - "body": {"type": "string", "description": "Body text.", "example": ""}, - "cc": {"type": "string", "description": "Optional CC.", "example": ""}, - "bcc": {"type": "string", "description": "Optional BCC.", "example": ""}, - "attachments": { - "type": "array", - "description": "Local file paths.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_gmail_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "create_draft", - unwrap_envelope=True, - fail_message="Failed to create draft.", - to=input_data["to"], - subject=input_data["subject"], - body=input_data["body"], - cc=input_data.get("cc") or None, - bcc=input_data.get("bcc") or None, - attachments=input_data.get("attachments"), - ) - - -@action( - name="update_gmail_draft", - description="Replace a Gmail draft's content. All fields are required (PUT semantics).", - action_sets=["gmail_drafts"], - input_schema={ - "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, - "to": {"type": "string", "description": "Recipient.", "example": ""}, - "subject": {"type": "string", "description": "Subject.", "example": ""}, - "body": {"type": "string", "description": "Body text.", "example": ""}, - "cc": {"type": "string", "description": "Optional CC.", "example": ""}, - "bcc": {"type": "string", "description": "Optional BCC.", "example": ""}, - "attachments": { - "type": "array", - "description": "Local file paths.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_gmail_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "update_draft", - unwrap_envelope=True, - fail_message="Failed to update draft.", - draft_id=input_data["draft_id"], - to=input_data["to"], - subject=input_data["subject"], - body=input_data["body"], - cc=input_data.get("cc") or None, - bcc=input_data.get("bcc") or None, - attachments=input_data.get("attachments"), - ) - - -@action( - name="send_gmail_draft", - irreversible=True, - description="Send a previously-created Gmail draft.", - action_sets=["gmail_drafts", "gmail"], - input_schema={ - "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_gmail_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "send_draft", - unwrap_envelope=True, - fail_message="Failed to send draft.", - draft_id=input_data["draft_id"], - ) - - -@action( - name="delete_gmail_draft", - description="Permanently delete a Gmail draft.", - action_sets=["gmail_drafts"], - input_schema={ - "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_gmail_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "delete_draft", - unwrap_envelope=True, - fail_message="Failed to delete draft.", - draft_id=input_data["draft_id"], - ) - - -# ------------------------------------------------------------------ -# Labels -# ------------------------------------------------------------------ - - -@action( - name="list_gmail_labels", - description="List all Gmail labels (system + user).", - action_sets=["gmail_labels", "gmail"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_gmail_labels(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "list_labels", - unwrap_envelope=True, - fail_message="Failed to list labels.", - ) - - -@action( - name="get_gmail_label", - description="Get a single Gmail label by ID.", - action_sets=["gmail_labels"], - input_schema={ - "label_id": {"type": "string", "description": "Label ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_gmail_label(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "get_label", - unwrap_envelope=True, - fail_message="Failed to get label.", - label_id=input_data["label_id"], - ) - - -@action( - name="create_gmail_label", - description="Create a new user label. label_list_visibility: labelShow|labelShowIfUnread|labelHide. message_list_visibility: show|hide.", - action_sets=["gmail_labels", "gmail"], - input_schema={ - "name": { - "type": "string", - "description": "Label name (use '/' for nesting, e.g. 'Work/Clients').", - "example": "Receipts", - }, - "label_list_visibility": { - "type": "string", - "description": "labelShow / labelShowIfUnread / labelHide.", - "example": "labelShow", - }, - "message_list_visibility": { - "type": "string", - "description": "show / hide.", - "example": "show", - }, - "background_color": { - "type": "string", - "description": "Hex color (optional, requires text_color).", - "example": "", - }, - "text_color": { - "type": "string", - "description": "Hex color (optional, requires background_color).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_gmail_label(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "create_label", - unwrap_envelope=True, - fail_message="Failed to create label.", - name=input_data["name"], - label_list_visibility=input_data.get("label_list_visibility", "labelShow"), - message_list_visibility=input_data.get("message_list_visibility", "show"), - background_color=input_data.get("background_color") or None, - text_color=input_data.get("text_color") or None, - ) - - -@action( - name="update_gmail_label", - description="Update (rename / recolor) a Gmail label.", - action_sets=["gmail_labels"], - input_schema={ - "label_id": {"type": "string", "description": "Label ID.", "example": ""}, - "name": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "label_list_visibility": { - "type": "string", - "description": "labelShow / labelShowIfUnread / labelHide.", - "example": "", - }, - "message_list_visibility": { - "type": "string", - "description": "show / hide.", - "example": "", - }, - "background_color": { - "type": "string", - "description": "Hex color (optional).", - "example": "", - }, - "text_color": { - "type": "string", - "description": "Hex color (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_gmail_label(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "update_label", - unwrap_envelope=True, - fail_message="Failed to update label.", - label_id=input_data["label_id"], - name=input_data.get("name") or None, - label_list_visibility=input_data.get("label_list_visibility") or None, - message_list_visibility=input_data.get("message_list_visibility") or None, - background_color=input_data.get("background_color") or None, - text_color=input_data.get("text_color") or None, - ) - - -@action( - name="delete_gmail_label", - description="Delete a Gmail label (also removes it from all messages/threads).", - action_sets=["gmail_labels"], - input_schema={ - "label_id": {"type": "string", "description": "Label ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_gmail_label(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "delete_label", - unwrap_envelope=True, - fail_message="Failed to delete label.", - label_id=input_data["label_id"], - ) - - -# ------------------------------------------------------------------ -# Attachments + profile -# ------------------------------------------------------------------ - - -@action( - name="download_gmail_attachment", - description=( - "Download a Gmail attachment to a local path. " - "First call get_gmail with full_body=true to get the attachments list — " - "each entry has attachment_id and filename. " - "Pass save_to as a directory path and filename separately, or as a full file path." - ), - action_sets=["gmail_attachments", "gmail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "attachment_id": { - "type": "string", - "description": "Attachment ID from get_gmail(full_body=true).attachments[].attachment_id.", - "example": "", - }, - "save_to": { - "type": "string", - "description": "Local path to save to. May be a directory; use filename to set the file name.", - "example": "C:/Users/me/downloads/", - }, - "filename": { - "type": "string", - "description": "Filename to use when save_to is a directory. Use the filename from get_gmail attachments list.", - "example": "invoice.pdf", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def download_gmail_attachment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "download_attachment", - unwrap_envelope=True, - fail_message="Failed to download attachment.", - message_id=input_data["message_id"], - attachment_id=input_data["attachment_id"], - save_to=input_data["save_to"], - filename=input_data.get("filename"), - ) - - -@action( - 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={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_gmail_profile(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "get_profile", - unwrap_envelope=True, - fail_message="Failed to get profile.", - ) - - -# ------------------------------------------------------------------ -# Backwards-compat aliases (legacy action names — kept for skills/memory) -# ------------------------------------------------------------------ - - -@action( - name="send_google_workspace_email", - irreversible=True, - description="Send email via Google Workspace.", - action_sets=["gmail_mail"], - input_schema={ - "to_email": { - "type": "string", - "description": "Recipient.", - "example": "user@example.com", - }, - "subject": {"type": "string", "description": "Subject.", "example": "Hello"}, - "body": {"type": "string", "description": "Body.", "example": "Hi"}, - "from_email": { - "type": "string", - "description": "Optional sender email.", - "example": "me@example.com", - }, - "attachments": {"type": "array", "description": "Attachments.", "example": []}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_google_workspace_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "send_email", - unwrap_envelope=True, - success_message="Email sent.", - fail_message="Failed to send email.", - to=input_data["to_email"], - subject=input_data["subject"], - body=input_data["body"], - from_email=input_data.get("from_email"), - attachments=input_data.get("attachments"), - ) - - -@action( - name="read_recent_google_workspace_emails", - description="Read recent emails.", - action_sets=["gmail_mail"], - input_schema={ - "n": {"type": "integer", "description": "Count.", "example": 5}, - "full_body": {"type": "boolean", "description": "Full body.", "example": False}, - "from_email": { - "type": "string", - "description": "Optional sender email.", - "example": "me@example.com", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def read_recent_google_workspace_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "gmail", - "read_top_emails", - unwrap_envelope=True, - fail_message="Failed to read emails.", - n=input_data.get("n", 5), - full_body=input_data.get("full_body", False), - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - History API (users.history.list) -# Incremental sync plumbing. The listener uses it internally. -# - Watch / push notifications (users.watch, users.stop) -# Cloud Pub/Sub webhook setup; server-side infrastructure. -# - Settings (users.settings.*): vacation, filters, forwarding, sendAs, smimeInfo, cse -# Each is a separate admin-style sub-resource. Could be added as -# gmail_settings if needed. For an assistant, ad-hoc rules are -# usually managed in the Gmail UI rather than via API. -# - Drafts.list with format=full -# The metadata format works for the common "list and resume" case. -# - Messages.import / messages.insert (raw upload of an existing email) -# Migration tooling, not interactive use. diff --git a/app/data/action/integrations/google_workspace/google_calendar_actions.py b/app/data/action/integrations/google_workspace/google_calendar_actions.py deleted file mode 100644 index f28ab19a..00000000 --- a/app/data/action/integrations/google_workspace/google_calendar_actions.py +++ /dev/null @@ -1,1338 +0,0 @@ -from agent_core import action - - -def _lean_gcal_event(ev: dict) -> dict: - """Reduce a raw Calendar Event resource to the fields an agent acts on. - - NOTE: action handlers run via exec() on extracted source, so handlers - import this by full module path inside the function body (module-level - names are not in scope at handler runtime). - """ - out = { - k: ev.get(k) - for k in ( - "id", - "summary", - "description", - "location", - "start", - "end", - "status", - "recurrence", - "recurringEventId", - "htmlLink", - "hangoutLink", - ) - if ev.get(k) is not None - } - attendees = ev.get("attendees") - if attendees: - out["attendees"] = [ - { - k: a.get(k) - for k in ("email", "displayName", "responseStatus", "organizer") - if a.get(k) is not None - } - for a in attendees - if isinstance(a, dict) - ] - return out - - -# ------------------------------------------------------------------ -# Convenience helpers (kept as-is for backwards-compat) -# ------------------------------------------------------------------ - - -@action( - name="create_google_meet", - description="Create a Google Calendar event with a Google Meet link. Returns id, hangoutLink + key fields.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_data": { - "type": "object", - "description": "Calendar event data with summary, start, end, conferenceData.", - "example": {}, - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "example": {"id": "...", "hangoutLink": "https://meet.google.com/..."}, - }, - }, -) -def create_google_meet(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "create_meet_event", - unwrap_envelope=True, - fail_message="Failed to create event.", - calendar_id=input_data.get("calendar_id", "primary"), - event_data=input_data.get("event_data"), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="check_calendar_availability", - description="Check Google Calendar free/busy availability.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "time_min": { - "type": "string", - "description": "Start time in ISO 8601 format.", - "example": "2024-01-15T09:00:00Z", - }, - "time_max": { - "type": "string", - "description": "End time in ISO 8601 format.", - "example": "2024-01-15T17:00:00Z", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def check_calendar_availability(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "check_availability", - unwrap_envelope=True, - fail_message="Failed to check availability.", - calendar_id=input_data.get("calendar_id", "primary"), - time_min=input_data.get("time_min"), - time_max=input_data.get("time_max"), - ) - - -@action( - name="check_availability_and_schedule", - description="Schedule meeting if free.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "start_time": { - "type": "string", - "description": "Start time.", - "example": "2024-01-01T10:00:00", - }, - "end_time": { - "type": "string", - "description": "End time.", - "example": "2024-01-01T11:00:00", - }, - "summary": {"type": "string", "description": "Summary.", "example": "Meeting"}, - "description": { - "type": "string", - "description": "Description.", - "example": "Details", - }, - "attendees": { - "type": "array", - "description": "Attendees.", - "example": ["a@b.com"], - }, - "from_email": { - "type": "string", - "description": "Sender.", - "example": "me@example.com", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def check_availability_and_schedule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - import uuid - from datetime import datetime - - try: - start_time = datetime.fromisoformat(input_data["start_time"]) - end_time = datetime.fromisoformat(input_data["end_time"]) - except Exception as e: - return {"status": "error", "message": str(e)} - - avail = run_client_sync( - "google_calendar", - "check_availability", - unwrap_envelope=True, - fail_message="Google Calendar FreeBusy API error", - calendar_id="primary", - time_min=start_time.isoformat() + "Z", - time_max=end_time.isoformat() + "Z", - ) - if avail["status"] == "error": - return { - "status": "error", - "reason": "Google Calendar FreeBusy API error", - "details": avail, - } - - busy_slots = ( - avail.get("result", {}).get("calendars", {}).get("primary", {}).get("busy", []) - ) - if busy_slots: - return { - "status": "busy", - "reason": "Time slot is already occupied", - "conflicting_events": busy_slots, - } - - attendees = input_data.get("attendees") or [] - event_payload = { - "summary": input_data["summary"], - "description": input_data.get("description", ""), - "start": {"dateTime": start_time.isoformat() + "Z", "timeZone": "UTC"}, - "end": {"dateTime": end_time.isoformat() + "Z", "timeZone": "UTC"}, - "attendees": [{"email": a} for a in attendees], - "conferenceData": { - "createRequest": { - "requestId": f"meet-{uuid.uuid4()}", - "conferenceSolutionKey": {"type": "hangoutsMeet"}, - } - }, - } - result = run_client_sync( - "google_calendar", - "create_meet_event", - unwrap_envelope=True, - fail_message="Google Calendar API error", - calendar_id="primary", - event_data=event_payload, - ) - if result["status"] == "error": - return { - "status": "error", - "reason": "Google Calendar API error", - "details": result, - } - event = result.get("result", result) - if isinstance(event, dict): - event = { - k: event.get(k) - for k in ("id", "hangoutLink", "htmlLink", "start", "end") - if event.get(k) is not None - } - return { - "status": "success", - "reason": "Meeting scheduled successfully.", - "event": event, - } - - -# ------------------------------------------------------------------ -# Events — daily-driver event operations -# ------------------------------------------------------------------ - - -@action( - name="list_google_calendar_events", - description="List events on a calendar between time_min and time_max. Returns expanded single events sorted by start time. Lean event fields by default (id, summary, description, location, start, end, status, attendees, recurrence, htmlLink, hangoutLink); set include_metadata for raw Event resources.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "time_min": { - "type": "string", - "description": "ISO 8601 lower bound (optional).", - "example": "2026-05-20T00:00:00Z", - }, - "time_max": { - "type": "string", - "description": "ISO 8601 upper bound (optional).", - "example": "2026-05-27T00:00:00Z", - }, - "max_results": { - "type": "integer", - "description": "Max events to return.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return full raw Event resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_calendar_events(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.data.action.integrations.google_workspace.google_calendar_actions import ( - _lean_gcal_event, - ) - - res = run_client_sync( - "google_calendar", - "list_events", - unwrap_envelope=True, - fail_message="Failed to list events.", - calendar_id=input_data.get("calendar_id", "primary"), - time_min=input_data.get("time_min"), - time_max=input_data.get("time_max"), - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - res = { - **res, - "result": [_lean_gcal_event(e) for e in items if isinstance(e, dict)], - } - return res - - -@action( - name="get_google_calendar_event", - description="Get a single event by ID. Lean event fields by default; set include_metadata for the raw Event resource.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_id": {"type": "string", "description": "Event ID.", "example": ""}, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "include_metadata": { - "type": "boolean", - "description": "Return the full raw Event resource (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.data.action.integrations.google_workspace.google_calendar_actions import ( - _lean_gcal_event, - ) - - res = run_client_sync( - "google_calendar", - "get_event", - unwrap_envelope=True, - fail_message="Failed to get event.", - event_id=input_data["event_id"], - calendar_id=input_data.get("calendar_id", "primary"), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - ev = res.get("result") - if isinstance(ev, dict): - res = {**res, "result": _lean_gcal_event(ev)} - return res - - -@action( - name="create_google_calendar_event", - description="Create a calendar event. event_data is the full Event resource (summary, start, end, attendees, etc.). Use create_google_meet for events with a Meet link. Returns id + key fields.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_data": { - "type": "object", - "description": "Event resource: summary, description, start, end, attendees, recurrence, etc.", - "example": {}, - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "send_updates": { - "type": "string", - "description": "none, all, or externalOnly — who gets notified.", - "example": "none", - }, - "supports_attachments": { - "type": "boolean", - "description": "Set true if event_data includes attachments.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "insert_event", - unwrap_envelope=True, - fail_message="Failed to create event.", - calendar_id=input_data.get("calendar_id", "primary"), - event_data=input_data["event_data"], - send_updates=input_data.get("send_updates", "none"), - supports_attachments=bool(input_data.get("supports_attachments", False)), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="update_google_calendar_event", - description="Replace an event entirely (PUT). For partial updates use patch_google_calendar_event. Returns id + key fields.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_id": {"type": "string", "description": "Event ID.", "example": ""}, - "event_data": { - "type": "object", - "description": "Full Event resource — replaces existing.", - "example": {}, - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "send_updates": { - "type": "string", - "description": "none, all, externalOnly.", - "example": "none", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "update_event", - unwrap_envelope=True, - fail_message="Failed to update event.", - calendar_id=input_data.get("calendar_id", "primary"), - event_id=input_data["event_id"], - event_data=input_data["event_data"], - send_updates=input_data.get("send_updates", "none"), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="patch_google_calendar_event", - description="Patch (partial update) an event. event_data contains ONLY the fields to change. Returns id + key fields.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_id": {"type": "string", "description": "Event ID.", "example": ""}, - "event_data": { - "type": "object", - "description": "Partial event fields to update.", - "example": {"summary": "New title"}, - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "send_updates": { - "type": "string", - "description": "none, all, externalOnly.", - "example": "none", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def patch_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "patch_event", - unwrap_envelope=True, - fail_message="Failed to patch event.", - calendar_id=input_data.get("calendar_id", "primary"), - event_id=input_data["event_id"], - event_data=input_data["event_data"], - send_updates=input_data.get("send_updates", "none"), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="delete_google_calendar_event", - description="Delete a calendar event.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "event_id": {"type": "string", "description": "Event ID.", "example": ""}, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "delete_event", - unwrap_envelope=True, - fail_message="Failed to delete event.", - event_id=input_data["event_id"], - calendar_id=input_data.get("calendar_id", "primary"), - ) - - -@action( - name="move_google_calendar_event", - description="Move an event from one calendar to another. Returns id + key fields.", - action_sets=["google_calendar_events"], - input_schema={ - "event_id": {"type": "string", "description": "Event ID.", "example": ""}, - "calendar_id": { - "type": "string", - "description": "Current calendar ID.", - "example": "primary", - }, - "destination_calendar_id": { - "type": "string", - "description": "Target calendar ID.", - "example": "", - }, - "send_updates": { - "type": "string", - "description": "none, all, externalOnly.", - "example": "none", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def move_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "move_event", - unwrap_envelope=True, - fail_message="Failed to move event.", - event_id=input_data["event_id"], - calendar_id=input_data.get("calendar_id", "primary"), - destination_calendar_id=input_data["destination_calendar_id"], - send_updates=input_data.get("send_updates", "none"), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="quick_add_google_calendar_event", - description="Create an event from a natural-language string (e.g. 'Lunch with Alice tomorrow at noon'). Returns id + key fields.", - action_sets=["google_calendar_events", "google_calendar"], - input_schema={ - "text": { - "type": "string", - "description": "Natural-language event description.", - "example": "Lunch with Alice tomorrow at noon", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "send_updates": { - "type": "string", - "description": "none, all, externalOnly.", - "example": "none", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def quick_add_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "quick_add_event", - unwrap_envelope=True, - fail_message="Failed to quick-add event.", - calendar_id=input_data.get("calendar_id", "primary"), - text=input_data["text"], - send_updates=input_data.get("send_updates", "none"), - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -@action( - name="list_google_calendar_event_instances", - description="Expand a recurring event into its individual instances. Lean event fields by default; set include_metadata for raw Event resources.", - action_sets=["google_calendar_events"], - input_schema={ - "event_id": { - "type": "string", - "description": "Recurring event ID.", - "example": "", - }, - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "time_min": { - "type": "string", - "description": "ISO 8601 lower bound (optional).", - "example": "", - }, - "time_max": { - "type": "string", - "description": "ISO 8601 upper bound (optional).", - "example": "", - }, - "max_results": { - "type": "integer", - "description": "Max instances.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return full raw Event resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_calendar_event_instances(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.data.action.integrations.google_workspace.google_calendar_actions import ( - _lean_gcal_event, - ) - - res = run_client_sync( - "google_calendar", - "list_event_instances", - unwrap_envelope=True, - fail_message="Failed to list instances.", - calendar_id=input_data.get("calendar_id", "primary"), - event_id=input_data["event_id"], - time_min=input_data.get("time_min"), - time_max=input_data.get("time_max"), - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - result = res.get("result") - if isinstance(result, dict) and isinstance(result.get("instances"), list): - res = { - **res, - "result": { - "instances": [ - _lean_gcal_event(e) - for e in result["instances"] - if isinstance(e, dict) - ] - }, - } - return res - - -@action( - name="import_google_calendar_event", - description="Import a pre-existing event (with its own iCal UID) into a calendar — preserves identity across calendars. Distinct from create. Returns id + key fields.", - action_sets=["google_calendar_events"], - input_schema={ - "event_data": { - "type": "object", - "description": "Event resource including iCalUID.", - "example": {}, - }, - "calendar_id": { - "type": "string", - "description": "Target calendar ID.", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def import_google_calendar_event(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "google_calendar", - "import_event", - unwrap_envelope=True, - fail_message="Failed to import event.", - calendar_id=input_data.get("calendar_id", "primary"), - event_data=input_data["event_data"], - ) - return pick_result( - res, ["id", "summary", "start", "end", "htmlLink", "hangoutLink", "status"] - ) - - -# ------------------------------------------------------------------ -# Calendars (the calendar resources themselves) -# ------------------------------------------------------------------ - - -@action( - name="list_google_calendars", - description="List calendars the user has access to (from their calendarList).", - action_sets=["google_calendar_admin", "google_calendar"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_calendars(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "list_calendars", - unwrap_envelope=True, - fail_message="Failed to list calendars.", - ) - - -@action( - name="get_google_calendar", - description="Get metadata for a single calendar (summary, timezone, description).", - action_sets=["google_calendar_admin", "google_calendar"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "get_calendar", - unwrap_envelope=True, - fail_message="Failed to get calendar.", - calendar_id=input_data.get("calendar_id", "primary"), - ) - - -@action( - name="create_google_calendar", - description="Create a new (secondary) calendar owned by the authenticated user.", - action_sets=["google_calendar_admin"], - input_schema={ - "summary": { - "type": "string", - "description": "Calendar name.", - "example": "Team events", - }, - "description": { - "type": "string", - "description": "Description (optional).", - "example": "", - }, - "time_zone": { - "type": "string", - "description": "IANA tz (optional, e.g. Asia/Tokyo).", - "example": "UTC", - }, - "location": { - "type": "string", - "description": "Default location (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "create_calendar", - unwrap_envelope=True, - fail_message="Failed to create calendar.", - summary=input_data["summary"], - description=input_data.get("description") or None, - time_zone=input_data.get("time_zone") or None, - location=input_data.get("location") or None, - ) - - -@action( - name="update_google_calendar", - description="Replace a calendar's metadata (PUT). For partial updates use patch_google_calendar.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, - "summary": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "description": { - "type": "string", - "description": "New description (optional).", - "example": "", - }, - "time_zone": { - "type": "string", - "description": "New IANA tz (optional).", - "example": "", - }, - "location": { - "type": "string", - "description": "New location (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "update_calendar", - unwrap_envelope=True, - fail_message="Failed to update calendar.", - calendar_id=input_data["calendar_id"], - summary=input_data.get("summary") or None, - description=input_data["description"] if "description" in input_data else None, - time_zone=input_data.get("time_zone") or None, - location=input_data["location"] if "location" in input_data else None, - ) - - -@action( - name="patch_google_calendar", - description="Patch (partial update) a calendar's metadata.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, - "summary": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "description": { - "type": "string", - "description": "New description (optional).", - "example": "", - }, - "time_zone": { - "type": "string", - "description": "New IANA tz (optional).", - "example": "", - }, - "location": { - "type": "string", - "description": "New location (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def patch_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "patch_calendar", - unwrap_envelope=True, - fail_message="Failed to patch calendar.", - calendar_id=input_data["calendar_id"], - summary=input_data.get("summary") or None, - description=input_data["description"] if "description" in input_data else None, - time_zone=input_data.get("time_zone") or None, - location=input_data["location"] if "location" in input_data else None, - ) - - -@action( - name="delete_google_calendar", - description="DELETE a secondary calendar. Cannot be used on the primary calendar.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID to delete.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "delete_calendar", - unwrap_envelope=True, - fail_message="Failed to delete calendar.", - calendar_id=input_data["calendar_id"], - ) - - -@action( - name="clear_google_calendar", - description="Delete ALL events on the user's PRIMARY calendar. Irreversible. No-op on secondary calendars.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Must be 'primary'.", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def clear_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "clear_calendar", - unwrap_envelope=True, - fail_message="Failed to clear calendar.", - calendar_id=input_data.get("calendar_id", "primary"), - ) - - -# ------------------------------------------------------------------ -# CalendarList (the user's view of calendars: subscriptions, colors, visibility) -# ------------------------------------------------------------------ - - -@action( - name="get_google_calendar_list_entry", - description="Get the user's per-calendar settings (color, visibility, summary override).", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar_list_entry(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "get_calendar_list_entry", - unwrap_envelope=True, - fail_message="Failed to get calendar list entry.", - calendar_id=input_data["calendar_id"], - ) - - -@action( - name="subscribe_google_calendar", - description="Subscribe to (add to the user's calendar list) an existing calendar by ID.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID to subscribe to.", - "example": "", - }, - "color_id": { - "type": "string", - "description": "Color ID from get_google_calendar_colors (optional).", - "example": "", - }, - "summary_override": { - "type": "string", - "description": "User-side display name (optional).", - "example": "", - }, - "selected": { - "type": "boolean", - "description": "Show in UI (optional).", - "example": True, - }, - "hidden": { - "type": "boolean", - "description": "Hide from UI (optional).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def subscribe_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "subscribe_calendar", - unwrap_envelope=True, - fail_message="Failed to subscribe to calendar.", - calendar_id=input_data["calendar_id"], - color_id=input_data.get("color_id") or None, - summary_override=input_data.get("summary_override") or None, - selected=input_data["selected"] if "selected" in input_data else None, - hidden=input_data["hidden"] if "hidden" in input_data else None, - ) - - -@action( - name="update_google_calendar_list_entry", - description="Update the user's per-calendar settings (color, visibility, display name).", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": {"type": "string", "description": "Calendar ID.", "example": ""}, - "color_id": { - "type": "string", - "description": "Color ID (optional).", - "example": "", - }, - "summary_override": { - "type": "string", - "description": "Display name (optional).", - "example": "", - }, - "selected": { - "type": "boolean", - "description": "Show in UI (optional).", - "example": True, - }, - "hidden": { - "type": "boolean", - "description": "Hide from UI (optional).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_google_calendar_list_entry(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "update_calendar_list_entry", - unwrap_envelope=True, - fail_message="Failed to update calendar list entry.", - calendar_id=input_data["calendar_id"], - color_id=input_data.get("color_id") or None, - summary_override=input_data["summary_override"] - if "summary_override" in input_data - else None, - selected=input_data["selected"] if "selected" in input_data else None, - hidden=input_data["hidden"] if "hidden" in input_data else None, - ) - - -@action( - name="unsubscribe_google_calendar", - description="Remove a calendar from the user's calendar list. Does NOT delete the calendar itself.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID to unsubscribe from.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def unsubscribe_google_calendar(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "unsubscribe_calendar", - unwrap_envelope=True, - fail_message="Failed to unsubscribe.", - calendar_id=input_data["calendar_id"], - ) - - -# ------------------------------------------------------------------ -# ACL (per-calendar sharing) -# ------------------------------------------------------------------ - - -@action( - name="list_google_calendar_acl", - description="List ACL rules (who has what access) on a calendar.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_calendar_acl(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "list_calendar_acl", - unwrap_envelope=True, - fail_message="Failed to list ACL.", - calendar_id=input_data.get("calendar_id", "primary"), - ) - - -@action( - name="get_google_calendar_acl_rule", - description="Get a single ACL rule by ID.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID.", - "example": "primary", - }, - "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar_acl_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "get_calendar_acl_rule", - unwrap_envelope=True, - fail_message="Failed to get ACL rule.", - calendar_id=input_data.get("calendar_id", "primary"), - rule_id=input_data["rule_id"], - ) - - -@action( - name="add_google_calendar_acl_rule", - description="Grant calendar access. scope_type: user/group/domain/default. role: none/freeBusyReader/reader/writer/owner.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID (default: primary).", - "example": "primary", - }, - "scope_type": { - "type": "string", - "description": "user, group, domain, or default.", - "example": "user", - }, - "scope_value": { - "type": "string", - "description": "Email, group address, or domain (empty for 'default').", - "example": "alice@example.com", - }, - "role": { - "type": "string", - "description": "none, freeBusyReader, reader, writer, or owner.", - "example": "reader", - }, - "send_notifications": { - "type": "boolean", - "description": "Email the grantee.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_google_calendar_acl_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "add_calendar_acl_rule", - unwrap_envelope=True, - fail_message="Failed to add ACL rule.", - calendar_id=input_data.get("calendar_id", "primary"), - scope_type=input_data["scope_type"], - scope_value=input_data.get("scope_value", ""), - role=input_data["role"], - send_notifications=bool(input_data.get("send_notifications", True)), - ) - - -@action( - name="update_google_calendar_acl_rule", - description="Change the role of an existing ACL rule.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID.", - "example": "primary", - }, - "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, - "role": {"type": "string", "description": "New role.", "example": "writer"}, - "scope_type": { - "type": "string", - "description": "New scope type (optional).", - "example": "", - }, - "scope_value": { - "type": "string", - "description": "New scope value (optional).", - "example": "", - }, - "send_notifications": { - "type": "boolean", - "description": "Email the grantee.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_google_calendar_acl_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "update_calendar_acl_rule", - unwrap_envelope=True, - fail_message="Failed to update ACL rule.", - calendar_id=input_data.get("calendar_id", "primary"), - rule_id=input_data["rule_id"], - role=input_data["role"], - scope_type=input_data.get("scope_type") or None, - scope_value=input_data.get("scope_value") or None, - send_notifications=bool(input_data.get("send_notifications", True)), - ) - - -@action( - name="delete_google_calendar_acl_rule", - description="Revoke access by deleting an ACL rule.", - action_sets=["google_calendar_admin"], - input_schema={ - "calendar_id": { - "type": "string", - "description": "Calendar ID.", - "example": "primary", - }, - "rule_id": {"type": "string", "description": "ACL rule ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_calendar_acl_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "delete_calendar_acl_rule", - unwrap_envelope=True, - fail_message="Failed to delete ACL rule.", - calendar_id=input_data.get("calendar_id", "primary"), - rule_id=input_data["rule_id"], - ) - - -# ------------------------------------------------------------------ -# Settings & colors -# ------------------------------------------------------------------ - - -@action( - name="list_google_calendar_settings", - description="List the authenticated user's Calendar settings (timezone, locale, weekStart, etc.) as a dict.", - action_sets=["google_calendar_admin"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_calendar_settings(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "list_calendar_settings", - unwrap_envelope=True, - fail_message="Failed to list settings.", - ) - - -@action( - name="get_google_calendar_setting", - description="Get a single user setting by ID. Common IDs: timezone, locale, autoAddHangouts, weekStart.", - action_sets=["google_calendar_admin"], - input_schema={ - "setting_id": { - "type": "string", - "description": "Setting ID.", - "example": "timezone", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar_setting(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "get_calendar_setting", - unwrap_envelope=True, - fail_message="Failed to get setting.", - setting_id=input_data["setting_id"], - ) - - -@action( - name="get_google_calendar_colors", - description="Get the color palette available for calendars and events (color_id → hex map).", - action_sets=["google_calendar_admin"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_calendar_colors(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_calendar", - "get_calendar_colors", - unwrap_envelope=True, - fail_message="Failed to get colors.", - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - Push notifications / watch endpoints (events.watch, calendarList.watch, ...) -# Server-side webhook setup for incremental sync. Not a per-interaction action; -# the host environment would own webhook plumbing if needed. -# - Conference data providers beyond hangoutsMeet -# Add-on/3rd-party conference data (Zoom/Webex via add-ons) is configured in -# the event_data payload by the agent — no separate endpoint needed. -# - Events.instances pagination tokens -# Single-call instances() with maxResults covers the realistic agent use -# case; full pagination can be added if/when needed. diff --git a/app/data/action/integrations/google_workspace/google_docs_actions.py b/app/data/action/integrations/google_workspace/google_docs_actions.py deleted file mode 100644 index 7245ff5e..00000000 --- a/app/data/action/integrations/google_workspace/google_docs_actions.py +++ /dev/null @@ -1,1383 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# File-level: create / get / list / search / delete / copy / export -# Sub-set: google_docs_files -# ------------------------------------------------------------------ - - -@action( - name="create_google_doc", - description="Create a new blank Google Doc with the given title. Returns the document ID and editable URL.", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "title": { - "type": "string", - "description": "Title for the new document.", - "example": "Meeting Notes", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def create_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "create_document", - unwrap_envelope=True, - fail_message="Failed to create Google Doc.", - title=input_data["title"], - ) - - -@action( - name="get_google_doc", - description="Fetch a Google Doc. Default returns {document_id, title, text} (body flattened to plain text); set include_metadata for the raw structured JSON (needed for index-based edits).", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - "include_metadata": { - "type": "boolean", - "description": "Return the full structured document JSON (default false = plain text).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_docs", - "get_document", - unwrap_envelope=True, - fail_message="Failed to fetch document.", - document_id=input_data["document_id"], - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - doc = res.get("result") - if isinstance(doc, dict): - # Same flattening as the google_docs client's get_document_text. - text_parts = [] - for elem in doc.get("body", {}).get("content", []) or []: - para = elem.get("paragraph") - if not para: - continue - for run in para.get("elements") or []: - tr = run.get("textRun") - if tr and tr.get("content"): - text_parts.append(tr["content"]) - res = { - **res, - "result": { - "document_id": doc.get("documentId") or input_data["document_id"], - "title": doc.get("title", ""), - "text": "".join(text_parts), - }, - } - return res - - -@action( - name="get_google_doc_text", - description="Get a Google Doc as plain text. Returns title and the doc body flattened to a string.", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_google_doc_text(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "get_document_text", - unwrap_envelope=True, - fail_message="Failed to read document.", - document_id=input_data["document_id"], - ) - - -@action( - name="list_google_docs", - description="List Google Docs the user owns or has access to, most recent first.", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "max_results": { - "type": "integer", - "description": "Max number of docs to return.", - "example": 50, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_google_docs(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "list_documents", - unwrap_envelope=True, - fail_message="Failed to list docs.", - max_results=input_data.get("max_results", 50), - ) - - -@action( - name="search_google_docs", - description="Search for Google Docs by title fragment.", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "query": { - "type": "string", - "description": "Title fragment to search for.", - "example": "Meeting", - }, - "max_results": { - "type": "integer", - "description": "Max number of docs to return.", - "example": 50, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_google_docs(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "search_documents", - unwrap_envelope=True, - fail_message="Failed to search docs.", - query=input_data["query"], - max_results=input_data.get("max_results", 50), - ) - - -@action( - name="delete_google_doc", - description="Move a Google Doc to the Drive trash.", - action_sets=["google_docs_files", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_document", - unwrap_envelope=True, - success_message="Document deleted.", - fail_message="Failed to delete document.", - document_id=input_data["document_id"], - ) - - -@action( - name="copy_google_doc", - description="Copy an existing Google Doc to a new file with a new title.", - action_sets=["google_docs_files"], - input_schema={ - "document_id": { - "type": "string", - "description": "Source document ID.", - "example": "1abcDEF...", - }, - "new_title": { - "type": "string", - "description": "Title for the copy.", - "example": "Meeting Notes (copy)", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def copy_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "copy_document", - unwrap_envelope=True, - fail_message="Failed to copy document.", - document_id=input_data["document_id"], - new_title=input_data["new_title"], - ) - - -@action( - name="export_google_doc", - description="Export a Google Doc to PDF, DOCX, ODT, plain text, or HTML and save to a local file path.", - action_sets=["google_docs_files"], - input_schema={ - "document_id": { - "type": "string", - "description": "Source document ID.", - "example": "1abcDEF...", - }, - "mime_type": { - "type": "string", - "description": "Export MIME type. application/pdf | application/vnd.openxmlformats-officedocument.wordprocessingml.document | application/vnd.oasis.opendocument.text | text/plain | text/html.", - "example": "application/pdf", - }, - "dest_path": { - "type": "string", - "description": "Local file path to write to.", - "example": "/tmp/doc.pdf", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def export_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "export_document", - unwrap_envelope=True, - fail_message="Failed to export document.", - document_id=input_data["document_id"], - mime_type=input_data["mime_type"], - dest_path=input_data["dest_path"], - ) - - -# ------------------------------------------------------------------ -# Content: insert / delete text, append, replace -# Sub-set: google_docs_content -# ------------------------------------------------------------------ - - -@action( - name="append_to_google_doc", - description="Append text to the end of a Google Doc.", - action_sets=["google_docs_content", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - "text": { - "type": "string", - "description": "Text to append.", - "example": "\\n\\nFollow-up: ...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def append_to_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "append_text", - unwrap_envelope=True, - success_message="Text appended.", - fail_message="Failed to append text.", - document_id=input_data["document_id"], - text=input_data["text"], - ) - - -@action( - name="insert_text_into_google_doc", - description="Insert text at a specific UTF-16 index in the document. Index 1 is the start of the body.", - action_sets=["google_docs_content", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "text": { - "type": "string", - "description": "Text to insert.", - "example": "Introduction\\n", - }, - "index": { - "type": "integer", - "description": "Position (UTF-16 index). Index 1 = start of body.", - "example": 1, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_text_into_google_doc(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_text", - unwrap_envelope=True, - success_message="Text inserted.", - fail_message="Failed to insert text.", - document_id=input_data["document_id"], - text=input_data["text"], - index=input_data["index"], - ) - - -@action( - name="delete_google_doc_range", - description="Delete content in a range (between startIndex and endIndex).", - action_sets=["google_docs_content", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index (inclusive).", - "example": 10, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index (exclusive).", - "example": 30, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_range(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_content_range", - unwrap_envelope=True, - success_message="Range deleted.", - fail_message="Failed to delete range.", - document_id=input_data["document_id"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - ) - - -@action( - name="replace_google_doc_text", - description="Find-and-replace across the entire Google Doc body. Returns the number of occurrences changed.", - action_sets=["google_docs_content", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "The Google Doc's document ID.", - "example": "1abcDEF...", - }, - "find": {"type": "string", "description": "Text to find.", "example": "TODO"}, - "replace": { - "type": "string", - "description": "Replacement text.", - "example": "DONE", - }, - "match_case": { - "type": "boolean", - "description": "Whether the search is case-sensitive.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def replace_google_doc_text(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "replace_text", - unwrap_envelope=True, - fail_message="Failed to replace text.", - document_id=input_data["document_id"], - find=input_data["find"], - replace=input_data["replace"], - match_case=input_data.get("match_case", False), - ) - - -# ------------------------------------------------------------------ -# Styling: text + paragraph -# Sub-set: google_docs_styling -# ------------------------------------------------------------------ - - -@action( - name="style_google_doc_text", - description="Apply text-level styling (bold, italic, font size, color, link) to a range. Only supplied fields change; others stay untouched.", - action_sets=["google_docs_styling", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index.", - "example": 10, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index (exclusive).", - "example": 30, - }, - "bold": {"type": "boolean", "description": "Toggle bold.", "example": True}, - "italic": { - "type": "boolean", - "description": "Toggle italic.", - "example": False, - }, - "underline": { - "type": "boolean", - "description": "Toggle underline.", - "example": False, - }, - "strikethrough": { - "type": "boolean", - "description": "Toggle strikethrough.", - "example": False, - }, - "font_size_pt": { - "type": "number", - "description": "Font size in points.", - "example": 14, - }, - "font_family": { - "type": "string", - "description": "Font family name.", - "example": "Arial", - }, - "foreground_color_hex": { - "type": "string", - "description": "Foreground color (#RRGGBB).", - "example": "#FF0000", - }, - "background_color_hex": { - "type": "string", - "description": "Background color (#RRGGBB).", - "example": "#FFFF00", - }, - "link_url": { - "type": "string", - "description": "Turn range into a hyperlink to this URL.", - "example": "https://example.com", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def style_google_doc_text(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "update_text_style", - unwrap_envelope=True, - success_message="Text styled.", - fail_message="Failed to style text.", - document_id=input_data["document_id"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - bold=input_data.get("bold"), - italic=input_data.get("italic"), - underline=input_data.get("underline"), - strikethrough=input_data.get("strikethrough"), - font_size_pt=input_data.get("font_size_pt"), - font_family=input_data.get("font_family") or None, - foreground_color_hex=input_data.get("foreground_color_hex") or None, - background_color_hex=input_data.get("background_color_hex") or None, - link_url=input_data.get("link_url") or None, - ) - - -@action( - name="style_google_doc_paragraph", - description="Apply paragraph-level styling (heading, alignment, line spacing) to a range.", - action_sets=["google_docs_styling", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index.", - "example": 1, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index (exclusive).", - "example": 20, - }, - "named_style_type": { - "type": "string", - "description": "NORMAL_TEXT | TITLE | SUBTITLE | HEADING_1..HEADING_6.", - "example": "HEADING_1", - }, - "alignment": { - "type": "string", - "description": "START | CENTER | END | JUSTIFIED.", - "example": "CENTER", - }, - "line_spacing": { - "type": "number", - "description": "Percentage (100 = single).", - "example": 150, - }, - "keep_with_next": { - "type": "boolean", - "description": "Keep with following paragraph.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def style_google_doc_paragraph(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "update_paragraph_style", - unwrap_envelope=True, - success_message="Paragraph styled.", - fail_message="Failed to style paragraph.", - document_id=input_data["document_id"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - named_style_type=input_data.get("named_style_type") or None, - alignment=input_data.get("alignment") or None, - line_spacing=input_data.get("line_spacing"), - keep_with_next=input_data.get("keep_with_next"), - ) - - -# ------------------------------------------------------------------ -# Lists -# Sub-set: google_docs_lists -# ------------------------------------------------------------------ - - -@action( - name="create_google_doc_bullets", - description="Turn paragraphs in a range into a bulleted or numbered list.", - action_sets=["google_docs_lists"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index.", - "example": 10, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index.", - "example": 60, - }, - "bullet_preset": { - "type": "string", - "description": "BULLET_DISC_CIRCLE_SQUARE | NUMBERED_DECIMAL_NESTED | BULLET_CHECKBOX | NUMBERED_DECIMAL_ALPHA_ROMAN | BULLET_ARROW_DIAMOND_DISC.", - "example": "BULLET_DISC_CIRCLE_SQUARE", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_doc_bullets(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "create_paragraph_bullets", - unwrap_envelope=True, - success_message="Bullets created.", - fail_message="Failed to create bullets.", - document_id=input_data["document_id"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - bullet_preset=input_data.get("bullet_preset", "BULLET_DISC_CIRCLE_SQUARE"), - ) - - -@action( - name="delete_google_doc_bullets", - description="Remove bullet/numbered list formatting from a range.", - action_sets=["google_docs_lists"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index.", - "example": 10, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index.", - "example": 60, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_bullets(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_paragraph_bullets", - unwrap_envelope=True, - success_message="Bullets removed.", - fail_message="Failed to remove bullets.", - document_id=input_data["document_id"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - ) - - -# ------------------------------------------------------------------ -# Tables -# Sub-set: google_docs_tables -# ------------------------------------------------------------------ - - -@action( - name="insert_google_doc_table", - description="Insert a new empty table at a specific document index.", - action_sets=["google_docs_tables", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "rows": {"type": "integer", "description": "Number of rows.", "example": 3}, - "columns": { - "type": "integer", - "description": "Number of columns.", - "example": 3, - }, - "index": { - "type": "integer", - "description": "Position to insert at.", - "example": 1, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_table(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_table", - unwrap_envelope=True, - success_message="Table inserted.", - fail_message="Failed to insert table.", - document_id=input_data["document_id"], - rows=input_data["rows"], - columns=input_data["columns"], - index=input_data["index"], - ) - - -@action( - name="insert_google_doc_table_row", - description="Insert a row above or below a table cell.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "The table's start index in the document.", - "example": 5, - }, - "row_index": { - "type": "integer", - "description": "Reference cell row (0-based).", - "example": 0, - }, - "column_index": { - "type": "integer", - "description": "Reference cell column (0-based).", - "example": 0, - }, - "insert_below": { - "type": "boolean", - "description": "True = below, False = above.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_table_row(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_table_row", - unwrap_envelope=True, - fail_message="Failed to insert row.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - insert_below=input_data.get("insert_below", True), - ) - - -@action( - name="insert_google_doc_table_column", - description="Insert a column left or right of a table cell.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "Table start index.", - "example": 5, - }, - "row_index": { - "type": "integer", - "description": "Reference cell row.", - "example": 0, - }, - "column_index": { - "type": "integer", - "description": "Reference cell column.", - "example": 0, - }, - "insert_right": { - "type": "boolean", - "description": "True = right, False = left.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_table_column(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_table_column", - unwrap_envelope=True, - fail_message="Failed to insert column.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - insert_right=input_data.get("insert_right", True), - ) - - -@action( - name="delete_google_doc_table_row", - description="Delete a row at the specified cell location.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "Table start index.", - "example": 5, - }, - "row_index": {"type": "integer", "description": "Row to delete.", "example": 1}, - "column_index": { - "type": "integer", - "description": "Any column index in the row.", - "example": 0, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_table_row(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_table_row", - unwrap_envelope=True, - fail_message="Failed to delete row.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - ) - - -@action( - name="delete_google_doc_table_column", - description="Delete a column at the specified cell location.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "Table start index.", - "example": 5, - }, - "row_index": { - "type": "integer", - "description": "Any row index in the column.", - "example": 0, - }, - "column_index": { - "type": "integer", - "description": "Column to delete.", - "example": 1, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_table_column(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_table_column", - unwrap_envelope=True, - fail_message="Failed to delete column.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - ) - - -@action( - name="merge_google_doc_table_cells", - description="Merge a rectangular range of table cells into one.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "Table start index.", - "example": 5, - }, - "row_index": { - "type": "integer", - "description": "Top-left cell row.", - "example": 0, - }, - "column_index": { - "type": "integer", - "description": "Top-left cell column.", - "example": 0, - }, - "row_span": {"type": "integer", "description": "Rows to span.", "example": 2}, - "column_span": { - "type": "integer", - "description": "Columns to span.", - "example": 2, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def merge_google_doc_table_cells(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "merge_table_cells", - unwrap_envelope=True, - fail_message="Failed to merge cells.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - row_span=input_data["row_span"], - column_span=input_data["column_span"], - ) - - -@action( - name="unmerge_google_doc_table_cells", - description="Reverse a cell merge in a table range.", - action_sets=["google_docs_tables"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "table_start_index": { - "type": "integer", - "description": "Table start index.", - "example": 5, - }, - "row_index": { - "type": "integer", - "description": "Top-left cell row.", - "example": 0, - }, - "column_index": { - "type": "integer", - "description": "Top-left cell column.", - "example": 0, - }, - "row_span": { - "type": "integer", - "description": "Rows in merged region.", - "example": 2, - }, - "column_span": { - "type": "integer", - "description": "Columns in merged region.", - "example": 2, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def unmerge_google_doc_table_cells(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "unmerge_table_cells", - unwrap_envelope=True, - fail_message="Failed to unmerge cells.", - document_id=input_data["document_id"], - table_start_index=input_data["table_start_index"], - row_index=input_data["row_index"], - column_index=input_data["column_index"], - row_span=input_data["row_span"], - column_span=input_data["column_span"], - ) - - -# ------------------------------------------------------------------ -# Images -# Sub-set: google_docs_images -# ------------------------------------------------------------------ - - -@action( - name="insert_google_doc_image", - description="Insert an inline image (referenced by public URI) at a document index.", - action_sets=["google_docs_images", "google_docs"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "image_uri": { - "type": "string", - "description": "Publicly accessible image URL.", - "example": "https://example.com/logo.png", - }, - "index": {"type": "integer", "description": "Insertion index.", "example": 1}, - "width_pt": { - "type": "number", - "description": "Optional width in points.", - "example": 200, - }, - "height_pt": { - "type": "number", - "description": "Optional height in points.", - "example": 150, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_image(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_inline_image", - unwrap_envelope=True, - success_message="Image inserted.", - fail_message="Failed to insert image.", - document_id=input_data["document_id"], - image_uri=input_data["image_uri"], - index=input_data["index"], - width_pt=input_data.get("width_pt"), - height_pt=input_data.get("height_pt"), - ) - - -@action( - name="replace_google_doc_image", - description="Replace an existing inline image with a new URI (keeps position and size).", - action_sets=["google_docs_images"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "image_object_id": { - "type": "string", - "description": "Inline image object ID.", - "example": "kix.xxxx", - }, - "image_uri": { - "type": "string", - "description": "New image URI.", - "example": "https://example.com/new.png", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def replace_google_doc_image(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "replace_image", - unwrap_envelope=True, - success_message="Image replaced.", - fail_message="Failed to replace image.", - document_id=input_data["document_id"], - image_object_id=input_data["image_object_id"], - image_uri=input_data["image_uri"], - ) - - -# ------------------------------------------------------------------ -# Structure: page/section breaks, headers/footers, named ranges -# Sub-set: google_docs_structure -# ------------------------------------------------------------------ - - -@action( - name="insert_google_doc_page_break", - description="Insert a page break at a document index.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "index": {"type": "integer", "description": "Insertion index.", "example": 1}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_page_break(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_page_break", - unwrap_envelope=True, - success_message="Page break inserted.", - fail_message="Failed to insert page break.", - document_id=input_data["document_id"], - index=input_data["index"], - ) - - -@action( - name="insert_google_doc_section_break", - description="Insert a section break (NEXT_PAGE or CONTINUOUS) at a document index.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "index": {"type": "integer", "description": "Insertion index.", "example": 1}, - "section_type": { - "type": "string", - "description": "NEXT_PAGE | CONTINUOUS.", - "example": "NEXT_PAGE", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def insert_google_doc_section_break(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "insert_section_break", - unwrap_envelope=True, - success_message="Section break inserted.", - fail_message="Failed to insert section break.", - document_id=input_data["document_id"], - index=input_data["index"], - section_type=input_data.get("section_type", "NEXT_PAGE"), - ) - - -@action( - name="create_google_doc_header", - description="Create a document header. Returns the header ID for further edits.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "header_type": { - "type": "string", - "description": "DEFAULT | FIRST_PAGE_HEADER.", - "example": "DEFAULT", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_doc_header(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "create_header", - unwrap_envelope=True, - success_message="Header created.", - fail_message="Failed to create header.", - document_id=input_data["document_id"], - header_type=input_data.get("header_type", "DEFAULT"), - ) - - -@action( - name="create_google_doc_footer", - description="Create a document footer. Returns the footer ID for further edits.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "footer_type": { - "type": "string", - "description": "DEFAULT | FIRST_PAGE_FOOTER.", - "example": "DEFAULT", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_doc_footer(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "create_footer", - unwrap_envelope=True, - success_message="Footer created.", - fail_message="Failed to create footer.", - document_id=input_data["document_id"], - footer_type=input_data.get("footer_type", "DEFAULT"), - ) - - -@action( - name="delete_google_doc_header", - description="Delete a header by its ID.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "header_id": { - "type": "string", - "description": "Header ID.", - "example": "kix.xxxx", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_header(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_header", - unwrap_envelope=True, - success_message="Header deleted.", - fail_message="Failed to delete header.", - document_id=input_data["document_id"], - header_id=input_data["header_id"], - ) - - -@action( - name="delete_google_doc_footer", - description="Delete a footer by its ID.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "footer_id": { - "type": "string", - "description": "Footer ID.", - "example": "kix.xxxx", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_footer(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_footer", - unwrap_envelope=True, - success_message="Footer deleted.", - fail_message="Failed to delete footer.", - document_id=input_data["document_id"], - footer_id=input_data["footer_id"], - ) - - -@action( - name="create_google_doc_named_range", - description="Create a named range over a document range so it can be referenced later.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "name": { - "type": "string", - "description": "Range name.", - "example": "intro_section", - }, - "start_index": { - "type": "integer", - "description": "Start UTF-16 index.", - "example": 1, - }, - "end_index": { - "type": "integer", - "description": "End UTF-16 index.", - "example": 50, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_google_doc_named_range(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "create_named_range", - unwrap_envelope=True, - success_message="Named range created.", - fail_message="Failed to create named range.", - document_id=input_data["document_id"], - name=input_data["name"], - start_index=input_data["start_index"], - end_index=input_data["end_index"], - ) - - -@action( - name="delete_google_doc_named_range", - description="Delete a named range by name or by ID.", - action_sets=["google_docs_structure"], - input_schema={ - "document_id": { - "type": "string", - "description": "Document ID.", - "example": "1abcDEF...", - }, - "name": { - "type": "string", - "description": "Range name to delete (one of name or id required).", - "example": "intro_section", - }, - "named_range_id": { - "type": "string", - "description": "Named range ID (alternative to name).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_google_doc_named_range(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_docs", - "delete_named_range", - unwrap_envelope=True, - success_message="Named range deleted.", - fail_message="Failed to delete named range.", - document_id=input_data["document_id"], - name=input_data.get("name") or None, - named_range_id=input_data.get("named_range_id") or None, - ) diff --git a/app/data/action/integrations/google_workspace/google_drive_actions.py b/app/data/action/integrations/google_workspace/google_drive_actions.py deleted file mode 100644 index ef70ea0e..00000000 --- a/app/data/action/integrations/google_workspace/google_drive_actions.py +++ /dev/null @@ -1,1246 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# Files — list / search / get / folder / upload / download / export / copy / move / delete -# ------------------------------------------------------------------ - - -@action( - name="list_drive_files", - description="List files in a specific Google Drive folder.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "folder_id": { - "type": "string", - "description": "Google Drive folder ID. Use 'root' for the user's My Drive.", - "example": "root", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_drive_files(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_drive_files", - unwrap_envelope=True, - fail_message="Failed to list files.", - folder_id=input_data["folder_id"], - ) - - -@action( - name="search_drive_files", - description="Free-form search across all of Drive using Drive's q-query syntax (e.g. \"name contains 'report' and mimeType = 'application/pdf'\").", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "query": { - "type": "string", - "description": "Drive q-query.", - "example": "name contains 'budget' and trashed = false", - }, - "max_results": { - "type": "integer", - "description": "Max results.", - "example": 50, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_drive_files(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "search_drive", - unwrap_envelope=True, - fail_message="Failed to search files.", - query=input_data["query"], - max_results=input_data.get("max_results", 50), - ) - - -@action( - name="get_drive_file", - description="Get metadata for a single Drive file or folder.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "fields": { - "type": "string", - "description": "Comma-separated field list (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_drive_file", - unwrap_envelope=True, - fail_message="Failed to get file.", - file_id=input_data["file_id"], - fields=input_data.get("fields") or None, - ) - - -@action( - name="create_drive_folder", - description="Create a new folder in Google Drive.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "name": { - "type": "string", - "description": "Folder name.", - "example": "Project Files", - }, - "parent_folder_id": { - "type": "string", - "description": "Optional parent folder ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_drive_folder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "create_drive_folder", - unwrap_envelope=True, - fail_message="Failed to create folder.", - name=input_data["name"], - parent_folder_id=input_data.get("parent_folder_id"), - ) - - -@action( - name="upload_drive_file", - description="Upload a local file to Google Drive. Reads from file_path on the agent host. MIME type is auto-detected if omitted.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_path": { - "type": "string", - "description": "Absolute path to the local file.", - "example": "C:/Users/me/report.pdf", - }, - "name": { - "type": "string", - "description": "Drive filename (defaults to local filename).", - "example": "", - }, - "mime_type": { - "type": "string", - "description": "MIME type (defaults to autodetect).", - "example": "", - }, - "parent_folder_id": { - "type": "string", - "description": "Target folder ID (defaults to root).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def upload_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "upload_drive_file", - unwrap_envelope=True, - fail_message="Failed to upload file.", - file_path=input_data["file_path"], - name=input_data.get("name") or None, - mime_type=input_data.get("mime_type") or None, - parent_folder_id=input_data.get("parent_folder_id") or None, - ) - - -@action( - name="update_drive_file_content", - description="Replace an existing Drive file's binary content with a local file. Does NOT change metadata.", - action_sets=["google_drive_files"], - input_schema={ - "file_id": { - "type": "string", - "description": "Drive file ID to overwrite.", - "example": "", - }, - "file_path": { - "type": "string", - "description": "Absolute path to the new local content.", - "example": "C:/Users/me/report_v2.pdf", - }, - "mime_type": { - "type": "string", - "description": "MIME type (defaults to autodetect).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_file_content(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_file_content", - unwrap_envelope=True, - fail_message="Failed to update file content.", - file_id=input_data["file_id"], - file_path=input_data["file_path"], - mime_type=input_data.get("mime_type") or None, - ) - - -@action( - name="download_drive_file", - description="Download a regular (non-Google-native) Drive file to a local path. For Google Docs/Sheets/Slides use export_drive_file instead.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "save_to": { - "type": "string", - "description": "Local path to save to. Parent directories will be created.", - "example": "C:/Users/me/downloads/report.pdf", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def download_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "download_drive_file", - unwrap_envelope=True, - fail_message="Failed to download file.", - file_id=input_data["file_id"], - save_to=input_data["save_to"], - ) - - -@action( - name="export_drive_file", - description="Export a Google-native file (Doc/Sheet/Slide/Drawing) to a local path in another format. Common mime_type values: application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document (.docx), application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (.xlsx), text/plain, text/csv. Limit: 10 MB.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": { - "type": "string", - "description": "Google-native file ID.", - "example": "", - }, - "save_to": { - "type": "string", - "description": "Local path to save to.", - "example": "C:/Users/me/report.pdf", - }, - "mime_type": { - "type": "string", - "description": "Target export MIME type.", - "example": "application/pdf", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def export_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "export_drive_file", - unwrap_envelope=True, - fail_message="Failed to export file.", - file_id=input_data["file_id"], - save_to=input_data["save_to"], - mime_type=input_data["mime_type"], - ) - - -@action( - name="copy_drive_file", - description="Duplicate a Drive file. Optionally rename and/or place in a different folder.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": {"type": "string", "description": "File ID to copy.", "example": ""}, - "name": { - "type": "string", - "description": "Name for the copy (optional).", - "example": "", - }, - "parent_folder_id": { - "type": "string", - "description": "Target folder ID (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def copy_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "copy_drive_file", - unwrap_envelope=True, - fail_message="Failed to copy file.", - file_id=input_data["file_id"], - name=input_data.get("name") or None, - parent_folder_id=input_data.get("parent_folder_id") or None, - ) - - -@action( - name="move_drive_file", - description="Move a file to a different Google Drive folder.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": { - "type": "string", - "description": "File ID to move.", - "example": "abc123", - }, - "destination_folder_id": { - "type": "string", - "description": "Destination folder ID.", - "example": "def456", - }, - "source_folder_id": { - "type": "string", - "description": "Current parent folder ID.", - "example": "root", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def move_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "move_drive_file", - unwrap_envelope=True, - fail_message="Failed to move file.", - file_id=input_data["file_id"], - add_parents=input_data["destination_folder_id"], - remove_parents=input_data.get("source_folder_id", ""), - ) - - -@action( - name="update_drive_file_metadata", - description="Rename / re-describe / star / trash a Drive file. Use trashed=true to send to trash without permanent delete.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "name": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "description": { - "type": "string", - "description": "New description (optional).", - "example": "", - }, - "starred": { - "type": "boolean", - "description": "Star/unstar (optional).", - "example": False, - }, - "trashed": { - "type": "boolean", - "description": "Send to trash without deleting (optional).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_file_metadata(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_file_metadata", - unwrap_envelope=True, - fail_message="Failed to update file.", - file_id=input_data["file_id"], - name=input_data.get("name") or None, - description=input_data["description"] if "description" in input_data else None, - starred=input_data["starred"] if "starred" in input_data else None, - trashed=input_data["trashed"] if "trashed" in input_data else None, - ) - - -@action( - name="delete_drive_file", - description="Permanently delete a Drive file. Irreversible. To send to trash instead, use update_drive_file_metadata with trashed=true.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_drive_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_drive_file", - unwrap_envelope=True, - fail_message="Failed to delete file.", - file_id=input_data["file_id"], - ) - - -@action( - name="empty_drive_trash", - description="Permanently delete EVERYTHING in the user's Drive trash. Irreversible.", - action_sets=["google_drive_files"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def empty_drive_trash(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "empty_drive_trash", - unwrap_envelope=True, - fail_message="Failed to empty trash.", - ) - - -@action( - name="get_drive_about", - description="Get Drive account info: user, storage quota, max upload size. Set include_metadata to also get the supported export/import format maps.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "include_metadata": { - "type": "boolean", - "description": "Include exportFormats/importFormats maps (default false).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_drive_about(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_drive_about", - unwrap_envelope=True, - fail_message="Failed to get Drive info.", - include_metadata=bool(input_data.get("include_metadata", False)), - ) - - -@action( - name="find_drive_folder_by_name", - description="Find folder by name.", - action_sets=["google_drive_files", "google_drive"], - input_schema={ - "name": {"type": "string", "description": "Name.", "example": "Folder"}, - "parent_folder_id": { - "type": "string", - "description": "Parent.", - "example": "root", - }, - "from_email": { - "type": "string", - "description": "Email.", - "example": "me@example.com", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def find_drive_folder_by_name(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "find_drive_folder_by_name", - unwrap_envelope=True, - fail_message="Failed to find folder.", - name=input_data["name"], - parent_folder_id=input_data.get("parent_folder_id"), - ) - - -@action( - name="resolve_drive_folder_path", - description="Resolve folder path.", - action_sets=["google_drive_files"], - input_schema={ - "path": {"type": "string", "description": "Path.", "example": "Root/Folder"}, - "from_email": { - "type": "string", - "description": "Email.", - "example": "me@example.com", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def resolve_drive_folder_path(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - """Walks the path one segment at a time — custom 'not_found' shape.""" - parts = [p for p in input_data["path"].split("/") if p] - if parts and parts[0].lower() == "root": - parts = parts[1:] - current_folder_id = "root" - - for part in parts: - result = run_client_sync( - "google_drive", - "find_drive_folder_by_name", - unwrap_envelope=True, - fail_message=f"Failed to look up '{part}'", - name=part, - parent_folder_id=current_folder_id, - ) - if result["status"] == "error": - return {"status": "error", "reason": result.get("message", "API error")} - folder = result.get("result") - if not folder: - return { - "status": "not_found", - "reason": f"Folder '{part}' not found", - "folder_id": None, - } - current_folder_id = folder["id"] - - return {"status": "success", "folder_id": current_folder_id} - - -# ------------------------------------------------------------------ -# Permissions (sharing) -# ------------------------------------------------------------------ - - -@action( - name="list_drive_permissions", - description="List who has access to a Drive file or folder, with their role.", - action_sets=["google_drive_permissions", "google_drive"], - input_schema={ - "file_id": { - "type": "string", - "description": "File or folder ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_drive_permissions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_drive_permissions", - unwrap_envelope=True, - fail_message="Failed to list permissions.", - file_id=input_data["file_id"], - ) - - -@action( - name="get_drive_permission", - description="Get one specific permission by ID.", - action_sets=["google_drive_permissions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "permission_id": { - "type": "string", - "description": "Permission ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_drive_permission(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_drive_permission", - unwrap_envelope=True, - fail_message="Failed to get permission.", - file_id=input_data["file_id"], - permission_id=input_data["permission_id"], - ) - - -@action( - name="add_drive_permission", - description="Share a Drive file/folder. perm_type: user|group|domain|anyone. role: reader|commenter|writer|owner.", - action_sets=["google_drive_permissions", "google_drive"], - input_schema={ - "file_id": { - "type": "string", - "description": "File or folder ID.", - "example": "", - }, - "role": { - "type": "string", - "description": "reader, commenter, writer, or owner.", - "example": "reader", - }, - "perm_type": { - "type": "string", - "description": "user, group, domain, or anyone.", - "example": "user", - }, - "email_address": { - "type": "string", - "description": "Email (for user/group types).", - "example": "alice@example.com", - }, - "domain": { - "type": "string", - "description": "Domain (for domain type).", - "example": "", - }, - "send_notification": { - "type": "boolean", - "description": "Email the grantee.", - "example": True, - }, - "email_message": { - "type": "string", - "description": "Custom notification message (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_drive_permission(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "create_drive_permission", - unwrap_envelope=True, - fail_message="Failed to add permission.", - file_id=input_data["file_id"], - role=input_data["role"], - perm_type=input_data.get("perm_type", "user"), - email_address=input_data.get("email_address") or None, - domain=input_data.get("domain") or None, - send_notification=bool(input_data.get("send_notification", True)), - email_message=input_data.get("email_message") or None, - ) - - -@action( - name="update_drive_permission", - description="Change a permission's role.", - action_sets=["google_drive_permissions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "permission_id": { - "type": "string", - "description": "Permission ID.", - "example": "", - }, - "role": {"type": "string", "description": "New role.", "example": "writer"}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_permission(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_permission", - unwrap_envelope=True, - fail_message="Failed to update permission.", - file_id=input_data["file_id"], - permission_id=input_data["permission_id"], - role=input_data["role"], - ) - - -@action( - name="remove_drive_permission", - description="Revoke access by deleting a permission.", - action_sets=["google_drive_permissions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "permission_id": { - "type": "string", - "description": "Permission ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def remove_drive_permission(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_drive_permission", - unwrap_envelope=True, - fail_message="Failed to remove permission.", - file_id=input_data["file_id"], - permission_id=input_data["permission_id"], - ) - - -# ------------------------------------------------------------------ -# Comments + replies -# ------------------------------------------------------------------ - - -@action( - name="list_drive_comments", - description="List comments on a Drive file.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "include_deleted": { - "type": "boolean", - "description": "Include soft-deleted comments.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_drive_comments(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_drive_comments", - unwrap_envelope=True, - fail_message="Failed to list comments.", - file_id=input_data["file_id"], - include_deleted=bool(input_data.get("include_deleted", False)), - ) - - -@action( - name="get_drive_comment", - description="Get a single comment with its replies.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_drive_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_drive_comment", - unwrap_envelope=True, - fail_message="Failed to get comment.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - ) - - -@action( - name="create_drive_comment", - description="Post a top-level comment on a Drive file. anchor is an optional region anchor (Google's structured anchor format).", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "content": { - "type": "string", - "description": "Comment text.", - "example": "Please review.", - }, - "anchor": { - "type": "string", - "description": "Optional anchor (structured format).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_drive_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "create_drive_comment", - unwrap_envelope=True, - fail_message="Failed to create comment.", - file_id=input_data["file_id"], - content=input_data["content"], - anchor=input_data.get("anchor") or None, - ) - - -@action( - name="update_drive_comment", - description="Edit a comment's content or mark it resolved.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - "content": { - "type": "string", - "description": "New content (optional).", - "example": "", - }, - "resolved": { - "type": "boolean", - "description": "Mark as resolved (optional).", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_comment", - unwrap_envelope=True, - fail_message="Failed to update comment.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - content=input_data["content"] if "content" in input_data else None, - resolved=input_data["resolved"] if "resolved" in input_data else None, - ) - - -@action( - name="delete_drive_comment", - description="Delete a comment.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_drive_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_drive_comment", - unwrap_envelope=True, - fail_message="Failed to delete comment.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - ) - - -@action( - name="list_drive_comment_replies", - description="List replies on a comment.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_drive_comment_replies(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_drive_comment_replies", - unwrap_envelope=True, - fail_message="Failed to list replies.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - ) - - -@action( - name="create_drive_comment_reply", - description="Reply to a comment.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - "content": {"type": "string", "description": "Reply text.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_drive_comment_reply(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "create_drive_comment_reply", - unwrap_envelope=True, - fail_message="Failed to create reply.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - content=input_data["content"], - ) - - -@action( - name="update_drive_comment_reply", - description="Edit a reply.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, - "content": {"type": "string", "description": "New content.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_comment_reply(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_comment_reply", - unwrap_envelope=True, - fail_message="Failed to update reply.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - reply_id=input_data["reply_id"], - content=input_data["content"], - ) - - -@action( - name="delete_drive_comment_reply", - description="Delete a reply.", - action_sets=["google_drive_comments"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "comment_id": {"type": "string", "description": "Comment ID.", "example": ""}, - "reply_id": {"type": "string", "description": "Reply ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_drive_comment_reply(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_drive_comment_reply", - unwrap_envelope=True, - fail_message="Failed to delete reply.", - file_id=input_data["file_id"], - comment_id=input_data["comment_id"], - reply_id=input_data["reply_id"], - ) - - -# ------------------------------------------------------------------ -# Revisions (version history) -# ------------------------------------------------------------------ - - -@action( - name="list_drive_revisions", - description="List revisions (version history) of a Drive file.", - action_sets=["google_drive_revisions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_drive_revisions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_drive_revisions", - unwrap_envelope=True, - fail_message="Failed to list revisions.", - file_id=input_data["file_id"], - ) - - -@action( - name="get_drive_revision", - description="Get details of a specific revision.", - action_sets=["google_drive_revisions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_drive_revision(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_drive_revision", - unwrap_envelope=True, - fail_message="Failed to get revision.", - file_id=input_data["file_id"], - revision_id=input_data["revision_id"], - ) - - -@action( - name="update_drive_revision", - description="Mark a revision keep-forever (pin) or set publish state for Google-native files.", - action_sets=["google_drive_revisions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, - "keep_forever": { - "type": "boolean", - "description": "Pin this revision (otherwise Drive auto-prunes after 100 or 30 days, whichever first).", - "example": True, - }, - "published": { - "type": "boolean", - "description": "Publish state (Google-native files only).", - "example": False, - }, - "publish_auto": { - "type": "boolean", - "description": "Auto-publish subsequent revisions.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_drive_revision(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_drive_revision", - unwrap_envelope=True, - fail_message="Failed to update revision.", - file_id=input_data["file_id"], - revision_id=input_data["revision_id"], - keep_forever=input_data["keep_forever"] - if "keep_forever" in input_data - else None, - published=input_data["published"] if "published" in input_data else None, - publish_auto=input_data["publish_auto"] - if "publish_auto" in input_data - else None, - ) - - -@action( - name="delete_drive_revision", - description="Delete a revision.", - action_sets=["google_drive_revisions"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - "revision_id": {"type": "string", "description": "Revision ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_drive_revision(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_drive_revision", - unwrap_envelope=True, - fail_message="Failed to delete revision.", - file_id=input_data["file_id"], - revision_id=input_data["revision_id"], - ) - - -# ------------------------------------------------------------------ -# Shared drives (formerly Team Drives) -# ------------------------------------------------------------------ - - -@action( - name="list_shared_drives", - description="List shared drives the user has access to.", - action_sets=["google_drive_shared_drives"], - input_schema={ - "page_size": {"type": "integer", "description": "Max results.", "example": 50}, - "q": { - "type": "string", - "description": "Drive search query (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_shared_drives(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "list_shared_drives", - unwrap_envelope=True, - fail_message="Failed to list shared drives.", - page_size=input_data.get("page_size", 50), - q=input_data.get("q") or None, - ) - - -@action( - name="get_shared_drive", - description="Get metadata for a shared drive.", - action_sets=["google_drive_shared_drives"], - input_schema={ - "drive_id": { - "type": "string", - "description": "Shared drive ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_shared_drive(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "get_shared_drive", - unwrap_envelope=True, - fail_message="Failed to get shared drive.", - drive_id=input_data["drive_id"], - ) - - -@action( - name="create_shared_drive", - description="Create a new shared drive. The user must have permission to create shared drives in their org.", - action_sets=["google_drive_shared_drives"], - input_schema={ - "name": { - "type": "string", - "description": "Shared drive name.", - "example": "Team project", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_shared_drive(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "create_shared_drive", - unwrap_envelope=True, - fail_message="Failed to create shared drive.", - name=input_data["name"], - ) - - -@action( - name="update_shared_drive", - description="Rename or hide/unhide a shared drive.", - action_sets=["google_drive_shared_drives"], - input_schema={ - "drive_id": { - "type": "string", - "description": "Shared drive ID.", - "example": "", - }, - "name": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "hidden": { - "type": "boolean", - "description": "Hide from UI (optional).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_shared_drive(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "update_shared_drive", - unwrap_envelope=True, - fail_message="Failed to update shared drive.", - drive_id=input_data["drive_id"], - name=input_data.get("name") or None, - hidden=input_data["hidden"] if "hidden" in input_data else None, - ) - - -@action( - name="delete_shared_drive", - description="Delete a shared drive. The drive must be empty.", - action_sets=["google_drive_shared_drives"], - input_schema={ - "drive_id": { - "type": "string", - "description": "Shared drive ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_shared_drive(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_drive", - "delete_shared_drive", - unwrap_envelope=True, - fail_message="Failed to delete shared drive.", - drive_id=input_data["drive_id"], - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - Changes / watch endpoints (changes.list, changes.watch, channels.stop, etc.) -# Push notifications / incremental sync — server-side webhook plumbing, -# not per-interaction actions. -# - generateIds -# Pre-allocating IDs before insert. Niche; most agents just let Drive -# mint IDs on POST. -# - Resumable upload (uploadType=resumable) -# Used for very large uploads (>5MB) with progress tracking. The simple -# 2-step upload (metadata + uploadType=media PATCH) handles realistic -# file sizes; resumable can be added later if needed. -# - DriveAccess proposals / members management on shared drives -# Org-admin-level concerns, not personal-agent work. -# - Multipart/related upload (uploadType=multipart) -# The 2-step pattern in upload_drive_file gives equivalent semantics -# without the multipart-body construction. diff --git a/app/data/action/integrations/google_workspace/google_youtube_actions.py b/app/data/action/integrations/google_workspace/google_youtube_actions.py deleted file mode 100644 index d27b8924..00000000 --- a/app/data/action/integrations/google_workspace/google_youtube_actions.py +++ /dev/null @@ -1,430 +0,0 @@ -from agent_core import action - - -@action( - 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={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_my_youtube_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "get_my_channel", - unwrap_envelope=True, - fail_message="Failed to fetch channel.", - ) - - -@action( - name="search_youtube", - description="Search YouTube for videos, channels, or playlists. Lean results by default ({videoId/channelId/playlistId, title, channelTitle, publishedAt, description}); set include_metadata for raw results.", - action_sets=["google_youtube"], - input_schema={ - "query": { - "type": "string", - "description": "Search terms.", - "example": "claude code tutorial", - }, - "type": { - "type": "string", - "description": "What to search for: video, channel, or playlist.", - "example": "video", - }, - "max_results": { - "type": "integer", - "description": "Max number of results.", - "example": 25, - }, - "include_metadata": { - "type": "boolean", - "description": "Return raw search results (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_youtube(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_youtube", - "search", - unwrap_envelope=True, - fail_message="YouTube search failed.", - query=input_data["query"], - type_filter=input_data.get("type", "video"), - max_results=input_data.get("max_results", 25), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - lean = [] - for it in items: - if not isinstance(it, dict): - continue - snippet = it.get("snippet") or {} - rid = it.get("id") or {} - entry = {} - for key in ("videoId", "channelId", "playlistId"): - if isinstance(rid, dict) and rid.get(key): - entry[key] = rid[key] - entry.update( - { - "title": snippet.get("title"), - "channelTitle": snippet.get("channelTitle"), - "publishedAt": snippet.get("publishedAt"), - "description": snippet.get("description"), - } - ) - lean.append(entry) - res = {**res, "result": lean} - return res - - -@action( - name="get_youtube_video", - description="Get full metadata for a YouTube video (snippet, statistics, content details).", - action_sets=["google_youtube"], - input_schema={ - "video_id": { - "type": "string", - "description": "The YouTube video ID.", - "example": "dQw4w9WgXcQ", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_youtube_video(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "get_video", - unwrap_envelope=True, - fail_message="Failed to fetch video.", - video_id=input_data["video_id"], - ) - - -@action( - name="list_my_youtube_subscriptions", - description="List the channels the authenticated user is subscribed to. Lean results by default ({channelId, title, description}); set include_metadata for raw results (needed for the subscription ID used by unsubscribe).", - action_sets=["google_youtube"], - input_schema={ - "max_results": { - "type": "integer", - "description": "Max number of subscriptions to return.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return raw subscription resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_my_youtube_subscriptions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_youtube", - "list_my_subscriptions", - unwrap_envelope=True, - fail_message="Failed to list subscriptions.", - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - lean = [] - for it in items: - if not isinstance(it, dict): - continue - snippet = it.get("snippet") or {} - entry = { - "channelId": (snippet.get("resourceId") or {}).get("channelId"), - "title": snippet.get("title"), - } - if snippet.get("description"): - entry["description"] = snippet["description"] - lean.append(entry) - res = {**res, "result": lean} - return res - - -@action( - name="list_my_youtube_playlists", - description="List playlists owned by the authenticated user. Lean results by default ({id, title, itemCount}); set include_metadata for raw results.", - action_sets=["google_youtube"], - input_schema={ - "max_results": { - "type": "integer", - "description": "Max number of playlists to return.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return raw playlist resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_my_youtube_playlists(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_youtube", - "list_my_playlists", - unwrap_envelope=True, - fail_message="Failed to list playlists.", - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - res = { - **res, - "result": [ - { - "id": it.get("id"), - "title": (it.get("snippet") or {}).get("title"), - "itemCount": (it.get("contentDetails") or {}).get("itemCount"), - } - for it in items - if isinstance(it, dict) - ], - } - return res - - -@action( - name="list_youtube_playlist_items", - description="List videos in a YouTube playlist. Lean results by default ({videoId, title, position, publishedAt}); set include_metadata for raw results.", - action_sets=["google_youtube"], - input_schema={ - "playlist_id": { - "type": "string", - "description": "The playlist ID.", - "example": "PLrAXt...", - }, - "max_results": { - "type": "integer", - "description": "Max number of items to return.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return raw playlistItem resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_youtube_playlist_items(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_youtube", - "list_playlist_items", - unwrap_envelope=True, - fail_message="Failed to list playlist items.", - playlist_id=input_data["playlist_id"], - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - lean = [] - for it in items: - if not isinstance(it, dict): - continue - snippet = it.get("snippet") or {} - lean.append( - { - "videoId": (snippet.get("resourceId") or {}).get("videoId"), - "title": snippet.get("title"), - "position": snippet.get("position"), - "publishedAt": snippet.get("publishedAt"), - } - ) - res = {**res, "result": lean} - return res - - -@action( - name="subscribe_to_youtube_channel", - description="Subscribe the authenticated user to a YouTube channel.", - action_sets=["google_youtube"], - input_schema={ - "channel_id": { - "type": "string", - "description": "The channel ID to subscribe to.", - "example": "UC...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def subscribe_to_youtube_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "subscribe", - unwrap_envelope=True, - success_message="Subscribed.", - fail_message="Failed to subscribe.", - channel_id=input_data["channel_id"], - ) - - -@action( - name="unsubscribe_from_youtube_channel", - description="Remove a YouTube subscription. Takes the subscription ID (from list_my_youtube_subscriptions), not the channel ID.", - action_sets=["google_youtube"], - input_schema={ - "subscription_id": { - "type": "string", - "description": "The subscription record ID.", - "example": "abc123...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def unsubscribe_from_youtube_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "unsubscribe", - unwrap_envelope=True, - success_message="Unsubscribed.", - fail_message="Failed to unsubscribe.", - subscription_id=input_data["subscription_id"], - ) - - -@action( - name="rate_youtube_video", - description="Like, dislike, or clear your rating on a YouTube video.", - action_sets=["google_youtube"], - input_schema={ - "video_id": { - "type": "string", - "description": "The YouTube video ID.", - "example": "dQw4w9WgXcQ", - }, - "rating": { - "type": "string", - "description": "One of: like, dislike, none.", - "example": "like", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def rate_youtube_video(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "rate_video", - unwrap_envelope=True, - fail_message="Failed to rate video.", - video_id=input_data["video_id"], - rating=input_data["rating"], - ) - - -@action( - name="post_youtube_comment", - irreversible=True, - description="Post a top-level comment on a YouTube video.", - action_sets=["google_youtube"], - input_schema={ - "video_id": { - "type": "string", - "description": "The YouTube video ID.", - "example": "dQw4w9WgXcQ", - }, - "text": { - "type": "string", - "description": "Comment text.", - "example": "Great video!", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def post_youtube_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "google_youtube", - "post_comment", - unwrap_envelope=True, - success_message="Comment posted.", - fail_message="Failed to post comment.", - video_id=input_data["video_id"], - text=input_data["text"], - ) - - -@action( - name="get_youtube_video_comments", - description="Get top-level comments on a YouTube video, most recent first. Lean results by default ({author, text, likeCount, publishedAt, totalReplyCount}); set include_metadata for raw commentThread resources.", - action_sets=["google_youtube"], - input_schema={ - "video_id": { - "type": "string", - "description": "The YouTube video ID.", - "example": "dQw4w9WgXcQ", - }, - "max_results": { - "type": "integer", - "description": "Max number of comments to return.", - "example": 50, - }, - "include_metadata": { - "type": "boolean", - "description": "Return raw commentThread resources (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_youtube_video_comments(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "google_youtube", - "get_video_comments", - unwrap_envelope=True, - fail_message="Failed to fetch comments.", - video_id=input_data["video_id"], - max_results=input_data.get("max_results", 50), - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - items = res.get("result") - if isinstance(items, list): - lean = [] - for it in items: - if not isinstance(it, dict): - continue - thread = it.get("snippet") or {} - comment = (thread.get("topLevelComment") or {}).get("snippet") or {} - lean.append( - { - "author": comment.get("authorDisplayName"), - "text": comment.get("textOriginal") - or comment.get("textDisplay"), - "likeCount": comment.get("likeCount"), - "publishedAt": comment.get("publishedAt"), - "totalReplyCount": thread.get("totalReplyCount"), - } - ) - res = {**res, "result": lean} - return res diff --git a/app/data/action/integrations/hubspot/hubspot_actions.py b/app/data/action/integrations/hubspot/hubspot_actions.py deleted file mode 100644 index fd28557c..00000000 --- a/app/data/action/integrations/hubspot/hubspot_actions.py +++ /dev/null @@ -1,3508 +0,0 @@ -"""HubSpot action surface. - -Mirrors the HubSpot client in -``craftos_integrations/integrations/hubspot/__init__.py`` 1:1. Sub-sets are -prefixed with ``hubspot_`` per the action_set convention; the ``hubspot`` -umbrella tags the high-value 20% the agent should reach for by default. - -Identifier shape (always string): HubSpot returns numeric-looking IDs that -overflow JS number range — pass them through as strings. See -``craftos_integrations/integrations/hubspot/INTEGRATION.md`` for the full -gotcha list. -""" - -from agent_core import action - - -# ================================================================== -# Contacts -# ================================================================== - - -@action( - name="list_hubspot_contacts", - description="List HubSpot contacts. Paginated; pass 'after' from the previous response's paging.next.after to get more.", - action_sets=["hubspot_contacts", "hubspot"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max results (1-100, default 30).", - "example": 30, - }, - "after": { - "type": "string", - "description": "Pagination cursor from previous response.", - "example": "", - }, - "properties": { - "type": "string", - "description": "Comma-separated property names to include.", - "example": "email,firstname,lastname", - }, - "archived": { - "type": "boolean", - "description": "Include archived contacts.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_contacts(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_contacts", - 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, - archived=input_data.get("archived", False), - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_contact", - description="Get a HubSpot contact by ID. Returns properties and (if requested) associated objects.", - action_sets=["hubspot_contacts", "hubspot"], - input_schema={ - "contact_id": { - "type": "string", - "description": "HubSpot contact ID (numeric string).", - "example": "123456789", - }, - "properties": { - "type": "string", - "description": "Comma-separated property names to include.", - "example": "email,firstname,lastname,phone", - }, - "associations": { - "type": "string", - "description": "Comma-separated object types to include associations for.", - "example": "companies,deals", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_contact(input_data: dict) -> dict: - props = input_data.get("properties", "") - assocs = input_data.get("associations", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_contact", - 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, - ) - - -@action( - name="create_hubspot_contact", - description="Create a HubSpot contact. 'properties' is a flat dict like {email, firstname, lastname, phone, company}. Returns only {id}.", - action_sets=["hubspot_contacts", "hubspot"], - input_schema={ - "properties": { - "type": "object", - "description": "Flat property dict.", - "example": { - "email": "jane@example.com", - "firstname": "Jane", - "lastname": "Doe", - }, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_contact(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_contact", - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="update_hubspot_contact", - description="Update a HubSpot contact's properties. Returns only {id}.", - action_sets=["hubspot_contacts", "hubspot"], - input_schema={ - "contact_id": { - "type": "string", - "description": "Contact ID.", - "example": "123456789", - }, - "properties": { - "type": "object", - "description": "Properties to update (flat dict).", - "example": {"phone": "+1-555-0100"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_contact(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_contact", - contact_id=input_data["contact_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_contact", - description="Archive (soft-delete) a HubSpot contact. The record can be restored from the trash UI.", - action_sets=["hubspot_contacts"], - input_schema={ - "contact_id": { - "type": "string", - "description": "Contact ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="search_hubspot_contacts", - description="Search HubSpot contacts. Use 'query' for free-text or 'filter_groups' for precise property filters (operators: EQ, NEQ, GT, GTE, LT, LTE, BETWEEN, IN, NOT_IN, CONTAINS_TOKEN, HAS_PROPERTY).", - action_sets=["hubspot_contacts", "hubspot"], - input_schema={ - "query": { - "type": "string", - "description": "Free-text search across default searchable properties.", - "example": "jane@example.com", - }, - "filter_groups": { - "type": "array", - "description": "Filter groups: [{filters: [{propertyName, operator, value}]}].", - "example": [ - { - "filters": [ - { - "propertyName": "email", - "operator": "EQ", - "value": "jane@example.com", - } - ] - } - ], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties to return.", - "example": "email,firstname,lastname", - }, - "limit": { - "type": "integer", - "description": "Max results (1-100).", - "example": 30, - }, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def search_hubspot_contacts(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "search_contacts", - 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, - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="batch_get_hubspot_contacts", - description="Read up to 100 contacts in a single call. Cheaper than N gets.", - action_sets=["hubspot_contacts"], - input_schema={ - "ids": { - "type": "array", - "description": "Contact IDs.", - "example": ["123", "456", "789"], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties to return.", - "example": "email,firstname", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def batch_get_hubspot_contacts(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "batch_get_contacts", - ids=input_data["ids"], - properties=[p.strip() for p in props.split(",") if p.strip()] or None, - ) - - -@action( - name="batch_create_hubspot_contacts", - description="Create up to 100 contacts in a single call. 'records' is a list of flat property dicts. Returns only the created ids (+ errors if any).", - action_sets=["hubspot_contacts"], - input_schema={ - "records": { - "type": "array", - "description": "List of property dicts.", - "example": [{"email": "a@x.com"}, {"email": "b@x.com"}], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}, - }, - parallelizable=False, -) -async def batch_create_hubspot_contacts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", "batch_create_contacts", records=input_data["records"] - ) - r = res.get("result") - if ( - res.get("status") == "success" - and isinstance(r, dict) - and isinstance(r.get("results"), list) - ): - reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} - if r.get("numErrors"): - reduced["numErrors"] = r.get("numErrors") - reduced["errors"] = r.get("errors") - res = {**res, "result": reduced} - return res - - -@action( - name="merge_hubspot_contacts", - description="Merge two contacts. The primary contact survives; the secondary is archived with associations transferred. Returns only {id}.", - action_sets=["hubspot_contacts"], - input_schema={ - "primary_id": { - "type": "string", - "description": "Contact ID that survives the merge.", - "example": "123", - }, - "id_to_merge": { - "type": "string", - "description": "Contact ID that gets merged INTO the primary.", - "example": "456", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def merge_hubspot_contacts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "merge_contacts", - primary_id=input_data["primary_id"], - id_to_merge=input_data["id_to_merge"], - ) - return pick_result(res, ["id"]) - - -# ================================================================== -# Companies -# ================================================================== - - -@action( - name="list_hubspot_companies", - description="List HubSpot companies. Paginated via 'after' cursor.", - action_sets=["hubspot_companies", "hubspot"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max results (1-100).", - "example": 30, - }, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated property names.", - "example": "name,domain,industry", - }, - "archived": { - "type": "boolean", - "description": "Include archived.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_companies(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_companies", - 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, - archived=input_data.get("archived", False), - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_company", - description="Get a HubSpot company by ID.", - action_sets=["hubspot_companies"], - input_schema={ - "company_id": { - "type": "string", - "description": "Company ID (numeric string).", - "example": "123456789", - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "name,domain,industry,city", - }, - "associations": { - "type": "string", - "description": "Comma-separated association types.", - "example": "contacts,deals", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_company(input_data: dict) -> dict: - props = input_data.get("properties", "") - assocs = input_data.get("associations", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_company", - 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, - ) - - -@action( - name="create_hubspot_company", - description="Create a HubSpot company. Typical properties: name, domain, industry, city, country. Returns only {id}.", - action_sets=["hubspot_companies", "hubspot"], - input_schema={ - "properties": { - "type": "object", - "description": "Flat property dict.", - "example": {"name": "Acme Co", "domain": "acme.com"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_company(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", "create_company", properties=input_data["properties"] - ) - return pick_result(res, ["id"]) - - -@action( - name="update_hubspot_company", - description="Update a HubSpot company's properties. Returns only {id}.", - action_sets=["hubspot_companies"], - input_schema={ - "company_id": { - "type": "string", - "description": "Company ID.", - "example": "123456789", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {"industry": "Software"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_company(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_company", - company_id=input_data["company_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_company", - description="Archive (soft-delete) a HubSpot company.", - action_sets=["hubspot_companies"], - input_schema={ - "company_id": { - "type": "string", - "description": "Company ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="search_hubspot_companies", - description="Search HubSpot companies using query or filter_groups (same shape as contact search).", - action_sets=["hubspot_companies", "hubspot"], - input_schema={ - "query": { - "type": "string", - "description": "Free-text search.", - "example": "acme", - }, - "filter_groups": { - "type": "array", - "description": "Property filter groups.", - "example": [ - { - "filters": [ - { - "propertyName": "domain", - "operator": "EQ", - "value": "acme.com", - } - ] - } - ], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties to return.", - "example": "name,domain", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def search_hubspot_companies(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "search_companies", - 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, - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="batch_get_hubspot_companies", - description="Read up to 100 companies in a single call.", - action_sets=["hubspot_companies"], - input_schema={ - "ids": { - "type": "array", - "description": "Company IDs.", - "example": ["123", "456"], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "name,domain", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def batch_get_hubspot_companies(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "batch_get_companies", - ids=input_data["ids"], - properties=[p.strip() for p in props.split(",") if p.strip()] or None, - ) - - -@action( - name="batch_create_hubspot_companies", - description="Create up to 100 companies in a single call. Returns only the created ids (+ errors if any).", - action_sets=["hubspot_companies"], - input_schema={ - "records": { - "type": "array", - "description": "List of property dicts.", - "example": [{"name": "Acme"}, {"name": "Foo"}], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}, - }, - parallelizable=False, -) -async def batch_create_hubspot_companies(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", "batch_create_companies", records=input_data["records"] - ) - r = res.get("result") - if ( - res.get("status") == "success" - and isinstance(r, dict) - and isinstance(r.get("results"), list) - ): - reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} - if r.get("numErrors"): - reduced["numErrors"] = r.get("numErrors") - reduced["errors"] = r.get("errors") - res = {**res, "result": reduced} - return res - - -# ================================================================== -# Deals -# ================================================================== - - -@action( - name="list_hubspot_deals", - description="List HubSpot deals. Paginated.", - action_sets=["hubspot_deals", "hubspot"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "dealname,amount,dealstage,pipeline", - }, - "archived": { - "type": "boolean", - "description": "Include archived.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_deals(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_deals", - 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, - archived=input_data.get("archived", False), - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_deal", - description="Get a HubSpot deal by ID.", - action_sets=["hubspot_deals"], - input_schema={ - "deal_id": { - "type": "string", - "description": "Deal ID.", - "example": "123456789", - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "dealname,amount,dealstage,pipeline,closedate", - }, - "associations": { - "type": "string", - "description": "Comma-separated association types.", - "example": "contacts,companies", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_deal(input_data: dict) -> dict: - props = input_data.get("properties", "") - assocs = input_data.get("associations", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_deal", - 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, - ) - - -@action( - name="create_hubspot_deal", - description="Create a HubSpot deal. Typical properties: dealname, amount, dealstage, pipeline, closedate, hubspot_owner_id. Returns only {id}.", - action_sets=["hubspot_deals", "hubspot"], - input_schema={ - "properties": { - "type": "object", - "description": "Flat property dict.", - "example": { - "dealname": "Q3 renewal", - "amount": "50000", - "dealstage": "qualifiedtobuy", - }, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_deal(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", "create_deal", properties=input_data["properties"] - ) - return pick_result(res, ["id"]) - - -@action( - name="update_hubspot_deal", - description="Update a HubSpot deal's properties. Returns only {id}.", - action_sets=["hubspot_deals", "hubspot"], - input_schema={ - "deal_id": { - "type": "string", - "description": "Deal ID.", - "example": "123456789", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {"amount": "75000"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_deal(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_deal", - deal_id=input_data["deal_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_deal", - description="Archive (soft-delete) a HubSpot deal.", - action_sets=["hubspot_deals"], - input_schema={ - "deal_id": { - "type": "string", - "description": "Deal ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="search_hubspot_deals", - description="Search HubSpot deals via query or filter_groups.", - action_sets=["hubspot_deals"], - input_schema={ - "query": { - "type": "string", - "description": "Free-text search.", - "example": "renewal", - }, - "filter_groups": { - "type": "array", - "description": "Property filter groups.", - "example": [ - { - "filters": [ - { - "propertyName": "dealstage", - "operator": "EQ", - "value": "closedwon", - } - ] - } - ], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "dealname,amount", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def search_hubspot_deals(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "search_deals", - 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, - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="batch_create_hubspot_deals", - description="Create up to 100 deals in a single call. Returns only the created ids (+ errors if any).", - action_sets=["hubspot_deals"], - input_schema={ - "records": { - "type": "array", - "description": "List of property dicts.", - "example": [{"dealname": "A"}, {"dealname": "B"}], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {ids, numErrors?, errors?}."}, - }, - parallelizable=False, -) -async def batch_create_hubspot_deals(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", "batch_create_deals", records=input_data["records"] - ) - r = res.get("result") - if ( - res.get("status") == "success" - and isinstance(r, dict) - and isinstance(r.get("results"), list) - ): - reduced = {"ids": [i.get("id") for i in r["results"] if isinstance(i, dict)]} - if r.get("numErrors"): - reduced["numErrors"] = r.get("numErrors") - reduced["errors"] = r.get("errors") - res = {**res, "result": reduced} - return res - - -@action( - name="move_hubspot_deal_stage", - description="Move a deal to a different pipeline stage. Helper around updating the 'dealstage' property. Returns only {id}.", - action_sets=["hubspot_deals", "hubspot"], - input_schema={ - "deal_id": { - "type": "string", - "description": "Deal ID.", - "example": "123456789", - }, - "stage_id": { - "type": "string", - "description": "Target stage ID (use list_hubspot_pipeline_stages to find).", - "example": "closedwon", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def move_hubspot_deal_stage(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "move_deal_stage", - deal_id=input_data["deal_id"], - stage_id=input_data["stage_id"], - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_deals_by_pipeline", - description="List deals in a specific pipeline. Helper that wraps search with a pipeline filter.", - action_sets=["hubspot_deals"], - input_schema={ - "pipeline_id": { - "type": "string", - "description": "Pipeline ID.", - "example": "default", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_deals_by_pipeline(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_deals_by_pipeline", - pipeline_id=input_data["pipeline_id"], - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Tickets -# ================================================================== - - -@action( - name="list_hubspot_tickets", - description="List HubSpot support tickets. Paginated.", - action_sets=["hubspot_tickets", "hubspot"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "subject,content,hs_pipeline_stage,hs_ticket_priority", - }, - "archived": { - "type": "boolean", - "description": "Include archived.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_tickets(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_tickets", - 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, - archived=input_data.get("archived", False), - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_ticket", - description="Get a HubSpot ticket by ID.", - action_sets=["hubspot_tickets"], - input_schema={ - "ticket_id": { - "type": "string", - "description": "Ticket ID.", - "example": "123456789", - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "subject,content,hs_pipeline_stage", - }, - "associations": { - "type": "string", - "description": "Comma-separated association types.", - "example": "contacts,companies", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_ticket(input_data: dict) -> dict: - props = input_data.get("properties", "") - assocs = input_data.get("associations", "") - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_ticket", - 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, - ) - - -@action( - name="create_hubspot_ticket", - description="Create a HubSpot support ticket. Typical properties: subject, content, hs_pipeline, hs_pipeline_stage, hs_ticket_priority (LOW/MEDIUM/HIGH/URGENT). Returns only {id}.", - action_sets=["hubspot_tickets", "hubspot"], - input_schema={ - "properties": { - "type": "object", - "description": "Flat property dict.", - "example": { - "subject": "Login fails", - "content": "User can't log in", - "hs_ticket_priority": "HIGH", - }, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", "create_ticket", properties=input_data["properties"] - ) - return pick_result(res, ["id"]) - - -@action( - name="update_hubspot_ticket", - description="Update a HubSpot ticket's properties. Returns only {id}.", - action_sets=["hubspot_tickets"], - input_schema={ - "ticket_id": { - "type": "string", - "description": "Ticket ID.", - "example": "123456789", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {"hs_ticket_priority": "URGENT"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_ticket", - ticket_id=input_data["ticket_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_ticket", - description="Archive (soft-delete) a HubSpot ticket.", - action_sets=["hubspot_tickets"], - input_schema={ - "ticket_id": { - "type": "string", - "description": "Ticket ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="search_hubspot_tickets", - description="Search HubSpot tickets via query or filter_groups.", - action_sets=["hubspot_tickets"], - input_schema={ - "query": { - "type": "string", - "description": "Free-text search.", - "example": "login", - }, - "filter_groups": { - "type": "array", - "description": "Filter groups.", - "example": [ - { - "filters": [ - { - "propertyName": "hs_ticket_priority", - "operator": "EQ", - "value": "HIGH", - } - ] - } - ], - }, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "subject,content", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def search_hubspot_tickets(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "search_tickets", - 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, - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="close_hubspot_ticket", - description="Move a ticket to its closed stage. Helper around updating 'hs_pipeline_stage'. Returns only {id}.", - action_sets=["hubspot_tickets", "hubspot"], - input_schema={ - "ticket_id": { - "type": "string", - "description": "Ticket ID.", - "example": "123456789", - }, - "closed_stage_id": { - "type": "string", - "description": "Closed-stage ID for this pipeline (use list_hubspot_pipeline_stages).", - "example": "4", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def close_hubspot_ticket(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "close_ticket", - ticket_id=input_data["ticket_id"], - closed_stage_id=input_data["closed_stage_id"], - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_tickets_by_pipeline", - description="List tickets in a specific pipeline. Helper that wraps search.", - action_sets=["hubspot_tickets"], - input_schema={ - "pipeline_id": { - "type": "string", - "description": "Pipeline ID.", - "example": "0", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_tickets_by_pipeline(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_tickets_by_pipeline", - pipeline_id=input_data["pipeline_id"], - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Engagements (tasks / notes / calls / emails / meetings) -# ================================================================== - - -@action( - name="list_hubspot_tasks", - description="List HubSpot tasks (engagements).", - action_sets=["hubspot_engagements"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "hs_task_subject,hs_task_status,hs_timestamp", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_tasks(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_tasks", - 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, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="create_hubspot_task", - description="Create a HubSpot task. Optionally associate it with a contact/company/deal/ticket. Returns only {id}.", - action_sets=["hubspot_engagements", "hubspot"], - input_schema={ - "subject": { - "type": "string", - "description": "Task title.", - "example": "Follow up on demo", - }, - "body": { - "type": "string", - "description": "Task description.", - "example": "Ask about pricing tier", - }, - "due_timestamp_ms": { - "type": "integer", - "description": "Due date in ms since epoch.", - "example": 1735689600000, - }, - "owner_id": { - "type": "string", - "description": "Owner (user) ID to assign.", - "example": "12345", - }, - "priority": { - "type": "string", - "description": "NONE | LOW | MEDIUM | HIGH.", - "example": "MEDIUM", - }, - "status": { - "type": "string", - "description": "NOT_STARTED | IN_PROGRESS | WAITING | COMPLETED | DEFERRED.", - "example": "NOT_STARTED", - }, - "associated_object_type": { - "type": "string", - "description": "Type of object to associate (contacts/companies/deals/tickets).", - "example": "contacts", - }, - "associated_object_id": { - "type": "string", - "description": "ID of the associated object.", - "example": "123456789", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_task(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_task", - subject=input_data["subject"], - body=input_data.get("body", ""), - due_timestamp_ms=input_data.get("due_timestamp_ms"), - owner_id=input_data.get("owner_id") or None, - priority=input_data.get("priority", "NONE"), - status=input_data.get("status", "NOT_STARTED"), - associated_object_type=input_data.get("associated_object_type") or None, - associated_object_id=input_data.get("associated_object_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="update_hubspot_task", - description="Update a HubSpot task. Common updates: hs_task_status, hs_task_priority, hs_task_subject. Returns only {id}.", - action_sets=["hubspot_engagements"], - input_schema={ - "task_id": { - "type": "string", - "description": "Task ID.", - "example": "123456789", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {"hs_task_status": "COMPLETED"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_task(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_task", - task_id=input_data["task_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_task", - description="Archive a HubSpot task.", - action_sets=["hubspot_engagements"], - input_schema={ - "task_id": { - "type": "string", - "description": "Task ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="list_hubspot_notes", - description="List HubSpot notes (engagements).", - action_sets=["hubspot_engagements"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "hs_note_body,hs_timestamp", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_notes(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_notes", - 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, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="create_hubspot_note", - description="Create a HubSpot note (typically attached to a contact/company/deal/ticket). Returns only {id}.", - action_sets=["hubspot_engagements", "hubspot"], - input_schema={ - "body": { - "type": "string", - "description": "Note content (HTML supported).", - "example": "Customer mentioned interest in Enterprise tier", - }, - "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, - "associated_object_type": { - "type": "string", - "description": "contacts/companies/deals/tickets.", - "example": "contacts", - }, - "associated_object_id": { - "type": "string", - "description": "ID of associated object.", - "example": "123456789", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_note(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_note", - body=input_data["body"], - owner_id=input_data.get("owner_id") or None, - associated_object_type=input_data.get("associated_object_type") or None, - associated_object_id=input_data.get("associated_object_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_note", - description="Archive a HubSpot note.", - action_sets=["hubspot_engagements"], - input_schema={ - "note_id": { - "type": "string", - "description": "Note ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="list_hubspot_calls", - description="List HubSpot call engagements (logged calls).", - action_sets=["hubspot_engagements"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "hs_call_title,hs_call_duration,hs_call_direction", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_calls(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_calls", - 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, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="log_hubspot_call", - description="Log a phone call as a HubSpot engagement. Returns only {id}.", - action_sets=["hubspot_engagements", "hubspot"], - input_schema={ - "title": { - "type": "string", - "description": "Call title.", - "example": "Discovery call", - }, - "body": { - "type": "string", - "description": "Call notes.", - "example": "Discussed pricing", - }, - "timestamp_ms": { - "type": "integer", - "description": "When the call happened (ms epoch). Defaults to now.", - "example": 1735689600000, - }, - "duration_ms": { - "type": "integer", - "description": "Call duration in ms.", - "example": 600000, - }, - "from_number": { - "type": "string", - "description": "Caller phone.", - "example": "+1-555-0100", - }, - "to_number": { - "type": "string", - "description": "Callee phone.", - "example": "+1-555-0200", - }, - "direction": { - "type": "string", - "description": "INBOUND | OUTBOUND.", - "example": "OUTBOUND", - }, - "disposition": { - "type": "string", - "description": "Outcome ID (configured per portal).", - "example": "", - }, - "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, - "associated_object_type": { - "type": "string", - "description": "contacts/companies/deals/tickets.", - "example": "contacts", - }, - "associated_object_id": { - "type": "string", - "description": "Associated object ID.", - "example": "123456789", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def log_hubspot_call(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "log_call", - title=input_data["title"], - body=input_data.get("body", ""), - timestamp_ms=input_data.get("timestamp_ms"), - duration_ms=input_data.get("duration_ms"), - from_number=input_data.get("from_number") or None, - to_number=input_data.get("to_number") or None, - direction=input_data.get("direction", "OUTBOUND"), - disposition=input_data.get("disposition") or None, - owner_id=input_data.get("owner_id") or None, - associated_object_type=input_data.get("associated_object_type") or None, - associated_object_id=input_data.get("associated_object_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_emails", - description="List HubSpot email engagements (logged emails — not marketing email sends).", - action_sets=["hubspot_engagements"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "hs_email_subject,hs_email_direction", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_emails(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_emails", - 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, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="log_hubspot_email", - description="Log an email as a HubSpot engagement (for record-keeping; doesn't actually send). Returns only {id}.", - action_sets=["hubspot_engagements"], - input_schema={ - "subject": { - "type": "string", - "description": "Email subject.", - "example": "Re: Pricing", - }, - "text_body": { - "type": "string", - "description": "Plain-text body.", - "example": "Here's the proposal", - }, - "html_body": { - "type": "string", - "description": "HTML body (optional).", - "example": "", - }, - "timestamp_ms": { - "type": "integer", - "description": "When sent (ms epoch).", - "example": 1735689600000, - }, - "direction": { - "type": "string", - "description": "EMAIL (incoming) | INCOMING_EMAIL | FORWARDED_EMAIL.", - "example": "EMAIL", - }, - "from_email": { - "type": "string", - "description": "Sender.", - "example": "you@yourdomain.com", - }, - "to_email": { - "type": "string", - "description": "Recipient.", - "example": "customer@example.com", - }, - "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, - "associated_object_type": { - "type": "string", - "description": "contacts/companies/deals/tickets.", - "example": "contacts", - }, - "associated_object_id": { - "type": "string", - "description": "Associated object ID.", - "example": "123456789", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def log_hubspot_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "log_email", - subject=input_data["subject"], - text_body=input_data.get("text_body", ""), - html_body=input_data.get("html_body", ""), - timestamp_ms=input_data.get("timestamp_ms"), - direction=input_data.get("direction", "EMAIL"), - from_email=input_data.get("from_email") or None, - to_email=input_data.get("to_email") or None, - owner_id=input_data.get("owner_id") or None, - associated_object_type=input_data.get("associated_object_type") or None, - associated_object_id=input_data.get("associated_object_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_meetings", - description="List HubSpot meeting engagements.", - action_sets=["hubspot_engagements"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - "properties": { - "type": "string", - "description": "Comma-separated properties.", - "example": "hs_meeting_title,hs_meeting_start_time", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_meetings(input_data: dict) -> dict: - props = input_data.get("properties", "") - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_meetings", - 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, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="create_hubspot_meeting", - description="Create a HubSpot meeting engagement record. Returns only {id}.", - action_sets=["hubspot_engagements"], - input_schema={ - "title": { - "type": "string", - "description": "Meeting title.", - "example": "Quarterly review", - }, - "body": { - "type": "string", - "description": "Description / agenda.", - "example": "Review Q3 numbers", - }, - "start_timestamp_ms": { - "type": "integer", - "description": "Start time (ms epoch).", - "example": 1735689600000, - }, - "end_timestamp_ms": { - "type": "integer", - "description": "End time (ms epoch).", - "example": 1735693200000, - }, - "location": { - "type": "string", - "description": "Where (URL or address).", - "example": "https://zoom.us/j/123", - }, - "meeting_outcome": { - "type": "string", - "description": "Outcome ID (configured per portal).", - "example": "", - }, - "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, - "associated_object_type": { - "type": "string", - "description": "contacts/companies/deals/tickets.", - "example": "deals", - }, - "associated_object_id": { - "type": "string", - "description": "Associated object ID.", - "example": "123456789", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_meeting(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_meeting", - title=input_data["title"], - body=input_data.get("body", ""), - start_timestamp_ms=input_data["start_timestamp_ms"], - end_timestamp_ms=input_data["end_timestamp_ms"], - location=input_data.get("location") or None, - meeting_outcome=input_data.get("meeting_outcome") or None, - owner_id=input_data.get("owner_id") or None, - associated_object_type=input_data.get("associated_object_type") or None, - associated_object_id=input_data.get("associated_object_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_meeting", - description="Archive a HubSpot meeting engagement.", - action_sets=["hubspot_engagements"], - input_schema={ - "meeting_id": { - "type": "string", - "description": "Meeting ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -# ================================================================== -# Lists -# ================================================================== - - -@action( - name="list_hubspot_lists", - description="List/search HubSpot lists. Optionally filter to specific list IDs.", - action_sets=["hubspot_lists"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max results (1-500).", - "example": 30, - }, - "list_ids": { - "type": "array", - "description": "Optional: specific list IDs to fetch.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_lists(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_lists", - limit=input_data.get("limit", 30), - list_ids=input_data.get("list_ids") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_list", - description="Get a HubSpot list by ID.", - action_sets=["hubspot_lists"], - input_schema={ - "list_id": {"type": "string", "description": "List ID.", "example": "1"}, - }, - 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"]) - - -@action( - name="create_hubspot_list", - description="Create a HubSpot list. processing_type=MANUAL for static (you add contacts yourself); DYNAMIC for filter-based. Returns only {listId}.", - action_sets=["hubspot_lists"], - input_schema={ - "name": { - "type": "string", - "description": "List name.", - "example": "Q3 prospects", - }, - "object_type_id": { - "type": "string", - "description": "Object type ID (0-1=contact, 0-2=company, 0-3=deal, 0-5=ticket).", - "example": "0-1", - }, - "processing_type": { - "type": "string", - "description": "MANUAL or DYNAMIC.", - "example": "MANUAL", - }, - "filter_branch": { - "type": "object", - "description": "Filter tree for DYNAMIC lists.", - "example": {}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {listId}."}, - }, - parallelizable=False, -) -async def create_hubspot_list(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "create_list", - name=input_data["name"], - object_type_id=input_data.get("object_type_id", "0-1"), - processing_type=input_data.get("processing_type", "MANUAL"), - filter_branch=input_data.get("filter_branch") or None, - ) - r = res.get("result") - if res.get("status") == "success" and isinstance(r, dict): - lst = r.get("list") if isinstance(r.get("list"), dict) else r - list_id = lst.get("listId") or lst.get("id") - if list_id is not None: - res = {**res, "result": {"listId": list_id}} - return res - - -@action( - name="delete_hubspot_list", - description="Delete a HubSpot list.", - action_sets=["hubspot_lists"], - input_schema={ - "list_id": {"type": "string", "description": "List ID.", "example": "1"}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="add_contacts_to_hubspot_list", - description="Add contact IDs to a static (MANUAL) list. No-op on DYNAMIC lists.", - action_sets=["hubspot_lists"], - input_schema={ - "list_id": {"type": "string", "description": "List ID.", "example": "1"}, - "contact_ids": { - "type": "array", - "description": "Contact IDs to add.", - "example": ["123", "456"], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -async def add_contacts_to_hubspot_list(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "add_contacts_to_list", - list_id=input_data["list_id"], - contact_ids=input_data["contact_ids"], - ) - - -@action( - name="remove_contacts_from_hubspot_list", - description="Remove contact IDs from a static (MANUAL) list.", - action_sets=["hubspot_lists"], - input_schema={ - "list_id": {"type": "string", "description": "List ID.", "example": "1"}, - "contact_ids": { - "type": "array", - "description": "Contact IDs to remove.", - "example": ["123", "456"], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -async def remove_contacts_from_hubspot_list(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "remove_contacts_from_list", - list_id=input_data["list_id"], - contact_ids=input_data["contact_ids"], - ) - - -# ================================================================== -# Pipelines -# ================================================================== - - -@action( - name="list_hubspot_pipelines", - description="List all pipelines for an object type (typically 'deals' or 'tickets').", - action_sets=["hubspot_pipelines"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type: deals or tickets.", - "example": "deals", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_pipelines(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", "list_pipelines", object_type=input_data["object_type"] - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_pipeline", - description="Get a pipeline definition (including stages).", - action_sets=["hubspot_pipelines"], - input_schema={ - "object_type": { - "type": "string", - "description": "deals or tickets.", - "example": "deals", - }, - "pipeline_id": { - "type": "string", - "description": "Pipeline ID.", - "example": "default", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_pipeline(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_pipeline", - object_type=input_data["object_type"], - pipeline_id=input_data["pipeline_id"], - ) - - -@action( - name="create_hubspot_pipeline", - description="Create a new pipeline. 'stages' is a list of {label, displayOrder, metadata:{probability,...}} dicts. Returns only {id}.", - action_sets=["hubspot_pipelines"], - input_schema={ - "object_type": { - "type": "string", - "description": "deals or tickets.", - "example": "deals", - }, - "label": { - "type": "string", - "description": "Pipeline name.", - "example": "Renewals", - }, - "stages": { - "type": "array", - "description": "Stage definitions.", - "example": [ - {"label": "New", "displayOrder": 0, "metadata": {"probability": "0.1"}} - ], - }, - "display_order": { - "type": "integer", - "description": "Display order among pipelines.", - "example": 0, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_pipeline(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_pipeline", - object_type=input_data["object_type"], - label=input_data["label"], - stages=input_data["stages"], - display_order=input_data.get("display_order", 0), - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_pipeline_stages", - description="List the stages of a pipeline. Returns stage IDs needed for move_hubspot_deal_stage / close_hubspot_ticket.", - action_sets=["hubspot_pipelines"], - input_schema={ - "object_type": { - "type": "string", - "description": "deals or tickets.", - "example": "deals", - }, - "pipeline_id": { - "type": "string", - "description": "Pipeline ID.", - "example": "default", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_pipeline_stages(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_pipeline_stages", - object_type=input_data["object_type"], - pipeline_id=input_data["pipeline_id"], - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="update_hubspot_pipeline_stage", - description="Update a pipeline stage's properties (label, displayOrder, metadata). Returns only {id}.", - action_sets=["hubspot_pipelines"], - input_schema={ - "object_type": { - "type": "string", - "description": "deals or tickets.", - "example": "deals", - }, - "pipeline_id": { - "type": "string", - "description": "Pipeline ID.", - "example": "default", - }, - "stage_id": { - "type": "string", - "description": "Stage ID.", - "example": "qualifiedtobuy", - }, - "properties": { - "type": "object", - "description": "Stage fields to update.", - "example": {"label": "Qualified — Buying"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def update_hubspot_pipeline_stage(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_pipeline_stage", - object_type=input_data["object_type"], - pipeline_id=input_data["pipeline_id"], - stage_id=input_data["stage_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id"]) - - -# ================================================================== -# Owners -# ================================================================== - - -@action( - name="list_hubspot_owners", - description="List HubSpot users (owners). Use this to find owner IDs for assignment.", - action_sets=["hubspot_owners", "hubspot"], - input_schema={ - "email": { - "type": "string", - "description": "Optional: filter to one owner by email.", - "example": "", - }, - "limit": { - "type": "integer", - "description": "Max results (1-500).", - "example": 100, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_owners(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_owners", - email=input_data.get("email") or None, - limit=input_data.get("limit", 100), - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_owner", - description="Get a HubSpot owner (user) by ID.", - action_sets=["hubspot_owners"], - input_schema={ - "owner_id": {"type": "string", "description": "Owner ID.", "example": "12345"}, - }, - 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"]) - - -# ================================================================== -# Properties (custom-field schema management) -# ================================================================== - - -@action( - name="list_hubspot_properties", - description="List all defined properties for an object type. Use this to discover custom-field names before reading/writing them.", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "contacts/companies/deals/tickets or custom schema name.", - "example": "contacts", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_properties(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", "list_properties", object_type=input_data["object_type"] - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_property", - description="Get a property definition (type, options, group).", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type.", - "example": "contacts", - }, - "property_name": { - "type": "string", - "description": "Property internal name.", - "example": "firstname", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_property", - object_type=input_data["object_type"], - property_name=input_data["property_name"], - ) - - -@action( - name="create_hubspot_property", - description="Create a new custom property. 'definition' must include name, label, type, fieldType, groupName. Returns only {id, name, type}.", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type.", - "example": "contacts", - }, - "definition": { - "type": "object", - "description": "Property definition.", - "example": { - "name": "favorite_color", - "label": "Favorite color", - "type": "string", - "fieldType": "text", - "groupName": "contactinformation", - }, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id, name, type}."}, - }, - parallelizable=False, -) -async def create_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_property", - object_type=input_data["object_type"], - definition=input_data["definition"], - ) - return pick_result(res, ["id", "name", "type"]) - - -@action( - name="update_hubspot_property", - description="Update an existing property's definition (label, description, options). Returns only {id, name, type}.", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type.", - "example": "contacts", - }, - "property_name": { - "type": "string", - "description": "Property internal name.", - "example": "favorite_color", - }, - "definition": { - "type": "object", - "description": "Fields to update.", - "example": {"label": "Color preference"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id, name, type}."}, - }, - parallelizable=False, -) -async def update_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "update_property", - object_type=input_data["object_type"], - property_name=input_data["property_name"], - definition=input_data["definition"], - ) - return pick_result(res, ["id", "name", "type"]) - - -@action( - name="delete_hubspot_property", - description="Delete a custom property. Built-in HubSpot properties cannot be deleted.", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type.", - "example": "contacts", - }, - "property_name": { - "type": "string", - "description": "Property internal name.", - "example": "favorite_color", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -async def delete_hubspot_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "delete_property", - object_type=input_data["object_type"], - property_name=input_data["property_name"], - ) - - -@action( - name="list_hubspot_property_groups", - description="List property groups for an object type (the visual sections grouping properties in HubSpot UI).", - action_sets=["hubspot_properties"], - input_schema={ - "object_type": { - "type": "string", - "description": "Object type.", - "example": "contacts", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_property_groups(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_property_groups", - object_type=input_data["object_type"], - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Associations (object-to-object links) -# ================================================================== - - -@action( - name="create_hubspot_association", - description="Link two objects (e.g. attach a contact to a deal). Leaves association_type_id empty for the default association between the pair. Returns only {id}.", - action_sets=["hubspot_associations", "hubspot"], - input_schema={ - "from_object_type": { - "type": "string", - "description": "Source object type.", - "example": "deals", - }, - "from_object_id": { - "type": "string", - "description": "Source object ID.", - "example": "123", - }, - "to_object_type": { - "type": "string", - "description": "Target object type.", - "example": "contacts", - }, - "to_object_id": { - "type": "string", - "description": "Target object ID.", - "example": "456", - }, - "association_type_id": { - "type": "integer", - "description": "Optional: specific association type ID (use list_hubspot_association_types).", - "example": 0, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_association(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_association", - from_object_type=input_data["from_object_type"], - from_object_id=input_data["from_object_id"], - to_object_type=input_data["to_object_type"], - to_object_id=input_data["to_object_id"], - association_type_id=input_data.get("association_type_id") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_associations", - description="List all objects of a given type associated with a source object.", - action_sets=["hubspot_associations"], - input_schema={ - "from_object_type": { - "type": "string", - "description": "Source object type.", - "example": "deals", - }, - "from_object_id": { - "type": "string", - "description": "Source object ID.", - "example": "123", - }, - "to_object_type": { - "type": "string", - "description": "Target object type to look up.", - "example": "contacts", - }, - "limit": { - "type": "integer", - "description": "Max results (1-500).", - "example": 100, - }, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_associations(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_associations", - from_object_type=input_data["from_object_type"], - from_object_id=input_data["from_object_id"], - to_object_type=input_data["to_object_type"], - limit=input_data.get("limit", 100), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="delete_hubspot_association", - description="Remove an association between two objects.", - action_sets=["hubspot_associations"], - input_schema={ - "from_object_type": { - "type": "string", - "description": "Source type.", - "example": "deals", - }, - "from_object_id": { - "type": "string", - "description": "Source ID.", - "example": "123", - }, - "to_object_type": { - "type": "string", - "description": "Target type.", - "example": "contacts", - }, - "to_object_id": { - "type": "string", - "description": "Target ID.", - "example": "456", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -async def delete_hubspot_association(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "delete_association", - from_object_type=input_data["from_object_type"], - from_object_id=input_data["from_object_id"], - to_object_type=input_data["to_object_type"], - to_object_id=input_data["to_object_id"], - ) - - -@action( - name="list_hubspot_association_types", - description="List the available association types between two object types (used when you need a specific labeled association).", - action_sets=["hubspot_associations"], - input_schema={ - "from_object_type": { - "type": "string", - "description": "Source type.", - "example": "deals", - }, - "to_object_type": { - "type": "string", - "description": "Target type.", - "example": "contacts", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_association_types(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_association_types", - from_object_type=input_data["from_object_type"], - to_object_type=input_data["to_object_type"], - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Forms -# ================================================================== - - -@action( - name="list_hubspot_forms", - description="List HubSpot forms (marketing v3).", - action_sets=["hubspot_forms"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_forms(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_forms", - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_form", - description="Get a HubSpot form definition by ID.", - action_sets=["hubspot_forms"], - input_schema={ - "form_id": { - "type": "string", - "description": "Form GUID.", - "example": "abc12345-6789-0abc-def0-123456789abc", - }, - }, - 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"]) - - -@action( - name="submit_hubspot_form", - description="Programmatically submit a HubSpot form. 'fields' is a list of {name, value} dicts. Returns only {id}.", - action_sets=["hubspot_forms"], - input_schema={ - "portal_id": { - "type": "string", - "description": "Portal/hub ID.", - "example": "12345678", - }, - "form_guid": { - "type": "string", - "description": "Form GUID.", - "example": "abc12345-6789-0abc-def0-123456789abc", - }, - "fields": { - "type": "array", - "description": "Form fields to submit.", - "example": [ - {"name": "email", "value": "jane@example.com"}, - {"name": "firstname", "value": "Jane"}, - ], - }, - "context": { - "type": "object", - "description": "Optional context (hutk, pageUrl, pageName, ipAddress).", - "example": {"pageName": "Demo Request"}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def submit_hubspot_form(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "submit_form", - portal_id=input_data["portal_id"], - form_guid=input_data["form_guid"], - fields=input_data["fields"], - context=input_data.get("context") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="list_hubspot_form_submissions", - description="List submissions for a HubSpot form.", - action_sets=["hubspot_forms"], - input_schema={ - "form_guid": { - "type": "string", - "description": "Form GUID.", - "example": "abc12345-6789-0abc-def0-123456789abc", - }, - "limit": { - "type": "integer", - "description": "Max results (1-50).", - "example": 30, - }, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_form_submissions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_form_submissions", - form_guid=input_data["form_guid"], - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Marketing email -# ================================================================== - - -@action( - name="list_hubspot_marketing_emails", - description="List marketing email campaigns.", - action_sets=["hubspot_marketing_email"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_marketing_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_marketing_emails", - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_marketing_email", - description="Get a marketing email campaign by ID.", - action_sets=["hubspot_marketing_email"], - input_schema={ - "email_id": { - "type": "string", - "description": "Marketing email ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="send_hubspot_single_send", - irreversible=True, - description="Send a one-off transactional email based on a pre-built marketing email template. Returns only {id}.", - action_sets=["hubspot_marketing_email", "hubspot"], - input_schema={ - "email_id": { - "type": "string", - "description": "Marketing email template ID.", - "example": "123456789", - }, - "to_email": { - "type": "string", - "description": "Recipient email.", - "example": "jane@example.com", - }, - "custom_properties": { - "type": "object", - "description": "Optional template variables.", - "example": {"first_name": "Jane"}, - }, - "contact_properties": { - "type": "object", - "description": "Optional contact-property overrides.", - "example": {}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def send_hubspot_single_send(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "send_single_email", - email_id=input_data["email_id"], - to_email=input_data["to_email"], - custom_properties=input_data.get("custom_properties") or None, - contact_properties=input_data.get("contact_properties") or None, - ) - return pick_result(res, ["id"]) - - -@action( - name="get_hubspot_marketing_email_statistics", - description="Get aggregated send/open/click statistics for a marketing email.", - action_sets=["hubspot_marketing_email"], - input_schema={ - "email_id": { - "type": "string", - "description": "Marketing email ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_hubspot_marketing_email_statistics(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "get_marketing_email_statistics", - email_id=input_data["email_id"], - ) - - -# ================================================================== -# Files -# ================================================================== - - -@action( - name="upload_hubspot_file", - description="Upload a local file to the HubSpot file manager. 'access' controls visibility: PUBLIC_INDEXABLE / PUBLIC_NOT_INDEXABLE / HIDDEN / PRIVATE. Returns only {id, url}.", - action_sets=["hubspot_files"], - input_schema={ - "file_path": { - "type": "string", - "description": "Local path to the file.", - "example": "/tmp/contract.pdf", - }, - "folder_path": { - "type": "string", - "description": "HubSpot folder path.", - "example": "/", - }, - "access": { - "type": "string", - "description": "PUBLIC_INDEXABLE | PUBLIC_NOT_INDEXABLE | HIDDEN | PRIVATE.", - "example": "PRIVATE", - }, - "overwrite": { - "type": "boolean", - "description": "Overwrite existing file with the same name.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id, url}."}, - }, - parallelizable=False, -) -async def upload_hubspot_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "upload_file", - file_path=input_data["file_path"], - folder_path=input_data.get("folder_path", "/"), - access=input_data.get("access", "PRIVATE"), - overwrite=input_data.get("overwrite", False), - ) - return pick_result(res, ["id", "url"]) - - -@action( - name="get_hubspot_file", - description="Get a file's metadata (including URL).", - action_sets=["hubspot_files"], - input_schema={ - "file_id": { - "type": "string", - "description": "File ID.", - "example": "123456789", - }, - }, - 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"]) - - -@action( - name="delete_hubspot_file", - description="Delete a file from the HubSpot file manager.", - action_sets=["hubspot_files"], - input_schema={ - "file_id": { - "type": "string", - "description": "File ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="list_hubspot_folders", - description="List folders in the HubSpot file manager.", - action_sets=["hubspot_files"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_folders(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_folders", - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -# ================================================================== -# Conversations (Inbox) -# ================================================================== - - -@action( - name="list_hubspot_conversations", - description="List conversation threads in the HubSpot Inbox.", - action_sets=["hubspot_conversations"], - input_schema={ - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_conversations(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_conversations", - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="get_hubspot_conversation", - description="Get a conversation thread by ID.", - action_sets=["hubspot_conversations"], - input_schema={ - "thread_id": { - "type": "string", - "description": "Thread ID.", - "example": "123456789", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="list_hubspot_conversation_messages", - description="List messages in a conversation thread.", - action_sets=["hubspot_conversations"], - input_schema={ - "thread_id": { - "type": "string", - "description": "Thread ID.", - "example": "123456789", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 30}, - "after": {"type": "string", "description": "Pagination cursor.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_conversation_messages(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_conversation_messages", - thread_id=input_data["thread_id"], - limit=input_data.get("limit", 30), - after=input_data.get("after") or None, - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="send_hubspot_conversation_message", - irreversible=True, - description="Send a message into a conversation thread. Requires the channel + channel-account IDs from the thread metadata. Returns only {id}.", - action_sets=["hubspot_conversations"], - input_schema={ - "thread_id": { - "type": "string", - "description": "Thread ID.", - "example": "123456789", - }, - "text": { - "type": "string", - "description": "Message body.", - "example": "Thanks for reaching out!", - }, - "channel_id": { - "type": "string", - "description": "Channel ID (from thread metadata).", - "example": "1000", - }, - "channel_account_id": { - "type": "string", - "description": "Channel account ID (from thread metadata).", - "example": "12345", - }, - "recipients": { - "type": "array", - "description": "Recipient list [{actorId, deliveryIdentifier:{type,value}}].", - "example": [ - { - "actorId": "V-123", - "deliveryIdentifier": { - "type": "HS_EMAIL_ADDRESS", - "value": "jane@example.com", - }, - } - ], - }, - "sender_actor_id": { - "type": "string", - "description": "Optional sender actor ID.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def send_hubspot_conversation_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "send_conversation_message", - thread_id=input_data["thread_id"], - text=input_data["text"], - channel_id=input_data["channel_id"], - channel_account_id=input_data["channel_account_id"], - recipients=input_data["recipients"], - sender_actor_id=input_data.get("sender_actor_id") or None, - ) - return pick_result(res, ["id"]) - - -# ================================================================== -# Webhooks (App-level — requires HubSpot App ID, not portal ID) -# ================================================================== - - -@action( - name="list_hubspot_webhook_subscriptions", - description="List webhook subscriptions for a HubSpot App. Requires the App ID from the developer console.", - action_sets=["hubspot_webhooks"], - input_schema={ - "app_id": { - "type": "string", - "description": "HubSpot App ID (developer console).", - "example": "1234567", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def list_hubspot_webhook_subscriptions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - res = await run_client( - "hubspot", - "list_webhook_subscriptions", - app_id=input_data["app_id"], - ) - r = res.get("result") - if isinstance(r, dict): - for it in r.get("results") or []: - if isinstance(it, dict): - it.pop("archived", None) - it.pop("createdAt", None) - it.pop("updatedAt", None) - nxt = (r.get("paging") or {}).get("next") - if isinstance(nxt, dict): - nxt.pop("link", None) - return res - - -@action( - name="create_hubspot_webhook_subscription", - description="Subscribe a HubSpot App to an event type (e.g. contact.creation, contact.propertyChange). Returns only {id}.", - action_sets=["hubspot_webhooks"], - input_schema={ - "app_id": { - "type": "string", - "description": "HubSpot App ID.", - "example": "1234567", - }, - "event_type": { - "type": "string", - "description": "Event type to subscribe to.", - "example": "contact.creation", - }, - "property_name": { - "type": "string", - "description": "Property name (only for *.propertyChange event types).", - "example": "", - }, - "active": { - "type": "boolean", - "description": "Whether the subscription is active.", - "example": True, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "Only {id}."}, - }, - parallelizable=False, -) -async def create_hubspot_webhook_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "hubspot", - "create_webhook_subscription", - app_id=input_data["app_id"], - event_type=input_data["event_type"], - property_name=input_data.get("property_name") or None, - active=input_data.get("active", True), - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_hubspot_webhook_subscription", - description="Delete a webhook subscription.", - action_sets=["hubspot_webhooks"], - input_schema={ - "app_id": { - "type": "string", - "description": "HubSpot App ID.", - "example": "1234567", - }, - "subscription_id": { - "type": "string", - "description": "Subscription ID.", - "example": "abc123", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -async def delete_hubspot_webhook_subscription(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client - - return await run_client( - "hubspot", - "delete_webhook_subscription", - app_id=input_data["app_id"], - subscription_id=input_data["subscription_id"], - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# These HubSpot REST categories are admin / niche / non-user-facing and are -# excluded from this action surface. Add them later if a real use case appears. -# -# - Workflows / Automation API -# Workflow CRUD is admin-heavy and requires deep knowledge of HubSpot's -# visual builder semantics. The agent should USE existing workflows -# (via property writes that trigger them), not author new ones. -# - CMS Hub (pages, blogs, themes, modules, HubL templates) -# Site-author surface, not an agent surface. CraftBot is not a CMS. -# - CTAs (legacy + new) -# Marketing creative surface; rarely useful for agents. -# - Settings (users, teams, business units, brand kits, integration installs) -# Admin endpoints. Adding/removing users via an agent is rarely safe. -# - Quotes / Line Items / Products -# Commerce primitives; complex inter-object dependencies. Skip until a -# specific use case justifies the surface. -# - Payments / Subscriptions / Invoices (HubSpot Payments) -# Money-moving operations. Should require an explicit guarded action -# surface, not a default one. -# - Custom Objects / Custom Object Schemas (definitional) -# Schema authoring is admin-only and rare. Reading/writing instances -# of an existing custom object works via the generic /crm/v3/objects/{type} -# endpoints — already covered. -# - Analytics (events, custom behavioral events, attribution) -# Analytics ingestion + reporting is a category of its own; not useful -# for the conversational agent flow. -# - Email Subscriptions / Subscription Preferences -# Compliance-sensitive; the agent should not be flipping consent bits. -# - Single-Send API for marketing emails (legacy v1) -# Superseded by /marketing/v3/transactional/single-email/send — exposed. -# - Calling Extensions / Video Conferencing Extensions -# Provider plugins, not user-facing. diff --git a/app/data/action/integrations/integration_management.py b/app/data/action/integrations/integration_management.py index dd773b8f..4d545d64 100644 --- a/app/data/action/integrations/integration_management.py +++ b/app/data/action/integrations/integration_management.py @@ -58,9 +58,13 @@ def list_available_integrations(input_data: dict) -> dict: return {"status": "success", "integrations": [], "message": "Simulated mode"} try: - from craftos_integrations import list_integrations_sync as list_integrations + # multi-account providers (gmail, slack, notion, ...) source connection state + + # accounts from the multi-account IntegrationSystem; everything else + # keeps the legacy handler.status() path. Metadata (name, icon, + # auth_type, description) still comes from the legacy handlers. + from app.data.action.integrations._helpers import list_integrations_merged - integrations = list_integrations() + integrations = list_integrations_merged() filter_connected = input_data.get("filter_connected", False) if filter_connected: @@ -271,6 +275,25 @@ def connect_integration(input_data: dict) -> dict: ], } + # multi-account providers: validate the token the same way the legacy + # handler login does, then store through the integration system + # (multi-account store), never the legacy single-account save. + from app.data.action.integrations._helpers import ( + system_connect_token, + system_for, + ) + + v2_system = system_for(integration_id) + if v2_system is not None: + success, message = system_connect_token( + v2_system, integration_id, credentials + ) + return { + "status": "success" if success else "error", + "message": message, + "auth_type": "token", + } + loop = asyncio.new_event_loop() try: success, message = loop.run_until_complete( @@ -294,6 +317,26 @@ def connect_integration(input_data: dict) -> dict: "auth_type": supported_auth, } + # multi-account providers: real multi-account OAuth via the + # IntegrationSystem (account chooser, identity capture, + # listener reconcile) instead of the legacy handler flow. + from app.data.action.integrations._helpers import system_for + + v2_system = system_for(integration_id) + if v2_system is not None: + loop = asyncio.new_event_loop() + try: + success, message, _accounts = loop.run_until_complete( + v2_system.add_account(integration_id) + ) + finally: + loop.close() + return { + "status": "success" if success else "error", + "message": message, + "auth_type": "oauth", + } + loop = asyncio.new_event_loop() try: success, message = loop.run_until_complete( @@ -500,6 +543,37 @@ def check_integration_status(input_data: dict) -> dict: "message": result.get("message", ""), } + # multi-account providers: connection state + accounts come from the + # multi-account IntegrationSystem (never the legacy credential + # files). Status text uses the shared plan-§6 line format; the + # structured accounts array carries {identity, alias, isPrimary, + # listen}. + from app.data.action.integrations._helpers import ( + account_lines, + accounts_payload, + v2_display_name, + system_for, + ) + + v2_system = system_for(integration_id) + if v2_system is not None: + infos = v2_system.list_accounts(integration_id) + accounts = accounts_payload(infos) + name = v2_display_name(v2_system, integration_id) + if accounts: + lines = "\n".join(account_lines(infos)) + message = ( + f"{name} is connected with {len(accounts)} account(s):\n{lines}" + ) + else: + message = f"{name} is not connected." + return { + "status": "success", + "connected": bool(accounts), + "accounts": accounts, + "message": message, + } + # Otherwise check general integration status from craftos_integrations import ( get_integration_info_sync as get_integration_info, @@ -597,6 +671,19 @@ def disconnect_integration(input_data: dict) -> dict: return {"status": "error", "message": "integration_id is required."} try: + # multi-account providers: remove accounts through the multi-account + # IntegrationSystem (with account_id: just that account; without: + # all of them, plus a best-effort legacy-file double-cleanup). + from app.data.action.integrations._helpers import system_disconnect, system_for + + v2_system = system_for(integration_id) + if v2_system is not None: + success, message = system_disconnect(v2_system, integration_id, account_id) + return { + "status": "success" if success else "error", + "message": message, + } + from craftos_integrations import disconnect as _disconnect loop = asyncio.new_event_loop() diff --git a/app/data/action/integrations/linkedin/linkedin_actions.py b/app/data/action/integrations/linkedin/linkedin_actions.py deleted file mode 100644 index 530dda80..00000000 --- a/app/data/action/integrations/linkedin/linkedin_actions.py +++ /dev/null @@ -1,814 +0,0 @@ -from agent_core import action - - -def _person_urn(client) -> str: - """LinkedIn URN of the authenticated user — used as author for posts/likes/comments.""" - cred = client._load() - return ( - f"urn:li:person:{cred.linkedin_id}" - if cred.linkedin_id - else f"urn:li:person:{cred.user_id}" - ) - - -# ------------------------------------------------------------------ -# Profile -# ------------------------------------------------------------------ - - -@action( - name="get_linkedin_profile", - description="Get the authenticated user's LinkedIn profile.", - action_sets=["linkedin"], - input_schema={}, - 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") - - -# ------------------------------------------------------------------ -# Posts (text post / reshare / delete / get / list / org posts) -# ------------------------------------------------------------------ - - -@action( - name="create_linkedin_post", - description="Create a text post on LinkedIn.", - action_sets=["linkedin"], - input_schema={ - "text": { - "type": "string", - "description": "Post text (max 3000 chars).", - "example": "Excited to share...", - }, - "visibility": { - "type": "string", - "description": "Visibility: PUBLIC, CONNECTIONS, or LOGGED_IN.", - "example": "PUBLIC", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def create_linkedin_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.create_text_post( - _person_urn(c), - input_data["text"], - visibility=input_data.get("visibility", "PUBLIC"), - ), - ) - - -@action( - name="delete_linkedin_post", - description="Delete a LinkedIn post.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - 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"]) - - -@action( - name="get_linkedin_post", - description="Get a post.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - 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"]) - - -@action( - name="get_my_linkedin_posts", - description="Get my posts. Lean posts ({id, text, created, lifecycleState, media}) by default; include_metadata=true returns the full raw ugcPosts.", - action_sets=["linkedin"], - input_schema={ - "count": {"type": "integer", "description": "Count.", "example": 50}, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean posts. True: full raw ugcPosts.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def get_my_linkedin_posts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - res = await with_client( - "linkedin", - lambda c: c.get_posts_by_author( - _person_urn(c), count=input_data.get("count", 50) - ), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - # with_client wraps the raw client return — collapse its transport envelope - if isinstance(body, dict) and body.get("ok") is True and "result" in body: - body = body["result"] - if not isinstance(body, dict) or "error" in body: - return res - - posts = [] - for el in body.get("elements", []) or []: - if not isinstance(el, dict): - continue - share = (el.get("specificContent") or {}).get( - "com.linkedin.ugc.ShareContent" - ) or {} - p = { - "id": el.get("id"), - "text": (share.get("shareCommentary") or {}).get("text"), - "created": (el.get("created") or {}).get("time"), - "lifecycleState": el.get("lifecycleState"), - } - media = share.get("media") - if media: - p["media"] = [ - {k: v for k, v in m.items() if k in ("media", "originalUrl", "status")} - for m in media - if isinstance(m, dict) - ] - posts.append(p) - lean = {"posts": posts} - if isinstance(body.get("paging"), dict): - pg = body["paging"] - lean["paging"] = { - "start": pg.get("start"), - "count": pg.get("count"), - "total": pg.get("total"), - } - return {**res, "result": lean} - - -@action( - name="get_linkedin_organization_posts", - description="Get organization posts. Lean posts ({id, text, created, lifecycleState, media}) by default; include_metadata=true returns the full raw ugcPosts.", - action_sets=["linkedin"], - input_schema={ - "organization_urn": { - "type": "string", - "description": "Org URN.", - "example": "urn:li:organization:123", - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean posts. True: full raw ugcPosts.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_linkedin_organization_posts(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "linkedin", - "get_posts_by_author", - author_urn=input_data["organization_urn"], - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if isinstance(body, dict) and body.get("ok") is True and "result" in body: - body = body["result"] - if not isinstance(body, dict) or "error" in body: - return res - - posts = [] - for el in body.get("elements", []) or []: - if not isinstance(el, dict): - continue - share = (el.get("specificContent") or {}).get( - "com.linkedin.ugc.ShareContent" - ) or {} - p = { - "id": el.get("id"), - "text": (share.get("shareCommentary") or {}).get("text"), - "created": (el.get("created") or {}).get("time"), - "lifecycleState": el.get("lifecycleState"), - } - media = share.get("media") - if media: - p["media"] = [ - {k: v for k, v in m.items() if k in ("media", "originalUrl", "status")} - for m in media - if isinstance(m, dict) - ] - posts.append(p) - lean = {"posts": posts} - if isinstance(body.get("paging"), dict): - pg = body["paging"] - lean["paging"] = { - "start": pg.get("start"), - "count": pg.get("count"), - "total": pg.get("total"), - } - return {**res, "result": lean} - - -@action( - name="reshare_linkedin_post", - description="Reshare a post.", - action_sets=["linkedin"], - input_schema={ - "original_post_urn": { - "type": "string", - "description": "Original Post URN.", - "example": "urn:li:share:123", - }, - "commentary": { - "type": "string", - "description": "Commentary.", - "example": "Interesting!", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def reshare_linkedin_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.reshare_post( - _person_urn(c), - input_data["original_post_urn"], - commentary=input_data.get("commentary", ""), - ), - ) - - -# ------------------------------------------------------------------ -# Reactions / Comments -# ------------------------------------------------------------------ - - -@action( - name="like_linkedin_post", - description="Like a post.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def like_linkedin_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.like_post(_person_urn(c), input_data["post_urn"]), - ) - - -@action( - name="unlike_linkedin_post", - description="Unlike a post.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def unlike_linkedin_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.unlike_post(_person_urn(c), input_data["post_urn"]), - ) - - -@action( - name="get_linkedin_post_likes", - description="Get post likes.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="comment_on_linkedin_post", - description="Comment on a post.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - }, - "text": { - "type": "string", - "description": "Comment text.", - "example": "Great post!", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def comment_on_linkedin_post(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.comment_on_post( - _person_urn(c), input_data["post_urn"], input_data["text"] - ), - ) - - -@action( - name="get_linkedin_post_comments", - description="Get post comments.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="delete_linkedin_comment", - description="Delete a comment.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - }, - "comment_urn": { - "type": "string", - "description": "Comment URN.", - "example": "urn:li:comment:123", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def delete_linkedin_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.delete_comment( - _person_urn(c), input_data["post_urn"], input_data["comment_urn"] - ), - ) - - -# ------------------------------------------------------------------ -# Connections / Invitations / Messages -# ------------------------------------------------------------------ - - -@action( - name="get_linkedin_connections", - description="Get the authenticated user's LinkedIn connections.", - action_sets=["linkedin"], - input_schema={ - "count": { - "type": "integer", - "description": "Number of connections to return.", - "example": 50, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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) - ) - - -@action( - name="send_linkedin_message", - irreversible=True, - description="Send a message to LinkedIn users.", - action_sets=["linkedin"], - input_schema={ - "recipient_urns": { - "type": "array", - "description": "List of recipient URNs (urn:li:person:xxx).", - "example": [], - }, - "subject": { - "type": "string", - "description": "Message subject.", - "example": "Hello", - }, - "body": { - "type": "string", - "description": "Message body.", - "example": "Hi, I wanted to connect...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def send_linkedin_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.send_message_to_recipients( - _person_urn(c), - input_data["recipient_urns"], - input_data["subject"], - input_data["body"], - ), - ) - - -@action( - name="send_linkedin_connection_request", - irreversible=True, - description="Send connection request.", - action_sets=["linkedin"], - input_schema={ - "invitee_profile_urn": { - "type": "string", - "description": "Profile URN.", - "example": "urn:li:person:123", - }, - "message": {"type": "string", "description": "Message.", "example": "Hi"}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def send_linkedin_connection_request(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "linkedin", - "send_connection_request", - invitee_profile_urn=input_data["invitee_profile_urn"], - message=input_data.get("message"), - ) - - -@action( - name="get_linkedin_sent_invitations", - description="Get sent invitations.", - action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, - 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) - ) - - -@action( - name="get_linkedin_received_invitations", - description="Get received invitations.", - action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 50}}, - 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) - ) - - -@action( - name="respond_to_linkedin_invitation", - description="Respond to invitation.", - action_sets=["linkedin"], - input_schema={ - "invitation_urn": { - "type": "string", - "description": "Invitation URN.", - "example": "urn:li:invitation:123", - }, - "action": { - "type": "string", - "description": "accept/ignore.", - "example": "accept", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def respond_to_linkedin_invitation(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "linkedin", - "respond_to_invitation", - invitation_urn=input_data["invitation_urn"], - action=input_data["action"], - ) - - -@action( - name="get_linkedin_conversations", - description="Get conversations.", - action_sets=["linkedin"], - input_schema={"count": {"type": "integer", "description": "Count.", "example": 20}}, - 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) - ) - - -# ------------------------------------------------------------------ -# Search / Lookups -# ------------------------------------------------------------------ - - -@action( - name="search_linkedin_jobs", - description="Search for job postings on LinkedIn.", - action_sets=["linkedin"], - input_schema={ - "keywords": { - "type": "string", - "description": "Job search keywords.", - "example": "software engineer", - }, - "location": { - "type": "string", - "description": "Optional location filter.", - "example": "", - }, - "count": { - "type": "integer", - "description": "Number of results.", - "example": 25, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_linkedin_jobs(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "linkedin", - "search_jobs", - keywords=input_data["keywords"], - location=input_data.get("location"), - count=input_data.get("count", 25), - ) - - -@action( - name="get_linkedin_job_details", - description="Get job details.", - action_sets=["linkedin"], - input_schema={ - "job_id": {"type": "string", "description": "Job ID.", "example": "123"} - }, - 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"]) - - -@action( - name="search_linkedin_companies", - description="Search companies.", - action_sets=["linkedin"], - input_schema={ - "keywords": {"type": "string", "description": "Keywords.", "example": "tech"} - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="lookup_linkedin_company", - description="Lookup company by vanity name.", - action_sets=["linkedin"], - input_schema={ - "vanity_name": { - "type": "string", - "description": "Vanity name.", - "example": "microsoft", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="get_linkedin_person", - description="Get person profile by ID.", - action_sets=["linkedin"], - input_schema={ - "person_id": {"type": "string", "description": "Person ID.", "example": "123"} - }, - 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"]) - - -# ------------------------------------------------------------------ -# Organizations / Analytics / Follow -# ------------------------------------------------------------------ - - -@action( - name="get_linkedin_organizations", - description="Get user's organizations.", - action_sets=["linkedin"], - input_schema={}, - 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") - - -@action( - name="get_linkedin_organization_info", - description="Get organization info.", - action_sets=["linkedin"], - input_schema={ - "organization_id": { - "type": "string", - "description": "Org ID.", - "example": "123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="get_linkedin_organization_analytics", - description="Get organization analytics.", - action_sets=["linkedin"], - input_schema={ - "organization_urn": { - "type": "string", - "description": "Org URN.", - "example": "urn:li:organization:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_linkedin_organization_analytics(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "linkedin", - "get_organization_analytics", - organization_urn=input_data["organization_urn"], - ) - - -@action( - name="get_linkedin_post_analytics", - description="Get post analytics.", - action_sets=["linkedin"], - input_schema={ - "post_urn": { - "type": "string", - "description": "Post URN.", - "example": "urn:li:share:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"]] - ) - - -@action( - name="follow_linkedin_organization", - description="Follow organization.", - action_sets=["linkedin"], - input_schema={ - "organization_urn": { - "type": "string", - "description": "Org URN.", - "example": "urn:li:organization:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def follow_linkedin_organization(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.follow_organization(_person_urn(c), input_data["organization_urn"]), - ) - - -@action( - name="unfollow_linkedin_organization", - description="Unfollow organization.", - action_sets=["linkedin"], - input_schema={ - "organization_urn": { - "type": "string", - "description": "Org URN.", - "example": "urn:li:organization:123", - } - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -async def unfollow_linkedin_organization(input_data: dict) -> dict: - from app.data.action.integrations._helpers import with_client - - return await with_client( - "linkedin", - lambda c: c.unfollow_organization( - _person_urn(c), input_data["organization_urn"] - ), - ) diff --git a/app/data/action/integrations/notion/notion_actions.py b/app/data/action/integrations/notion/notion_actions.py deleted file mode 100644 index b9fa9e4f..00000000 --- a/app/data/action/integrations/notion/notion_actions.py +++ /dev/null @@ -1,1136 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# Search (workspace-wide) -# ------------------------------------------------------------------ - - -@action( - name="search_notion", - description="Search Notion workspace for pages and databases. Lean results ({id, object, title, url}) by default; include_metadata=true returns the full raw objects (properties, timestamps, parents, ...).", - action_sets=["notion"], - input_schema={ - "query": { - "type": "string", - "description": "Search query.", - "example": "meeting notes", - }, - "filter_type": { - "type": "string", - "description": "Optional: 'page' or 'database'.", - "example": "page", - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean {id, object, title, url} per result. True: full raw.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_notion(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "notion", - "search", - query=input_data["query"], - filter_type=input_data.get("filter_type"), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - items = res.get("result") - if not isinstance(items, list): - return res - - def _plain(rt) -> str: - return "".join( - x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) - ) - - lean = [] - for it in items: - if not isinstance(it, dict) or "error" in it: - lean.append(it) - continue - if isinstance(it.get("title"), list): # database object - title = _plain(it["title"]) - else: # page object — title lives in the title-type property - title = "" - for p in (it.get("properties") or {}).values(): - if isinstance(p, dict) and p.get("type") == "title": - title = _plain(p.get("title")) - break - lean.append( - { - "id": it.get("id"), - "object": it.get("object"), - "title": title, - "url": it.get("url"), - } - ) - return {**res, "result": lean} - - -# ------------------------------------------------------------------ -# Pages -# ------------------------------------------------------------------ - - -@action( - name="get_notion_page", - description="Get a Notion page by ID (returns metadata + properties, not block content). Lean {id, url, archived, properties: {name: plain value}} by default; include_metadata=true returns the full raw page object.", - action_sets=["notion_pages", "notion"], - input_schema={ - "page_id": { - "type": "string", - "description": "Notion page ID.", - "example": "abc123", - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean page with plain property values. True: full raw.", - "example": False, - }, - }, - 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 - - res = run_client_sync("notion", "get_page", page_id=input_data["page_id"]) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _plain(rt) -> str: - return "".join( - x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) - ) - - def _prop_value(p): - if not isinstance(p, dict): - return p - t = p.get("type") - v = p.get(t) - if t in ("title", "rich_text"): - return _plain(v) - if t in ("select", "status"): - return (v or {}).get("name") - if t == "multi_select": - return [o.get("name") for o in (v or []) if isinstance(o, dict)] - if t == "date": - return ( - {"start": v.get("start"), "end": v.get("end")} - if isinstance(v, dict) - else None - ) - if t == "people": - return [ - u.get("name") or u.get("id") for u in (v or []) if isinstance(u, dict) - ] - if t == "relation": - return [r.get("id") for r in (v or []) if isinstance(r, dict)] - if t in ("formula", "rollup"): - inner = (v or {}).get("type") - return (v or {}).get(inner) - if t in ("created_by", "last_edited_by"): - return (v or {}).get("name") or (v or {}).get("id") - if t == "files": - return [f.get("name") for f in (v or []) if isinstance(f, dict)] - return v - - lean = { - "id": body.get("id"), - "url": body.get("url"), - "archived": body.get("archived"), - "properties": { - name: _prop_value(p) for name, p in (body.get("properties") or {}).items() - }, - } - return {**res, "result": lean} - - -@action( - name="create_notion_page", - description="Create a new page in Notion.", - action_sets=["notion_pages", "notion"], - input_schema={ - "parent_id": { - "type": "string", - "description": "Parent page or database ID.", - "example": "abc123", - }, - "parent_type": { - "type": "string", - "description": "'page_id' or 'database_id'.", - "example": "page_id", - }, - "properties": { - "type": "object", - "description": "Page properties.", - "example": {"title": [{"text": {"content": "New Page"}}]}, - }, - "children": { - "type": "array", - "description": "Optional content blocks.", - "example": [], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "{id, url} of the new page."}, - }, - parallelizable=False, -) -def create_notion_page(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "notion", - "create_page", - parent_id=input_data["parent_id"], - parent_type=input_data["parent_type"], - properties=input_data["properties"], - children=input_data.get("children"), - ) - return pick_result(res, ["id", "url"]) - - -@action( - name="update_notion_page", - description="Update a Notion page's properties (and/or archive state).", - action_sets=["notion_pages", "notion"], - input_schema={ - "page_id": { - "type": "string", - "description": "Page ID to update.", - "example": "abc123", - }, - "properties": { - "type": "object", - "description": "Properties to update.", - "example": {}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "{id, url} of the updated page."}, - }, - parallelizable=False, -) -def update_notion_page(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "notion", - "update_page", - page_id=input_data["page_id"], - properties=input_data["properties"], - ) - return pick_result(res, ["id", "url"]) - - -@action( - name="archive_notion_page", - description="Archive a Notion page (send to trash). Reversible via restore_notion_page.", - action_sets=["notion_pages", "notion"], - input_schema={ - "page_id": {"type": "string", "description": "Page ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="restore_notion_page", - description="Restore a previously-archived Notion page.", - action_sets=["notion_pages"], - input_schema={ - "page_id": {"type": "string", "description": "Page ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="get_notion_page_property", - description="Get a single page property's value. For rollup/relation/people properties that paginate, this returns the full list.", - action_sets=["notion_pages"], - input_schema={ - "page_id": {"type": "string", "description": "Page ID.", "example": ""}, - "property_id": { - "type": "string", - "description": "Property ID (from page schema).", - "example": "", - }, - "page_size": { - "type": "integer", - "description": "Pagination size.", - "example": 100, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_notion_page_property(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "get_page_property", - page_id=input_data["page_id"], - property_id=input_data["property_id"], - page_size=input_data.get("page_size", 100), - ) - - -# ------------------------------------------------------------------ -# Databases -# ------------------------------------------------------------------ - - -@action( - name="get_notion_database_schema", - description="Get a Notion database schema by ID. Lean {id, title, url, properties: {name: type (+options for select/multi_select/status)}} by default; include_metadata=true returns the full raw database object.", - action_sets=["notion_databases", "notion"], - input_schema={ - "database_id": { - "type": "string", - "description": "Database ID.", - "example": "abc123", - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean schema (property name -> type). True: full raw.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "database": {"type": "object"}, - }, -) -def get_notion_database_schema(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "notion", "get_database", database_id=input_data["database_id"] - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _plain(rt) -> str: - return "".join( - x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) - ) - - props = {} - for name, p in (body.get("properties") or {}).items(): - if not isinstance(p, dict): - continue - t = p.get("type") - if t in ("select", "multi_select", "status"): - options = (p.get(t) or {}).get("options") or [] - props[name] = { - "type": t, - "options": [o.get("name") for o in options if isinstance(o, dict)], - } - else: - props[name] = t - lean = { - "id": body.get("id"), - "title": _plain(body.get("title")), - "url": body.get("url"), - "properties": props, - } - return {**res, "result": lean} - - -@action( - name="query_notion_database", - description="Query a Notion database with optional filters and sorts. Lean rows ({id, url, properties: {name: plain value}}) by default; include_metadata=true returns the full raw page objects.", - action_sets=["notion_databases", "notion"], - input_schema={ - "database_id": { - "type": "string", - "description": "Database ID.", - "example": "abc123", - }, - "filter": { - "type": "object", - "description": "Optional Notion filter object.", - "example": {}, - }, - "sorts": { - "type": "array", - "description": "Optional sort array.", - "example": [], - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean rows with plain property values. True: full raw.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def query_notion_database(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "notion", - "query_database", - database_id=input_data["database_id"], - filter_obj=input_data.get("filter"), - sorts=input_data.get("sorts"), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _plain(rt) -> str: - return "".join( - x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict) - ) - - def _prop_value(p): - if not isinstance(p, dict): - return p - t = p.get("type") - v = p.get(t) - if t in ("title", "rich_text"): - return _plain(v) - if t in ("select", "status"): - return (v or {}).get("name") - if t == "multi_select": - return [o.get("name") for o in (v or []) if isinstance(o, dict)] - if t == "date": - return ( - {"start": v.get("start"), "end": v.get("end")} - if isinstance(v, dict) - else None - ) - if t == "people": - return [ - u.get("name") or u.get("id") for u in (v or []) if isinstance(u, dict) - ] - if t == "relation": - return [r.get("id") for r in (v or []) if isinstance(r, dict)] - if t in ("formula", "rollup"): - inner = (v or {}).get("type") - return (v or {}).get(inner) - if t in ("created_by", "last_edited_by"): - return (v or {}).get("name") or (v or {}).get("id") - if t == "files": - return [f.get("name") for f in (v or []) if isinstance(f, dict)] - return v - - lean = { - "results": [ - { - "id": row.get("id"), - "url": row.get("url"), - "properties": { - name: _prop_value(p) - for name, p in (row.get("properties") or {}).items() - }, - } - for row in body.get("results", []) or [] - if isinstance(row, dict) - ], - "has_more": body.get("has_more"), - "next_cursor": body.get("next_cursor"), - } - return {**res, "result": lean} - - -@action( - name="create_notion_database", - description="Create a new database under a parent page. Schema goes in 'properties' (each value is a property type config like {'title': {}} / {'rich_text': {}} / {'select': {'options': [...]}}).", - action_sets=["notion_databases", "notion"], - input_schema={ - "parent_page_id": { - "type": "string", - "description": "Parent page ID.", - "example": "", - }, - "title": { - "type": "array", - "description": "Title rich_text array.", - "example": [{"text": {"content": "Tasks"}}], - }, - "description": { - "type": "array", - "description": "Description rich_text array (optional).", - "example": [], - }, - "properties": { - "type": "object", - "description": "Property schema (column definitions). Required.", - "example": {"Name": {"title": {}}}, - }, - "is_inline": { - "type": "boolean", - "description": "Render inline.", - "example": False, - }, - "icon": { - "type": "object", - "description": "Icon (optional). e.g. {'type':'emoji','emoji':'📋'}.", - "example": {}, - }, - "cover": {"type": "object", "description": "Cover (optional).", "example": {}}, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "{id, url} of the new database."}, - }, - parallelizable=False, -) -def create_notion_database(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "notion", - "create_database", - parent_page_id=input_data["parent_page_id"], - title=input_data.get("title"), - description=input_data.get("description"), - properties=input_data.get("properties"), - is_inline=bool(input_data.get("is_inline", False)), - icon=input_data.get("icon") or None, - cover=input_data.get("cover") or None, - ) - return pick_result(res, ["id", "url"]) - - -@action( - name="update_notion_database", - description="Update a Notion database (title, description, schema, inline state).", - action_sets=["notion_databases", "notion"], - input_schema={ - "database_id": {"type": "string", "description": "Database ID.", "example": ""}, - "title": { - "type": "array", - "description": "New title rich_text (optional).", - "example": [], - }, - "description": { - "type": "array", - "description": "New description rich_text (optional).", - "example": [], - }, - "properties": { - "type": "object", - "description": "Property updates (rename / change type / remove with null) (optional).", - "example": {}, - }, - "is_inline": { - "type": "boolean", - "description": "Set inline (optional).", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "description": "{id, url} of the updated database.", - }, - }, - parallelizable=False, -) -def update_notion_database(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "notion", - "update_database", - database_id=input_data["database_id"], - title=input_data.get("title"), - description=input_data.get("description"), - properties=input_data.get("properties"), - is_inline=input_data["is_inline"] if "is_inline" in input_data else None, - ) - return pick_result(res, ["id", "url"]) - - -@action( - name="archive_notion_database", - description="Archive a Notion database.", - action_sets=["notion_databases"], - input_schema={ - "database_id": {"type": "string", "description": "Database ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="restore_notion_database", - description="Restore an archived Notion database.", - action_sets=["notion_databases"], - input_schema={ - "database_id": {"type": "string", "description": "Database ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -# ------------------------------------------------------------------ -# Blocks -# ------------------------------------------------------------------ - - -@action( - name="get_notion_page_content", - description=( - "Get the content blocks of a Notion page (or any block that has children). " - "By default returns SIMPLIFIED content (each block's type + plain text) to keep the " - "output small and readable. Set include_metadata=true to get the FULL raw blocks " - "including block IDs, timestamps and other metadata — do this when you need block IDs " - "to update or delete specific blocks." - ), - action_sets=["notion_blocks", "notion"], - input_schema={ - "page_id": { - "type": "string", - "description": "Page ID (or block ID for nested children).", - "example": "abc123", - }, - "include_metadata": { - "type": "boolean", - "description": ( - "False (default): return only {type, text} per block — lean, for reading. " - "True: return the full raw blocks with block IDs/timestamps/etc. — needed to " - "edit or delete specific blocks." - ), - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "content": { - "type": "array", - "description": "Simplified blocks [{type, text, ...}] when include_metadata is false; full raw blocks when true.", - }, - }, -) -def get_notion_page_content(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - include_metadata = bool(input_data.get("include_metadata", False)) - result = run_client_sync( - "notion", "get_block_children", block_id=input_data["page_id"] - ) - if include_metadata or result.get("status") == "error": - return result - - raw = result.get("result", {}) - blocks = raw.get("results", []) if isinstance(raw, dict) else [] - - def _simplify(b: dict) -> dict: - t = b.get("type") - data = b.get(t) if isinstance(b.get(t), dict) else {} - text = "".join( - rt.get("plain_text", "") - for rt in data.get("rich_text", []) - if isinstance(rt, dict) - ) - out = {"type": t, "text": text} - if t == "to_do": - out["checked"] = bool(data.get("checked")) - if b.get("has_children"): - out["has_children"] = True - return out - - content = [_simplify(b) for b in blocks if isinstance(b, dict)] - out = {"status": "success", "content": content} - if isinstance(raw, dict) and raw.get("has_more"): - out["has_more"] = True - out["next_cursor"] = raw.get("next_cursor") - return out - - -@action( - name="append_notion_page_content", - description="Append content blocks to a Notion page (or any block). Returns {appended: count, ids: [block ids]}.", - action_sets=["notion_blocks", "notion"], - input_schema={ - "page_id": { - "type": "string", - "description": "Page ID (or block ID).", - "example": "abc123", - }, - "children": { - "type": "array", - "description": "List of block objects.", - "example": [], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "{appended, ids}."}, - }, - parallelizable=False, -) -def append_notion_page_content(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "notion", - "append_block_children", - block_id=input_data["page_id"], - children=input_data["children"], - ) - if res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict) or not isinstance(body.get("results"), list): - return res - ids = [b.get("id") for b in body["results"] if isinstance(b, dict)] - return {**res, "result": {"appended": len(ids), "ids": ids}} - - -@action( - name="get_notion_block", - description="Get a single block (not its children) by block ID.", - action_sets=["notion_blocks", "notion"], - input_schema={ - "block_id": {"type": "string", "description": "Block ID.", "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"]) - - -@action( - name="update_notion_block", - description="Update a block's content. block_update has the per-block-type key as the top-level field, e.g. {'to_do': {'rich_text': [...], 'checked': true}} for a to-do, {'paragraph': {'rich_text': [...]}} for a paragraph. Pass {'in_trash': true} to soft-delete.", - action_sets=["notion_blocks", "notion"], - input_schema={ - "block_id": {"type": "string", "description": "Block ID.", "example": ""}, - "block_update": { - "type": "object", - "description": "Per-block-type update object.", - "example": {"paragraph": {"rich_text": [{"text": {"content": "Updated"}}]}}, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": {"type": "object", "description": "{id} of the updated block."}, - }, - parallelizable=False, -) -def update_notion_block(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "notion", - "update_block", - block_id=input_data["block_id"], - block_update=input_data["block_update"], - ) - return pick_result(res, ["id"]) - - -@action( - name="delete_notion_block", - description="Delete (soft delete, send to trash) a Notion block.", - action_sets=["notion_blocks", "notion"], - input_schema={ - "block_id": {"type": "string", "description": "Block ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -# ------------------------------------------------------------------ -# Comments -# ------------------------------------------------------------------ - - -@action( - name="list_notion_comments", - description="List comments on a page or block.", - action_sets=["notion_comments", "notion"], - input_schema={ - "block_id": { - "type": "string", - "description": "Block or page ID.", - "example": "", - }, - "page_size": {"type": "integer", "description": "Max results.", "example": 100}, - "start_cursor": { - "type": "string", - "description": "Pagination cursor (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_notion_comments(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "list_comments", - block_id=input_data["block_id"], - page_size=input_data.get("page_size", 100), - start_cursor=input_data.get("start_cursor") or None, - ) - - -@action( - name="create_notion_comment", - description="Post a comment on a page/block, or reply in a discussion. Provide exactly one of parent_page_id, parent_block_id, or discussion_id.", - action_sets=["notion_comments", "notion"], - input_schema={ - "rich_text": { - "type": "array", - "description": "Comment content as rich_text array.", - "example": [{"text": {"content": "Looks good!"}}], - }, - "parent_page_id": { - "type": "string", - "description": "Page ID for a new top-level discussion (optional).", - "example": "", - }, - "parent_block_id": { - "type": "string", - "description": "Block ID for a new top-level discussion (optional).", - "example": "", - }, - "discussion_id": { - "type": "string", - "description": "Discussion ID to reply to (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_notion_comment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "create_comment", - 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, - discussion_id=input_data.get("discussion_id") or None, - ) - - -# ------------------------------------------------------------------ -# Users -# ------------------------------------------------------------------ - - -@action( - name="list_notion_users", - description="List workspace members visible to the integration.", - action_sets=["notion_users", "notion"], - input_schema={ - "page_size": {"type": "integer", "description": "Max results.", "example": 100}, - "start_cursor": { - "type": "string", - "description": "Pagination cursor (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_notion_users(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "list_users", - page_size=input_data.get("page_size", 100), - start_cursor=input_data.get("start_cursor") or None, - ) - - -@action( - name="get_notion_user", - description="Get a single Notion user by ID.", - action_sets=["notion_users", "notion"], - input_schema={ - "user_id": {"type": "string", "description": "User ID.", "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"]) - - -@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={}, - 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") - - -# ------------------------------------------------------------------ -# File uploads -# ------------------------------------------------------------------ - - -@action( - name="upload_notion_file", - description="High-level: upload a local file in one call (single-part). Returns the file_upload object with id+status='uploaded'. Attach to a block via {'type':'file_upload','file_upload':{'id': }}. Use multi-part flow for files >20 MB.", - action_sets=["notion_files", "notion"], - input_schema={ - "file_path": { - "type": "string", - "description": "Absolute path to local file.", - "example": "C:/Users/me/report.pdf", - }, - "content_type": { - "type": "string", - "description": "MIME type (autodetect if omitted).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def upload_notion_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "upload_local_file", - file_path=input_data["file_path"], - content_type=input_data.get("content_type") or None, - ) - - -@action( - name="create_notion_file_upload", - description="Step 1 of file upload: initialise a file_upload resource. Returns id + upload_url. Use mode=single_part for <20 MB, multi_part for larger, or external_url to import from a URL.", - action_sets=["notion_files"], - input_schema={ - "mode": { - "type": "string", - "description": "single_part | multi_part | external_url.", - "example": "single_part", - }, - "filename": { - "type": "string", - "description": "Required for multi_part.", - "example": "", - }, - "content_type": { - "type": "string", - "description": "MIME type (recommended).", - "example": "", - }, - "number_of_parts": { - "type": "integer", - "description": "Required for multi_part.", - "example": 0, - }, - "external_url": { - "type": "string", - "description": "Required for external_url mode.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_notion_file_upload(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - parts = input_data.get("number_of_parts") - return run_client_sync( - "notion", - "create_file_upload", - mode=input_data.get("mode", "single_part"), - filename=input_data.get("filename") or None, - content_type=input_data.get("content_type") or None, - number_of_parts=parts if parts else None, - external_url=input_data.get("external_url") or None, - ) - - -@action( - name="send_notion_file_upload", - description="Step 2: send file bytes to a pending file_upload. For multi_part uploads, repeat with each part_number.", - action_sets=["notion_files"], - input_schema={ - "file_upload_id": { - "type": "string", - "description": "ID from create_notion_file_upload.", - "example": "", - }, - "file_path": { - "type": "string", - "description": "Absolute path to local file (or one part for multi_part).", - "example": "", - }, - "part_number": { - "type": "integer", - "description": "1..1000, only for multi_part.", - "example": 0, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_notion_file_upload(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - pn = input_data.get("part_number") - return run_client_sync( - "notion", - "send_file_upload", - file_upload_id=input_data["file_upload_id"], - file_path=input_data["file_path"], - part_number=pn if pn else None, - ) - - -@action( - name="complete_notion_file_upload", - description="Step 3 (multi_part only): finalize a multi-part upload after all parts sent.", - action_sets=["notion_files"], - input_schema={ - "file_upload_id": { - "type": "string", - "description": "File upload ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def complete_notion_file_upload(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "complete_file_upload", - file_upload_id=input_data["file_upload_id"], - ) - - -@action( - name="get_notion_file_upload", - description="Get the current status of a file upload.", - action_sets=["notion_files"], - input_schema={ - "file_upload_id": { - "type": "string", - "description": "File upload ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_notion_file_upload(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "get_file_upload", - file_upload_id=input_data["file_upload_id"], - ) - - -@action( - name="list_notion_file_uploads", - description="List file uploads created by this integration. Filter by status (pending|uploaded|expired|failed).", - action_sets=["notion_files"], - input_schema={ - "status": { - "type": "string", - "description": "Filter (optional).", - "example": "", - }, - "page_size": {"type": "integer", "description": "Max results.", "example": 100}, - "start_cursor": { - "type": "string", - "description": "Pagination cursor (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_notion_file_uploads(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "notion", - "list_file_uploads", - status=input_data.get("status") or None, - page_size=input_data.get("page_size", 100), - start_cursor=input_data.get("start_cursor") or None, - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - Data sources (multi-source databases) sub-resource -# Newer feature; the standard property-on-database surface covers the -# common single-source case. Add when an agent task actually needs it. -# - OAuth invite / token refresh endpoints -# Handled by the integration handler (/notion invite/login), not as -# per-task actions. -# - Direct upload_url PUT (signed S3 URL approach) -# The send_file_upload helper covers the realistic case; signed-URL -# PUT is reserved for very large multi-part flows. -# - Workspace settings / sharing / page permissions -# Notion does not expose these via REST; they're UI-only. diff --git a/app/data/action/integrations/outlook/outlook_actions.py b/app/data/action/integrations/outlook/outlook_actions.py deleted file mode 100644 index 6f6090fd..00000000 --- a/app/data/action/integrations/outlook/outlook_actions.py +++ /dev/null @@ -1,1325 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# Mail — read / send / reply / forward / draft / lifecycle -# ------------------------------------------------------------------ - - -@action( - name="send_outlook_email", - irreversible=True, - description="Send an email via Outlook (Microsoft 365).", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "to": { - "type": "string", - "description": "Recipient email address.", - "example": "user@example.com", - }, - "subject": { - "type": "string", - "description": "Email subject.", - "example": "Meeting Follow-up", - }, - "body": { - "type": "string", - "description": "Email body text.", - "example": "Hi, here are the notes...", - }, - "cc": { - "type": "string", - "description": "Optional CC recipients (comma-separated).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "send_email", - unwrap_envelope=True, - success_message="Email sent.", - fail_message="Failed to send email.", - to=input_data["to"], - subject=input_data["subject"], - body=input_data["body"], - cc=input_data.get("cc"), - ) - - -@action( - name="list_outlook_emails", - description="List recent emails from Outlook inbox.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "count": { - "type": "integer", - "description": "Number of recent emails to list.", - "example": 10, - }, - "unread_only": { - "type": "boolean", - "description": "Only show unread emails.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_emails", - unwrap_envelope=True, - fail_message="Failed to list emails.", - n=input_data.get("count", 10), - unread_only=input_data.get("unread_only", False), - ) - - -@action( - name="get_outlook_email", - description="Get full details of a specific Outlook email by message ID. Body is plain text by default; set include_metadata for the HTML body.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": { - "type": "string", - "description": "Outlook message ID.", - "example": "AAMk...", - }, - "include_metadata": { - "type": "boolean", - "description": "Return the HTML body instead of plain text (default false).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "get_email", - unwrap_envelope=True, - fail_message="Failed to get email.", - message_id=input_data["message_id"], - include_metadata=bool(input_data.get("include_metadata", False)), - ) - - -@action( - name="read_top_outlook_emails", - description="Read the top N recent Outlook emails with details. With full_body=true, bodies are plain text by default; set include_metadata for HTML bodies.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "count": { - "type": "integer", - "description": "Number of emails to read.", - "example": 5, - }, - "full_body": { - "type": "boolean", - "description": "Include full body text.", - "example": False, - }, - "include_metadata": { - "type": "boolean", - "description": "With full_body, return HTML bodies instead of plain text (default false).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def read_top_outlook_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "read_top_emails", - unwrap_envelope=True, - fail_message="Failed to read emails.", - n=input_data.get("count", 5), - full_body=input_data.get("full_body", False), - include_metadata=bool(input_data.get("include_metadata", False)), - ) - - -@action( - name="search_outlook_emails", - description="Search Outlook messages by free-text query (matches subject, body, attachments). Sorted by relevance.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "query": { - "type": "string", - "description": "Search text.", - "example": "invoice contoso", - }, - "top": {"type": "integer", "description": "Max results.", "example": 25}, - "folder": { - "type": "string", - "description": "Optional folder name (inbox/sentitems/etc.) or ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_outlook_emails(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "search_messages", - unwrap_envelope=True, - fail_message="Failed to search.", - query=input_data["query"], - top=input_data.get("top", 25), - folder=input_data.get("folder") or None, - ) - - -@action( - name="reply_outlook_email", - irreversible=True, - description="Reply to the sender of an email. Sent immediately.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "AAMk...", - }, - "comment": { - "type": "string", - "description": "Reply body (plain text).", - "example": "Thanks, sounds good.", - }, - "to_recipients": { - "type": "string", - "description": "Optional comma-separated extra recipients.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def reply_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - to = ( - csv_list(input_data.get("to_recipients", ""), default=None) - if input_data.get("to_recipients") - else None - ) - return run_client_sync( - "outlook", - "reply_to_message", - unwrap_envelope=True, - fail_message="Failed to reply.", - message_id=input_data["message_id"], - comment=input_data["comment"], - to_recipients=to, - ) - - -@action( - name="reply_all_outlook_email", - irreversible=True, - description="Reply-all to an email. Sent immediately.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "AAMk...", - }, - "comment": {"type": "string", "description": "Reply body.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def reply_all_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "reply_all_to_message", - unwrap_envelope=True, - fail_message="Failed to reply-all.", - message_id=input_data["message_id"], - comment=input_data["comment"], - ) - - -@action( - name="forward_outlook_email", - irreversible=True, - description="Forward an email to other recipients.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": { - "type": "string", - "description": "Message ID.", - "example": "AAMk...", - }, - "to_recipients": { - "type": "string", - "description": "Comma-separated recipient emails.", - "example": "bob@example.com", - }, - "comment": { - "type": "string", - "description": "Optional intro comment.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def forward_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - to = csv_list(input_data["to_recipients"]) - if not to: - return {"status": "error", "message": "No recipients provided."} - return run_client_sync( - "outlook", - "forward_message", - unwrap_envelope=True, - fail_message="Failed to forward.", - message_id=input_data["message_id"], - to_recipients=to, - comment=input_data.get("comment", ""), - ) - - -@action( - name="create_outlook_reply_draft", - description="Create a draft reply (pre-populated with quoted original). Edit with update_outlook_draft, then send with send_outlook_draft.", - action_sets=["outlook_mail"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "AAMk...", - }, - "comment": { - "type": "string", - "description": "Optional initial reply text.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_reply_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "create_reply_draft", - unwrap_envelope=True, - fail_message="Failed to create reply draft.", - message_id=input_data["message_id"], - comment=input_data.get("comment", ""), - ) - - -@action( - name="create_outlook_forward_draft", - description="Create a draft forward (pre-populated with quoted original). Edit and send later.", - action_sets=["outlook_mail"], - input_schema={ - "message_id": { - "type": "string", - "description": "Original message ID.", - "example": "AAMk...", - }, - "to_recipients": { - "type": "string", - "description": "Comma-separated recipient emails.", - "example": "", - }, - "comment": {"type": "string", "description": "Optional intro.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_forward_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - to = csv_list(input_data.get("to_recipients", "")) - return run_client_sync( - "outlook", - "create_forward_draft", - unwrap_envelope=True, - fail_message="Failed to create forward draft.", - message_id=input_data["message_id"], - to_recipients=to, - comment=input_data.get("comment", ""), - ) - - -@action( - name="create_outlook_draft", - description="Create a new email draft (not sent). Returns the draft_id for later editing/sending.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "subject": { - "type": "string", - "description": "Subject.", - "example": "Quick question", - }, - "body": {"type": "string", "description": "Body.", "example": ""}, - "to": { - "type": "string", - "description": "Comma-separated recipients (optional).", - "example": "", - }, - "cc": { - "type": "string", - "description": "Comma-separated CC (optional).", - "example": "", - }, - "bcc": { - "type": "string", - "description": "Comma-separated BCC (optional).", - "example": "", - }, - "html": {"type": "boolean", "description": "Body is HTML.", "example": False}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - return run_client_sync( - "outlook", - "create_draft", - unwrap_envelope=True, - fail_message="Failed to create draft.", - subject=input_data["subject"], - body=input_data["body"], - to=csv_list(input_data.get("to", ""), default=None), - cc=csv_list(input_data.get("cc", ""), default=None), - bcc=csv_list(input_data.get("bcc", ""), default=None), - html=bool(input_data.get("html", False)), - ) - - -@action( - name="update_outlook_draft", - description="Edit a draft's subject/body/recipients before sending.", - action_sets=["outlook_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Draft ID.", "example": ""}, - "subject": { - "type": "string", - "description": "New subject (optional).", - "example": "", - }, - "body": { - "type": "string", - "description": "New body (optional).", - "example": "", - }, - "html": {"type": "boolean", "description": "Body is HTML.", "example": False}, - "to": { - "type": "string", - "description": "New comma-separated recipients (optional, replaces).", - "example": "", - }, - "cc": {"type": "string", "description": "New CC (optional).", "example": ""}, - "bcc": {"type": "string", "description": "New BCC (optional).", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_outlook_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - return run_client_sync( - "outlook", - "update_draft", - unwrap_envelope=True, - fail_message="Failed to update draft.", - message_id=input_data["message_id"], - subject=input_data.get("subject") if "subject" in input_data else None, - body=input_data.get("body") if "body" in input_data else None, - html=bool(input_data.get("html", False)), - to=csv_list(input_data["to"], default=None) if "to" in input_data else None, - cc=csv_list(input_data["cc"], default=None) if "cc" in input_data else None, - bcc=csv_list(input_data["bcc"], default=None) if "bcc" in input_data else None, - ) - - -@action( - name="send_outlook_draft", - irreversible=True, - description="Send a previously-created draft.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Draft ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def send_outlook_draft(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "send_draft", - unwrap_envelope=True, - fail_message="Failed to send draft.", - message_id=input_data["message_id"], - ) - - -@action( - name="delete_outlook_email", - description="Permanently delete a message. Use move_outlook_email to deleteditems for a soft delete.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "delete_message", - unwrap_envelope=True, - fail_message="Failed to delete.", - message_id=input_data["message_id"], - ) - - -@action( - name="move_outlook_email", - description="Move a message to another folder. destination_folder_id can be a well-known name (inbox, drafts, sentitems, deleteditems, archive, junkemail) or a custom folder ID.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "destination_folder_id": { - "type": "string", - "description": "Folder ID or well-known name.", - "example": "archive", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def move_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "move_message", - unwrap_envelope=True, - fail_message="Failed to move.", - message_id=input_data["message_id"], - destination_folder_id=input_data["destination_folder_id"], - ) - - -@action( - name="copy_outlook_email", - description="Copy a message to another folder (original stays).", - action_sets=["outlook_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "destination_folder_id": { - "type": "string", - "description": "Folder ID or well-known name.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def copy_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "copy_message", - unwrap_envelope=True, - fail_message="Failed to copy.", - message_id=input_data["message_id"], - destination_folder_id=input_data["destination_folder_id"], - ) - - -@action( - name="mark_outlook_email_read", - description="Mark an Outlook email as read.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": { - "type": "string", - "description": "Outlook message ID.", - "example": "AAMk...", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def mark_outlook_email_read(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "mark_as_read", - unwrap_envelope=True, - success_message="Email marked as read.", - fail_message="Failed to mark email.", - message_id=input_data["message_id"], - ) - - -@action( - name="mark_outlook_email_unread", - description="Mark an Outlook email as unread.", - action_sets=["outlook_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def mark_outlook_email_unread(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "mark_as_unread", - unwrap_envelope=True, - fail_message="Failed to mark unread.", - message_id=input_data["message_id"], - ) - - -@action( - name="flag_outlook_email", - description="Set the flag status on an email. flag_status: notFlagged | flagged | complete.", - action_sets=["outlook_mail", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "flag_status": { - "type": "string", - "description": "notFlagged, flagged, or complete.", - "example": "flagged", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def flag_outlook_email(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "flag_message", - unwrap_envelope=True, - fail_message="Failed to flag.", - message_id=input_data["message_id"], - flag_status=input_data.get("flag_status", "flagged"), - ) - - -@action( - name="set_outlook_email_categories", - description="Replace the categories on an Outlook message (use list_outlook_categories to see available ones).", - action_sets=["outlook_mail"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "categories": { - "type": "string", - "description": "Comma-separated category display names.", - "example": "Personal,Important", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def set_outlook_email_categories(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - from app.utils.text import csv_list - - categories = csv_list(input_data.get("categories", "")) - return run_client_sync( - "outlook", - "set_message_categories", - unwrap_envelope=True, - fail_message="Failed to set categories.", - message_id=input_data["message_id"], - categories=categories, - ) - - -# ------------------------------------------------------------------ -# Attachments -# ------------------------------------------------------------------ - - -@action( - name="list_outlook_attachments", - description="List attachments on an Outlook message.", - action_sets=["outlook_attachments", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_attachments(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_attachments", - unwrap_envelope=True, - fail_message="Failed to list attachments.", - message_id=input_data["message_id"], - ) - - -@action( - name="download_outlook_attachment", - description="Download an attachment to a local path. Only works for fileAttachment type.", - action_sets=["outlook_attachments", "outlook"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "attachment_id": { - "type": "string", - "description": "Attachment ID.", - "example": "", - }, - "save_to": { - "type": "string", - "description": "Local path to save to.", - "example": "C:/Users/me/downloads/file.pdf", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def download_outlook_attachment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "download_attachment", - unwrap_envelope=True, - fail_message="Failed to download.", - message_id=input_data["message_id"], - attachment_id=input_data["attachment_id"], - save_to=input_data["save_to"], - ) - - -@action( - name="add_outlook_attachment", - description="Attach a local file to a DRAFT message (under 3 MB).", - action_sets=["outlook_attachments"], - input_schema={ - "message_id": { - "type": "string", - "description": "Draft message ID.", - "example": "", - }, - "file_path": { - "type": "string", - "description": "Absolute path to the local file.", - "example": "", - }, - "content_type": { - "type": "string", - "description": "MIME type (autodetect if omitted).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_outlook_attachment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "add_attachment", - unwrap_envelope=True, - fail_message="Failed to add attachment.", - message_id=input_data["message_id"], - file_path=input_data["file_path"], - content_type=input_data.get("content_type") or None, - ) - - -@action( - name="delete_outlook_attachment", - description="Remove an attachment from a draft.", - action_sets=["outlook_attachments"], - input_schema={ - "message_id": {"type": "string", "description": "Message ID.", "example": ""}, - "attachment_id": { - "type": "string", - "description": "Attachment ID.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_outlook_attachment(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "delete_attachment", - unwrap_envelope=True, - fail_message="Failed to delete attachment.", - message_id=input_data["message_id"], - attachment_id=input_data["attachment_id"], - ) - - -# ------------------------------------------------------------------ -# Folders -# ------------------------------------------------------------------ - - -@action( - name="list_outlook_folders", - description="List mail folders in Outlook.", - action_sets=["outlook_folders", "outlook"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_folders(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_folders", - unwrap_envelope=True, - fail_message="Failed to list folders.", - ) - - -@action( - name="get_outlook_folder", - description="Get metadata for a single mail folder (counts, parent).", - action_sets=["outlook_folders"], - input_schema={ - "folder_id": { - "type": "string", - "description": "Folder ID or well-known name (inbox, drafts, sentitems, etc.).", - "example": "inbox", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_outlook_folder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "get_folder", - unwrap_envelope=True, - fail_message="Failed to get folder.", - folder_id=input_data["folder_id"], - ) - - -@action( - name="create_outlook_folder", - description="Create a new mail folder. Defaults to top-level (under msgfolderroot).", - action_sets=["outlook_folders", "outlook"], - input_schema={ - "display_name": { - "type": "string", - "description": "Folder name.", - "example": "Receipts", - }, - "parent_folder_id": { - "type": "string", - "description": "Parent folder ID or well-known name. Default msgfolderroot.", - "example": "msgfolderroot", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_folder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "create_folder", - unwrap_envelope=True, - fail_message="Failed to create folder.", - display_name=input_data["display_name"], - parent_folder_id=input_data.get("parent_folder_id", "msgfolderroot"), - ) - - -@action( - name="update_outlook_folder", - description="Rename a mail folder.", - action_sets=["outlook_folders"], - input_schema={ - "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, - "display_name": {"type": "string", "description": "New name.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_outlook_folder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "update_folder", - unwrap_envelope=True, - fail_message="Failed to rename folder.", - folder_id=input_data["folder_id"], - display_name=input_data["display_name"], - ) - - -@action( - name="delete_outlook_folder", - description="Delete a mail folder (and all messages in it). Cannot delete well-known folders.", - action_sets=["outlook_folders"], - input_schema={ - "folder_id": {"type": "string", "description": "Folder ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_outlook_folder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "delete_folder", - unwrap_envelope=True, - fail_message="Failed to delete folder.", - folder_id=input_data["folder_id"], - ) - - -@action( - name="list_outlook_child_folders", - description="List child folders of a mail folder.", - action_sets=["outlook_folders"], - input_schema={ - "folder_id": { - "type": "string", - "description": "Parent folder ID or well-known name. Default msgfolderroot.", - "example": "msgfolderroot", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_child_folders(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_child_folders", - unwrap_envelope=True, - fail_message="Failed to list child folders.", - folder_id=input_data.get("folder_id", "msgfolderroot"), - ) - - -@action( - name="list_outlook_folder_messages", - description="List messages in a specific folder.", - action_sets=["outlook_folders", "outlook"], - input_schema={ - "folder_id": { - "type": "string", - "description": "Folder ID or well-known name.", - "example": "inbox", - }, - "count": {"type": "integer", "description": "Max results.", "example": 25}, - "unread_only": { - "type": "boolean", - "description": "Filter to unread.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_folder_messages(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_folder_messages", - unwrap_envelope=True, - fail_message="Failed to list messages.", - folder_id=input_data["folder_id"], - n=input_data.get("count", 25), - unread_only=bool(input_data.get("unread_only", False)), - ) - - -# ------------------------------------------------------------------ -# Mailbox settings + auto-replies + rules + categories -# ------------------------------------------------------------------ - - -@action( - name="get_outlook_mailbox_settings", - description="Get the user's mailbox settings. Default returns {timeZone, language, workingHours, automaticRepliesSetting.status}; set include_metadata for the raw settings.", - action_sets=["outlook_settings"], - input_schema={ - "include_metadata": { - "type": "boolean", - "description": "Return the raw mailboxSettings resource (default false = lean).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_outlook_mailbox_settings(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "outlook", - "get_mailbox_settings", - unwrap_envelope=True, - fail_message="Failed to get settings.", - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - settings = res.get("result") - if isinstance(settings, dict): - lean = {"timeZone": settings.get("timeZone")} - language = settings.get("language") or {} - if language.get("displayName"): - lean["language"] = {"displayName": language["displayName"]} - wh = settings.get("workingHours") or {} - if wh: - lean["workingHours"] = { - k: wh.get(k) - for k in ("daysOfWeek", "startTime", "endTime") - if wh.get(k) is not None - } - ars = settings.get("automaticRepliesSetting") or {} - if ars.get("status"): - lean["automaticRepliesSetting"] = {"status": ars["status"]} - res = {**res, "result": lean} - return res - - -@action( - name="get_outlook_automatic_replies", - description="Get the current out-of-office / automatic reply settings. Default returns {status, schedule, reply messages as plain text}; set include_metadata for the raw setting.", - action_sets=["outlook_settings", "outlook"], - input_schema={ - "include_metadata": { - "type": "boolean", - "description": "Return the raw automaticRepliesSetting (default false = lean, HTML stripped).", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_outlook_automatic_replies(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "outlook", - "get_automatic_replies", - unwrap_envelope=True, - fail_message="Failed to get auto-replies.", - ) - if not input_data.get("include_metadata") and res.get("status") == "success": - setting = res.get("result") - if isinstance(setting, dict): - import html - import re - - def _strip_html(value): - if not isinstance(value, str): - return value - return html.unescape(re.sub(r"<[^>]+>", "", value)).strip() - - res = { - **res, - "result": { - k: v - for k, v in { - "status": setting.get("status"), - "scheduledStartDateTime": setting.get("scheduledStartDateTime"), - "scheduledEndDateTime": setting.get("scheduledEndDateTime"), - "internalReplyMessage": _strip_html( - setting.get("internalReplyMessage") - ), - "externalReplyMessage": _strip_html( - setting.get("externalReplyMessage") - ), - }.items() - if v is not None - }, - } - return res - - -@action( - name="update_outlook_automatic_replies", - description="Set out-of-office reply. status: disabled | alwaysEnabled | scheduled. external_audience: none | contactsOnly | all.", - action_sets=["outlook_settings", "outlook"], - input_schema={ - "status": { - "type": "string", - "description": "disabled, alwaysEnabled, or scheduled.", - "example": "alwaysEnabled", - }, - "internal_reply": { - "type": "string", - "description": "Reply text shown to internal senders (optional).", - "example": "Out of office until Friday.", - }, - "external_reply": { - "type": "string", - "description": "Reply text shown to external senders (optional).", - "example": "", - }, - "external_audience": { - "type": "string", - "description": "none, contactsOnly, or all.", - "example": "all", - }, - "scheduled_start": { - "type": "string", - "description": "ISO 8601 start (only for status=scheduled).", - "example": "", - }, - "scheduled_end": { - "type": "string", - "description": "ISO 8601 end (only for status=scheduled).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_outlook_automatic_replies(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "update_automatic_replies", - unwrap_envelope=True, - fail_message="Failed to set auto-replies.", - status=input_data["status"], - internal_reply=input_data.get("internal_reply") - if "internal_reply" in input_data - else None, - external_reply=input_data.get("external_reply") - if "external_reply" in input_data - else None, - external_audience=input_data.get("external_audience", "all"), - scheduled_start=input_data.get("scheduled_start") or None, - scheduled_end=input_data.get("scheduled_end") or None, - ) - - -@action( - name="list_outlook_inbox_rules", - description="List inbox rules (server-side mail rules).", - action_sets=["outlook_settings"], - input_schema={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_inbox_rules(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_inbox_rules", - unwrap_envelope=True, - fail_message="Failed to list rules.", - ) - - -@action( - name="create_outlook_inbox_rule", - description="Create an inbox rule. conditions and actions are Graph rule objects — e.g. conditions={'fromAddresses': [{'emailAddress': {'address': 'x@y.com'}}]}, actions={'moveToFolder': ''}.", - action_sets=["outlook_settings"], - input_schema={ - "display_name": { - "type": "string", - "description": "Rule name.", - "example": "From boss to Important", - }, - "conditions": { - "type": "object", - "description": "Graph messageRulePredicates object.", - "example": {}, - }, - "actions": { - "type": "object", - "description": "Graph messageRuleActions object.", - "example": {}, - }, - "sequence": { - "type": "integer", - "description": "Run order (lower runs first).", - "example": 1, - }, - "is_enabled": { - "type": "boolean", - "description": "Enable on create.", - "example": True, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_inbox_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "create_inbox_rule", - unwrap_envelope=True, - fail_message="Failed to create rule.", - display_name=input_data["display_name"], - conditions=input_data["conditions"], - actions=input_data["actions"], - sequence=input_data.get("sequence", 1), - is_enabled=bool(input_data.get("is_enabled", True)), - ) - - -@action( - name="delete_outlook_inbox_rule", - description="Delete an inbox rule.", - action_sets=["outlook_settings"], - input_schema={ - "rule_id": {"type": "string", "description": "Rule ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_outlook_inbox_rule(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "delete_inbox_rule", - unwrap_envelope=True, - fail_message="Failed to delete rule.", - rule_id=input_data["rule_id"], - ) - - -@action( - 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={}, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_outlook_categories(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "list_categories", - unwrap_envelope=True, - fail_message="Failed to list categories.", - ) - - -@action( - name="create_outlook_category", - description="Create a master category. color: preset0..preset24 from Graph categoryColor enum.", - action_sets=["outlook_settings"], - input_schema={ - "display_name": { - "type": "string", - "description": "Category name.", - "example": "Personal", - }, - "color": { - "type": "string", - "description": "preset0..preset24.", - "example": "preset0", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_outlook_category(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "create_category", - unwrap_envelope=True, - fail_message="Failed to create category.", - display_name=input_data["display_name"], - color=input_data.get("color", "preset0"), - ) - - -@action( - name="delete_outlook_category", - description="Delete a master category.", - action_sets=["outlook_settings"], - input_schema={ - "category_id": {"type": "string", "description": "Category ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_outlook_category(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "outlook", - "delete_category", - unwrap_envelope=True, - fail_message="Failed to delete category.", - category_id=input_data["category_id"], - ) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - Subscriptions / webhooks (subscribe to mailbox changes) -# Server-side push notification setup; not interactive. -# - Large attachment upload sessions (>3 MB via uploadSession) -# The simple add_attachment covers the realistic agent use case (<3 MB). -# - Schema extensions and open extensions -# Custom property storage on resources; niche developer tooling. -# - Find meeting times / get schedule -# Calendar surface — would belong to a separate outlook_calendar action set, -# not this mail-focused expansion. -# - Delta queries (incremental sync via $deltaToken) -# Synchronization plumbing, not per-action work. -# - Permissions delegation (sharedMailbox, sendOnBehalf) -# Admin / multi-user concerns. diff --git a/app/data/action/integrations/slack/slack_actions.py b/app/data/action/integrations/slack/slack_actions.py deleted file mode 100644 index 15ef97e1..00000000 --- a/app/data/action/integrations/slack/slack_actions.py +++ /dev/null @@ -1,1826 +0,0 @@ -from agent_core import action - - -# ------------------------------------------------------------------ -# Messages — post / update / delete / ephemeral / schedule / permalink / threads -# ------------------------------------------------------------------ - - -@action( - name="send_slack_message", - irreversible=True, - description="Send a message to a Slack channel or DM. Pass thread_ts to reply in a thread.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID or name.", - "example": "C01234567", - }, - "text": { - "type": "string", - "description": "Message text.", - "example": "Hello team!", - }, - "thread_ts": { - "type": "string", - "description": "Optional thread timestamp for replies.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "description": "{channel, ts} of the posted message.", - }, - }, - parallelizable=False, -) -async def send_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client - - res = await run_client( - "slack", - "send_message", - recipient=input_data["channel"], - text=input_data["text"], - thread_ts=input_data.get("thread_ts"), - ) - return pick_result(res, ["channel", "ts"]) - - -@action( - name="update_slack_message", - description="Edit a previously-sent Slack message. ts is the timestamp returned when posting.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "ts": { - "type": "string", - "description": "Timestamp of the message to edit.", - "example": "1234567890.123456", - }, - "text": { - "type": "string", - "description": "New text (optional).", - "example": "", - }, - "blocks": { - "type": "array", - "description": "New Block Kit blocks (optional).", - "example": [], - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "description": "{channel, ts} of the edited message.", - }, - }, - parallelizable=False, -) -def update_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "slack", - "update_message", - channel=input_data["channel"], - ts=input_data["ts"], - text=input_data["text"] if "text" in input_data else None, - blocks=input_data["blocks"] if "blocks" in input_data else None, - ) - return pick_result(res, ["channel", "ts"]) - - -@action( - name="delete_slack_message", - description="Delete a Slack message.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "ts": {"type": "string", "description": "Message timestamp.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "delete_message", - channel=input_data["channel"], - ts=input_data["ts"], - ) - - -@action( - name="send_slack_ephemeral", - irreversible=True, - description="Send an ephemeral message visible only to one user in a channel.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "user": { - "type": "string", - "description": "User ID who will see the message.", - "example": "U12345", - }, - "text": {"type": "string", "description": "Message text.", "example": ""}, - "blocks": { - "type": "array", - "description": "Block Kit blocks (optional).", - "example": [], - }, - "thread_ts": { - "type": "string", - "description": "Reply in a thread (optional).", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "description": "{message_ts} of the ephemeral message.", - }, - }, - parallelizable=False, -) -def send_slack_ephemeral(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "slack", - "post_ephemeral", - channel=input_data["channel"], - user=input_data["user"], - text=input_data["text"], - blocks=input_data["blocks"] if "blocks" in input_data else None, - thread_ts=input_data.get("thread_ts") or None, - ) - return pick_result(res, ["channel", "message_ts"]) - - -@action( - name="schedule_slack_message", - description="Schedule a Slack message to be sent at a future time. post_at is a Unix timestamp.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "post_at": { - "type": "integer", - "description": "Unix timestamp when to send.", - "example": 0, - }, - "text": {"type": "string", "description": "Message text.", "example": ""}, - "blocks": { - "type": "array", - "description": "Block Kit blocks (optional).", - "example": [], - }, - "thread_ts": { - "type": "string", - "description": "Optional thread reply.", - "example": "", - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "result": { - "type": "object", - "description": "{scheduled_message_id, channel, post_at}.", - }, - }, - parallelizable=False, -) -def schedule_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import pick_result, run_client_sync - - res = run_client_sync( - "slack", - "schedule_message", - channel=input_data["channel"], - post_at=input_data["post_at"], - text=input_data["text"], - blocks=input_data["blocks"] if "blocks" in input_data else None, - thread_ts=input_data.get("thread_ts") or None, - ) - return pick_result(res, ["scheduled_message_id", "channel", "post_at"]) - - -@action( - name="delete_scheduled_slack_message", - description="Cancel a previously-scheduled Slack message.", - action_sets=["slack_messages"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "scheduled_message_id": { - "type": "string", - "description": "Scheduled message ID (from schedule_slack_message response).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def delete_scheduled_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "delete_scheduled_message", - channel=input_data["channel"], - scheduled_message_id=input_data["scheduled_message_id"], - ) - - -@action( - name="list_scheduled_slack_messages", - description="List the bot's pending scheduled messages.", - action_sets=["slack_messages"], - input_schema={ - "channel": { - "type": "string", - "description": "Filter to one channel (optional).", - "example": "", - }, - "limit": {"type": "integer", "description": "Max results.", "example": 100}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_scheduled_slack_messages(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "list_scheduled_messages", - channel=input_data.get("channel") or None, - limit=input_data.get("limit", 100), - ) - - -@action( - name="get_slack_message_permalink", - description="Get a shareable permalink URL for a Slack message.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "message_ts": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_slack_message_permalink(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "get_permalink", - channel=input_data["channel"], - message_ts=input_data["message_ts"], - ) - - -@action( - name="get_slack_thread_replies", - description="Get all messages in a Slack thread (the parent + all replies). Lean messages (user, text, ts, thread_ts, reply_count, reactions) by default; include_metadata=true returns full raw messages (blocks, team, bot_profile, ...).", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "ts": { - "type": "string", - "description": "Parent message timestamp (thread_ts).", - "example": "", - }, - "limit": {"type": "integer", "description": "Max messages.", "example": 100}, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean messages. True: full raw.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_slack_thread_replies(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "slack", - "get_thread_replies", - channel=input_data["channel"], - ts=input_data["ts"], - limit=input_data.get("limit", 100), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _lean(m: dict) -> dict: - out = {"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")} - if m.get("thread_ts"): - out["thread_ts"] = m["thread_ts"] - if m.get("reply_count") is not None: - out["reply_count"] = m["reply_count"] - if m.get("subtype"): - out["subtype"] = m["subtype"] - if m.get("reactions"): - out["reactions"] = [ - {"name": r.get("name"), "count": r.get("count")} - for r in m["reactions"] - if isinstance(r, dict) - ] - return out - - lean = { - "messages": [ - _lean(m) for m in body.get("messages", []) or [] if isinstance(m, dict) - ] - } - if body.get("has_more"): - lean["has_more"] = True - return {**res, "result": lean} - - -# ----- Reactions ----- - - -@action( - name="add_slack_reaction", - description="Add an emoji reaction to a Slack message. name is the emoji code without colons (e.g. 'thumbsup', 'eyes').", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "timestamp": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - "name": { - "type": "string", - "description": "Emoji name without colons.", - "example": "thumbsup", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_slack_reaction(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "add_reaction", - channel=input_data["channel"], - timestamp=input_data["timestamp"], - name=input_data["name"], - ) - - -@action( - name="remove_slack_reaction", - description="Remove an emoji reaction from a Slack message.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "timestamp": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - "name": { - "type": "string", - "description": "Emoji name without colons.", - "example": "thumbsup", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def remove_slack_reaction(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "remove_reaction", - channel=input_data["channel"], - timestamp=input_data["timestamp"], - name=input_data["name"], - ) - - -@action( - name="get_slack_reactions", - description="Get all reactions on a Slack message.", - action_sets=["slack_messages"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "timestamp": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def get_slack_reactions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "get_reactions", - channel=input_data["channel"], - timestamp=input_data["timestamp"], - ) - - -@action( - name="list_slack_user_reactions", - description="List messages a user has reacted to.", - action_sets=["slack_messages"], - input_schema={ - "user": { - "type": "string", - "description": "User ID (optional, defaults to auth'd user).", - "example": "", - }, - "count": {"type": "integer", "description": "Max results.", "example": 100}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_slack_user_reactions(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "list_user_reactions", - user=input_data.get("user") or None, - count=input_data.get("count", 100), - ) - - -# ----- Pins ----- - - -@action( - name="pin_slack_message", - description="Pin a message to a Slack channel.", - action_sets=["slack_messages", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "timestamp": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def pin_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "pin_message", - channel=input_data["channel"], - timestamp=input_data["timestamp"], - ) - - -@action( - name="unpin_slack_message", - description="Unpin a message from a Slack channel.", - action_sets=["slack_messages"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "timestamp": { - "type": "string", - "description": "Message timestamp.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def unpin_slack_message(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "unpin_message", - channel=input_data["channel"], - timestamp=input_data["timestamp"], - ) - - -@action( - name="list_slack_pins", - description="List pinned items in a Slack channel.", - action_sets=["slack_messages"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "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"]) - - -# ------------------------------------------------------------------ -# Conversations — list/info/create/invite/open/archive/rename/topic/members -# ------------------------------------------------------------------ - - -@action( - name="list_slack_channels", - description="List channels in the Slack workspace. Lean channels (id, name, is_private, is_archived, is_member, num_members, topic, purpose) by default; include_metadata=true returns full raw channel objects.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max channels to return.", - "example": 100, - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean channels. True: full raw.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "channels": {"type": "array"}, - }, -) -def list_slack_channels(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync("slack", "list_channels", limit=input_data.get("limit", 100)) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _lean(c: dict) -> dict: - out = { - "id": c.get("id"), - "name": c.get("name"), - "is_private": c.get("is_private"), - "is_archived": c.get("is_archived"), - "num_members": c.get("num_members"), - "topic": (c.get("topic") or {}).get("value"), - "purpose": (c.get("purpose") or {}).get("value"), - } - if "is_member" in c: - out["is_member"] = c.get("is_member") - return out - - lean = { - "channels": [ - _lean(c) for c in body.get("channels", []) or [] if isinstance(c, dict) - ] - } - cursor = (body.get("response_metadata") or {}).get("next_cursor") - if cursor: - lean["next_cursor"] = cursor - return {**res, "result": lean} - - -@action( - name="get_slack_channel_info", - description="Get info about a Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C1234567", - }, - }, - 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"]) - - -@action( - name="get_slack_channel_history", - description="Get message history from a Slack channel. Lean messages (user, text, ts, thread_ts, reply_count, reactions) by default; include_metadata=true returns full raw messages (blocks, team, bot_profile, ...).", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C01234567", - }, - "limit": {"type": "integer", "description": "Max messages.", "example": 50}, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean messages. True: full raw.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "messages": {"type": "array"}, - }, -) -def get_slack_channel_history(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "slack", - "get_channel_history", - channel=input_data["channel"], - limit=input_data.get("limit", 50), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _lean(m: dict) -> dict: - out = {"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")} - if m.get("thread_ts"): - out["thread_ts"] = m["thread_ts"] - if m.get("reply_count") is not None: - out["reply_count"] = m["reply_count"] - if m.get("subtype"): - out["subtype"] = m["subtype"] - if m.get("reactions"): - out["reactions"] = [ - {"name": r.get("name"), "count": r.get("count")} - for r in m["reactions"] - if isinstance(r, dict) - ] - return out - - lean = { - "messages": [ - _lean(m) for m in body.get("messages", []) or [] if isinstance(m, dict) - ] - } - if body.get("has_more"): - lean["has_more"] = True - return {**res, "result": lean} - - -@action( - name="list_slack_channel_members", - description="List members of a Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "limit": {"type": "integer", "description": "Max members.", "example": 100}, - "cursor": { - "type": "string", - "description": "Pagination cursor.", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_slack_channel_members(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "list_channel_members", - channel=input_data["channel"], - limit=input_data.get("limit", 100), - cursor=input_data.get("cursor") or None, - ) - - -@action( - name="create_slack_channel", - description="Create a new Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "name": { - "type": "string", - "description": "Channel name.", - "example": "project-alpha", - }, - "is_private": { - "type": "boolean", - "description": "Is private?", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_slack_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "create_channel", - name=input_data["name"], - is_private=input_data.get("is_private", False), - ) - - -@action( - name="invite_to_slack_channel", - description="Invite users to a Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Channel ID.", - "example": "C1234567", - }, - "users": { - "type": "array", - "description": "List of user IDs.", - "example": ["U123"], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def invite_to_slack_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "invite_to_channel", - channel=input_data["channel"], - users=input_data["users"], - ) - - -@action( - name="open_slack_dm", - description="Open a DM with Slack users.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "users": { - "type": "array", - "description": "List of user IDs.", - "example": ["U123"], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="archive_slack_channel", - description="Archive a Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="unarchive_slack_channel", - description="Unarchive a previously-archived Slack channel.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="rename_slack_channel", - description="Rename a Slack channel.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "name": {"type": "string", "description": "New channel name.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def rename_slack_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "rename_channel", - channel=input_data["channel"], - name=input_data["name"], - ) - - -@action( - name="set_slack_channel_topic", - description="Set a Slack channel's topic.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "topic": {"type": "string", "description": "New topic.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def set_slack_channel_topic(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "set_channel_topic", - channel=input_data["channel"], - topic=input_data["topic"], - ) - - -@action( - name="set_slack_channel_purpose", - description="Set a Slack channel's purpose / description.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "purpose": {"type": "string", "description": "New purpose.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def set_slack_channel_purpose(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "set_channel_purpose", - channel=input_data["channel"], - purpose=input_data["purpose"], - ) - - -@action( - name="join_slack_channel", - description="Have the bot join a Slack channel.", - action_sets=["slack_conversations", "slack"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="leave_slack_channel", - description="Have the bot leave a Slack channel.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -@action( - name="kick_user_from_slack_channel", - description="Remove a user from a Slack channel.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Channel ID.", "example": ""}, - "user": {"type": "string", "description": "User ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def kick_user_from_slack_channel(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "kick_user", - channel=input_data["channel"], - user=input_data["user"], - ) - - -@action( - name="close_slack_conversation", - description="Close a DM, MPDM, or private channel.", - action_sets=["slack_conversations"], - input_schema={ - "channel": {"type": "string", "description": "Conversation ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -# ------------------------------------------------------------------ -# Files -# ------------------------------------------------------------------ - - -@action( - name="upload_slack_file", - description="Upload a local file to Slack using the modern 3-step files.getUploadURLExternal flow. Optionally share into a channel + post initial comment.", - action_sets=["slack_files", "slack"], - input_schema={ - "file_path": { - "type": "string", - "description": "Absolute path to local file.", - "example": "C:/Users/me/report.pdf", - }, - "channel_id": { - "type": "string", - "description": "Channel ID to share into (optional).", - "example": "C01234567", - }, - "initial_comment": { - "type": "string", - "description": "Message text with the file (optional).", - "example": "", - }, - "title": { - "type": "string", - "description": "File title (optional).", - "example": "", - }, - "thread_ts": { - "type": "string", - "description": "Reply in a thread (optional).", - "example": "", - }, - "filename": { - "type": "string", - "description": "Override filename (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def upload_slack_file(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "upload_file_v2", - file_path=input_data["file_path"], - channel_id=input_data.get("channel_id") or None, - initial_comment=input_data.get("initial_comment") or None, - title=input_data.get("title") or None, - thread_ts=input_data.get("thread_ts") or None, - filename=input_data.get("filename") or None, - ) - - -@action( - name="list_slack_files", - description="List files in the workspace (optionally filter by channel, user, or types like 'images,zips'). Lean files (id, name, title, mimetype, size, created, user, permalink) by default; include_metadata=true returns full raw file objects (thumbnails, share info, ...).", - action_sets=["slack_files", "slack"], - input_schema={ - "channel": { - "type": "string", - "description": "Filter to channel (optional).", - "example": "", - }, - "user": { - "type": "string", - "description": "Filter to user (optional).", - "example": "", - }, - "types": { - "type": "string", - "description": "Comma-separated types: all, spaces, snippets, images, gdocs, zips, pdfs (optional).", - "example": "", - }, - "count": {"type": "integer", "description": "Max results.", "example": 100}, - "page": {"type": "integer", "description": "Page number.", "example": 1}, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean files. True: full raw.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_slack_files(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "slack", - "list_files", - channel=input_data.get("channel") or None, - user=input_data.get("user") or None, - types=input_data.get("types") or None, - count=input_data.get("count", 100), - page=input_data.get("page", 1), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - lean = { - "files": [ - { - "id": f.get("id"), - "name": f.get("name"), - "title": f.get("title"), - "mimetype": f.get("mimetype"), - "size": f.get("size"), - "created": f.get("created"), - "user": f.get("user"), - "permalink": f.get("permalink"), - } - for f in body.get("files", []) or [] - if isinstance(f, dict) - ] - } - if isinstance(body.get("paging"), dict): - lean["paging"] = body["paging"] - return {**res, "result": lean} - - -@action( - name="get_slack_file_info", - description="Get metadata for a Slack file (name, size, URL, channels shared into).", - action_sets=["slack_files", "slack"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": "F0123ABC"}, - }, - 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"]) - - -@action( - name="delete_slack_file", - description="Delete a Slack file. Irreversible.", - action_sets=["slack_files"], - input_schema={ - "file_id": {"type": "string", "description": "File ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -# ------------------------------------------------------------------ -# Users + usergroups + presence -# ------------------------------------------------------------------ - - -@action( - name="list_slack_users", - description="List users in the Slack workspace. Lean members (id, name, real_name, display_name, email, is_bot, is_admin, tz, deleted) by default; include_metadata=true returns full raw user objects (avatar URLs, full profile, ...).", - action_sets=["slack_users", "slack"], - input_schema={ - "limit": { - "type": "integer", - "description": "Max users to return.", - "example": 100, - }, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean members. True: full raw.", - "example": False, - }, - }, - output_schema={ - "status": {"type": "string", "example": "success"}, - "users": {"type": "array"}, - }, -) -def list_slack_users(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync("slack", "list_users", limit=input_data.get("limit", 100)) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict): - return res - - def _lean(m: dict) -> dict: - profile = m.get("profile") or {} - out = { - "id": m.get("id"), - "name": m.get("name"), - "real_name": m.get("real_name") or profile.get("real_name"), - "display_name": profile.get("display_name"), - "email": profile.get("email"), - "is_bot": m.get("is_bot"), - "tz": m.get("tz"), - "deleted": m.get("deleted"), - } - if "is_admin" in m: - out["is_admin"] = m.get("is_admin") - return out - - lean = { - "members": [ - _lean(m) for m in body.get("members", []) or [] if isinstance(m, dict) - ] - } - cursor = (body.get("response_metadata") or {}).get("next_cursor") - if cursor: - lean["next_cursor"] = cursor - return {**res, "result": lean} - - -@action( - name="get_slack_user_info", - description="Get info about a Slack user.", - action_sets=["slack_users", "slack"], - input_schema={ - "slack_user_id": { - "type": "string", - "description": "User ID.", - "example": "U1234567", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="lookup_slack_user_by_email", - description="Resolve a Slack user by their email address.", - action_sets=["slack_users", "slack"], - input_schema={ - "email": { - "type": "string", - "description": "Email address.", - "example": "alice@example.com", - }, - }, - 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"]) - - -@action( - name="get_slack_user_presence", - description="Check whether a Slack user is online (active) or offline (away).", - action_sets=["slack_users"], - input_schema={ - "user": {"type": "string", "description": "User ID.", "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"]) - - -@action( - name="set_slack_user_presence", - description="Set the authenticated user's presence (requires user token xoxp-, not bot token).", - action_sets=["slack_users"], - input_schema={ - "presence": { - "type": "string", - "description": "auto or away.", - "example": "auto", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="list_slack_usergroups", - description="List Slack usergroups (@team mentions) in the workspace.", - action_sets=["slack_users", "slack"], - input_schema={ - "include_disabled": { - "type": "boolean", - "description": "Include disabled groups.", - "example": False, - }, - "include_count": { - "type": "boolean", - "description": "Include member counts.", - "example": False, - }, - "include_users": { - "type": "boolean", - "description": "Include user list per group.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_slack_usergroups(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "list_usergroups", - 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)), - ) - - -@action( - name="create_slack_usergroup", - description="Create a new Slack usergroup.", - action_sets=["slack_users"], - input_schema={ - "name": { - "type": "string", - "description": "Group name (e.g. 'Marketing').", - "example": "", - }, - "handle": { - "type": "string", - "description": "Handle without @ (optional).", - "example": "", - }, - "description": { - "type": "string", - "description": "Description (optional).", - "example": "", - }, - "channels": { - "type": "array", - "description": "Default channels (optional).", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def create_slack_usergroup(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "create_usergroup", - name=input_data["name"], - handle=input_data.get("handle") or None, - description=input_data.get("description") or None, - channels=input_data.get("channels") or None, - ) - - -@action( - name="update_slack_usergroup", - description="Update a Slack usergroup's name/handle/description/channels.", - action_sets=["slack_users"], - input_schema={ - "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, - "name": { - "type": "string", - "description": "New name (optional).", - "example": "", - }, - "handle": { - "type": "string", - "description": "New handle (optional).", - "example": "", - }, - "description": { - "type": "string", - "description": "New description (optional).", - "example": "", - }, - "channels": { - "type": "array", - "description": "New default channels (optional).", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def update_slack_usergroup(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "update_usergroup", - 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, - description=input_data["description"] if "description" in input_data else None, - channels=input_data["channels"] if "channels" in input_data else None, - ) - - -@action( - name="list_slack_usergroup_users", - description="List the users in a Slack usergroup.", - action_sets=["slack_users"], - input_schema={ - "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, - "include_disabled": { - "type": "boolean", - "description": "Include disabled users.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def list_slack_usergroup_users(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "list_usergroup_users", - usergroup=input_data["usergroup"], - include_disabled=bool(input_data.get("include_disabled", False)), - ) - - -@action( - name="set_slack_usergroup_users", - description="REPLACE the members of a Slack usergroup.", - action_sets=["slack_users"], - input_schema={ - "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, - "users": { - "type": "array", - "description": "List of user IDs to set as members.", - "example": [], - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def set_slack_usergroup_users(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "update_usergroup_users", - usergroup=input_data["usergroup"], - users=input_data["users"], - ) - - -@action( - name="enable_slack_usergroup", - description="Enable a previously-disabled Slack usergroup.", - action_sets=["slack_users"], - input_schema={ - "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="disable_slack_usergroup", - description="Disable a Slack usergroup (keeps it but hides from autocomplete).", - action_sets=["slack_users"], - input_schema={ - "usergroup": {"type": "string", "description": "Usergroup ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -# ------------------------------------------------------------------ -# Workspace: auth / team / search / bookmarks / reminders -# ------------------------------------------------------------------ - - -@action( - 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={}, - 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") - - -@action( - name="get_slack_team_info", - description="Get info about the Slack workspace (team name, domain, icon).", - action_sets=["slack_workspace", "slack"], - input_schema={ - "team": { - "type": "string", - "description": "Team ID (optional, defaults to current).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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 - ) - - -@action( - name="search_slack_messages", - description="Search for messages in the Slack workspace (requires user token / search:read). Lean matches (user, text, ts, channel {id, name}, permalink) by default; include_metadata=true returns full raw matches (blocks, score, pagination, ...).", - action_sets=["slack_workspace", "slack"], - input_schema={ - "query": { - "type": "string", - "description": "Search query.", - "example": "project update", - }, - "count": {"type": "integer", "description": "Max results.", "example": 20}, - "include_metadata": { - "type": "boolean", - "description": "False (default): lean matches. True: full raw.", - "example": False, - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -def search_slack_messages(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - res = run_client_sync( - "slack", - "search_messages", - query=input_data["query"], - count=input_data.get("count", 20), - ) - if input_data.get("include_metadata") or res.get("status") != "success": - return res - body = res.get("result") - if not isinstance(body, dict) or not isinstance(body.get("messages"), dict): - return res - msgs = body["messages"] - - def _lean(m: dict) -> dict: - ch = m.get("channel") or {} - out = { - "user": m.get("user"), - "text": m.get("text"), - "ts": m.get("ts"), - "channel": {"id": ch.get("id"), "name": ch.get("name")}, - "permalink": m.get("permalink"), - } - if m.get("thread_ts"): - out["thread_ts"] = m["thread_ts"] - return out - - lean = { - "total": msgs.get("total"), - "matches": [ - _lean(m) for m in msgs.get("matches", []) or [] if isinstance(m, dict) - ], - } - return {**res, "result": lean} - - -@action( - name="list_slack_bookmarks", - description="List bookmarks pinned to a Slack channel.", - action_sets=["slack_workspace", "slack"], - input_schema={ - "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="add_slack_bookmark", - description="Add a bookmark to a Slack channel.", - action_sets=["slack_workspace", "slack"], - input_schema={ - "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, - "title": { - "type": "string", - "description": "Bookmark title.", - "example": "Project doc", - }, - "type": { - "type": "string", - "description": "Bookmark type (link).", - "example": "link", - }, - "link": { - "type": "string", - "description": "URL (for type=link).", - "example": "", - }, - "emoji": { - "type": "string", - "description": "Emoji shortcode (optional).", - "example": ":bookmark:", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_slack_bookmark(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "add_bookmark", - channel_id=input_data["channel_id"], - title=input_data["title"], - type=input_data.get("type", "link"), - link=input_data.get("link") or None, - emoji=input_data.get("emoji") or None, - ) - - -@action( - name="edit_slack_bookmark", - description="Edit an existing channel bookmark.", - action_sets=["slack_workspace"], - input_schema={ - "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, - "bookmark_id": {"type": "string", "description": "Bookmark ID.", "example": ""}, - "title": { - "type": "string", - "description": "New title (optional).", - "example": "", - }, - "link": {"type": "string", "description": "New URL (optional).", "example": ""}, - "emoji": { - "type": "string", - "description": "New emoji (optional).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def edit_slack_bookmark(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "edit_bookmark", - channel_id=input_data["channel_id"], - bookmark_id=input_data["bookmark_id"], - title=input_data["title"] if "title" in input_data else None, - link=input_data["link"] if "link" in input_data else None, - emoji=input_data["emoji"] if "emoji" in input_data else None, - ) - - -@action( - name="remove_slack_bookmark", - description="Delete a channel bookmark.", - action_sets=["slack_workspace"], - input_schema={ - "channel_id": {"type": "string", "description": "Channel ID.", "example": ""}, - "bookmark_id": {"type": "string", "description": "Bookmark ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def remove_slack_bookmark(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "remove_bookmark", - channel_id=input_data["channel_id"], - bookmark_id=input_data["bookmark_id"], - ) - - -@action( - name="add_slack_reminder", - description="Add a Slack reminder. time can be a Unix timestamp or natural-language ('in 15 minutes'). Requires user token (xoxp-) — bot tokens can't create reminders.", - action_sets=["slack_workspace", "slack"], - input_schema={ - "text": { - "type": "string", - "description": "Reminder text.", - "example": "Send the weekly report", - }, - "time": { - "type": "string", - "description": "Unix timestamp OR natural-language ('in 15 minutes').", - "example": "in 15 minutes", - }, - "user": { - "type": "string", - "description": "User ID (optional, defaults to self).", - "example": "", - }, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -def add_slack_reminder(input_data: dict) -> dict: - from app.data.action.integrations._helpers import run_client_sync - - return run_client_sync( - "slack", - "add_reminder", - text=input_data["text"], - time=input_data["time"], - user=input_data.get("user") or None, - ) - - -@action( - name="list_slack_reminders", - description="List the authenticated user's Slack reminders.", - action_sets=["slack_workspace"], - input_schema={}, - 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") - - -@action( - name="get_slack_reminder", - description="Get info about a single Slack reminder.", - action_sets=["slack_workspace"], - input_schema={ - "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, -) -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"] - ) - - -@action( - name="complete_slack_reminder", - description="Mark a Slack reminder as complete.", - action_sets=["slack_workspace"], - input_schema={ - "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"] - ) - - -@action( - name="delete_slack_reminder", - description="Delete a Slack reminder.", - action_sets=["slack_workspace"], - input_schema={ - "reminder": {"type": "string", "description": "Reminder ID.", "example": ""}, - }, - output_schema={"status": {"type": "string", "example": "success"}}, - parallelizable=False, -) -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"]) - - -# ================================================================== -# Intentionally NOT exposed as actions (and why) -# ================================================================== -# - Events API subscriptions, RTM (deprecated), Socket Mode setup -# Server-side event-receiving plumbing. The listener handles it internally. -# - views.* (modal/home/app views) and interactions.* (block button responses) -# Interactive UI surface that requires a paired Events API endpoint to -# handle callbacks. Not actionable from a one-shot agent loop. -# - canvases / lists (canvases.create/edit/listcategories, slackLists) -# New Block Kit-adjacent surfaces; not stable enough across plans. -# - admin.* and scim -# Enterprise Grid admin. Requires enterprise tokens. -# - apps.connections.open (Socket Mode tokens) -# Realtime infrastructure. -# - dnd.* (Do-not-disturb) -# User-token-only, rarely needed by an assistant. -# - migration.exchange / stars / dialog.* (deprecated) -# Legacy surfaces. -# - chat.unfurl / link_shared -# Event-driven; requires Events API loop. diff --git a/app/data/agent_file_system_template/AGENT.md b/app/data/agent_file_system_template/AGENT.md index 28675368..0fd79abd 100644 --- a/app/data/agent_file_system_template/AGENT.md +++ b/app/data/agent_file_system_template/AGENT.md @@ -2521,6 +2521,20 @@ lark token Lark messaging To enumerate at runtime: call the `list_available_integrations` action. To check what's already connected: `check_integration_status`. Guessed ids get normalized via an alias map (e.g. `gdrive` → `google_drive`, `gcal` → `google_calendar`). +### Multi-account + +Ten integrations support **multiple connected accounts**: the five Google services, Outlook, LinkedIn, Notion, HubSpot, and Slack. Each holds one **primary** account plus any number of additional ones; every account can carry a user-set nickname (alias), and nicknames are shared across the Google family for the same underlying account. + +Rules that matter to you: + +- **Every action for these integrations takes an optional `account` input** — an email/identity, the nickname, or any unique fragment of either. Omit it to act as the primary account. +- **Extract account qualifiers from natural language.** "My school calendar" → `account="school"`. "The work inbox" → `account="work"`. Never silently default to primary when the user named an account in any form. +- **Bad hints self-correct.** An unresolvable or ambiguous `account` returns an error listing the connected accounts — choose from that list or ask the user; don't retry the same hint. +- **IDs are account-scoped.** A message/event/file/page id returned under `account="work"` must be used with `account="work"` on every follow-up action. +- **Ask before irreversible actions when ambiguous.** Multiple accounts connected + a send/delete/clear request that names no account → ask which account first. +- Alias/primary management (renaming accounts, switching primary, per-account listening) lives in the Settings UI, not in agent actions. +- The Google services stay split per service, but the same person's account connects to each service separately; an alias set once applies across all five. + ### The agent's connection toolkit (actions) ``` diff --git a/app/integrations.py b/app/integrations.py new file mode 100644 index 00000000..8c16f9a1 --- /dev/null +++ b/app/integrations.py @@ -0,0 +1,167 @@ +"""Host bootstrap for the integrations system. + +The single place CraftBot constructs its IntegrationSystem. Everything +host-specific about the system — which storage backend, which providers, where +legacy credential files live — is decided here; the package itself stays +host-blind. + +Lazy singleton: construction needs nothing from app config because the +FileCredentialStore resolves ``ConfigStore.project_root`` per call, so +``get_system()`` is safe to call before ``configure_integrations`` has run +(clients are only built at action-execution time, long after startup). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.logger import get_logger + +logger = get_logger(__name__) + +_system: Optional[IntegrationSystem] = None +_listeners: Optional[Any] = None # ListenerManager, built lazily in start_listeners() +_listener_task: Optional[asyncio.Task] = None # holds ListenerManager.start()'s run-loop + + +def _legacy_filenames() -> Dict[str, str]: + """Map provider id → the legacy single-account credential filename, read + from the old handlers' IntegrationSpec so the two can never drift.""" + mapping: Dict[str, str] = {} + try: + from craftos_integrations import registry as legacy_registry + + legacy_registry.autoload_integrations() + for name, handler in legacy_registry.get_all_handlers().items(): + spec = getattr(handler, "spec", None) + if spec is None: + continue + mapping[name] = spec.cred_file + mapping[spec.platform_id] = spec.cred_file + except Exception: + # Fall back to the store's default (.json) per lookup. + pass + return mapping + + +def get_system() -> IntegrationSystem: + global _system + if _system is None: + from craftos_integrations.providers import default_providers + + _system = IntegrationSystem( + store=FileCredentialStore(legacy_filenames=_legacy_filenames()), + providers=default_providers(), + ) + return _system + + +def reset_system() -> None: + """Testing hook: drop the singletons so the next get_system() rebuilds.""" + global _system, _listeners + _system = None + _listeners = None + + +# ── listener fan-out (PR 5) ────────────────────────────────────────────── + + +class CraftBotEventSink: + """EventSink implementation: listener events → the agent's trigger + system. + + The ListenerManager emits the same payload-dict shape the legacy + ``ExternalCommsManager._handle_platform_message`` builds, so events are + forwarded to the very same host callback (``ConfigStore.on_message``, + set by ``initialize_manager``) — the agent cannot tell which engine + delivered a message. Before forwarding, the payload is enriched with + the account that received it so multi-account routing survives the + trip: ``payload["account"]`` carries the identity, and the + human-readable ``source`` gains an ``(alias-or-identity)`` suffix. + """ + + async def on_event( + self, provider_id: str, identity: str, event: Dict[str, Any] + ) -> None: + from craftos_integrations.config import ConfigStore + + on_message = ConfigStore.on_message + if on_message is None: + logger.warning( + f"[LISTENERS] Dropping {provider_id}/{identity} event: " + "no on_message callback configured" + ) + return + + payload = dict(event) + payload["account"] = identity + + alias: Optional[str] = None + try: + for info in get_system().accounts.list_accounts(provider_id): + if info.identity == identity: + alias = info.alias + break + except Exception: + pass # best-effort: fall back to the bare identity + payload["source"] = f"{payload.get('source', provider_id)} ({alias or identity})" + + await on_message(payload) + + +def _log_listener_task_exit(task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error(f"[LISTENERS] manager run-loop died: {exc!r}") + + +async def start_listeners() -> None: + """Build (once) and start the ListenerManager. + + Wires FileCursorStore + CraftBotEventSink and attaches the manager as + ``system.listeners`` so account mutations can reconcile running + listeners. + + ``ListenerManager.start()`` is a service run-loop — it reconciles and + then HOLDS until ``stop()`` — so it must run as a background task; + awaiting it inline deadlocks the caller (observed live 2026-08-12: + agent boot froze at step 6/7). Idempotent: the manager is built once + and a still-running task is left alone. + """ + global _listeners, _listener_task + if _listeners is None: + from craftos_integrations.core.listeners import ( + FileCursorStore, + ListenerManager, + ) + + system = get_system() + _listeners = ListenerManager(system, CraftBotEventSink(), FileCursorStore()) + system.listeners = _listeners + if _listener_task is None or _listener_task.done(): + _listener_task = asyncio.create_task( + _listeners.start(), name="integrations-listener-manager" + ) + _listener_task.add_done_callback(_log_listener_task_exit) + # Yield once so the manager's initial reconcile gets underway + # before boot continues. + await asyncio.sleep(0) + + +async def stop_listeners() -> None: + """Stop the ListenerManager if it was ever started.""" + global _listener_task + if _listeners is not None: + await _listeners.stop() + if _listener_task is not None: + if not _listener_task.done(): + try: + await _listener_task + except asyncio.CancelledError: + pass + _listener_task = None diff --git a/app/living_ui/agent_view.py b/app/living_ui/agent_view.py index c2af901d..507c0c44 100644 --- a/app/living_ui/agent_view.py +++ b/app/living_ui/agent_view.py @@ -137,12 +137,17 @@ def capability_block() -> Optional[str]: try: from craftos_integrations import get_client, get_registered_platforms from agent_core.core.action_framework.registry import ActionRegistry + from app.data.action.integrations._helpers import system_for connected, disconnected = [], [] for pid in get_registered_platforms(): try: - client = get_client(pid) - ok = bool(client and client.has_credentials()) + system = system_for(pid) + if system is not None: + ok = bool(system.list_accounts(pid)) + else: + client = get_client(pid) + ok = bool(client and client.has_credentials()) except Exception: ok = False (connected if ok else disconnected).append(pid) diff --git a/app/living_ui/integration_bridge.py b/app/living_ui/integration_bridge.py index 5ccf612f..51b1355d 100644 --- a/app/living_ui/integration_bridge.py +++ b/app/living_ui/integration_bridge.py @@ -110,11 +110,19 @@ async def _handle_available(self, request: web.Request) -> web.Response: return web.json_response({"error": "Unauthorized"}, status=401) from craftos_integrations import get_registered_platforms, get_client + from app.data.action.integrations._helpers import system_for integrations = [] for platform_id in get_registered_platforms(): - client = get_client(platform_id) - connected = client.has_credentials() if client else False + system = system_for(platform_id) + if system is not None: + try: + connected = bool(system.list_accounts(platform_id)) + except Exception: + connected = False + else: + client = get_client(platform_id) + connected = client.has_credentials() if client else False integrations.append( { "id": platform_id, @@ -765,6 +773,32 @@ def _resolve_destination(self, integration: str, url: str) -> tuple: return True, raw return False, f"host {host!r} is not one of {', '.join(allowed)}" + def _client_for_platform(self, platform_id: str): + """Credentialed client for a platform, or None. + + multi-account provider ids get the PRIMARY account's client from the + IntegrationSystem (the bound client subclasses the legacy client, so + the header-extraction below works unchanged); everything else keeps + the legacy single-account client. + """ + from app.data.action.integrations._helpers import system_for + + system = system_for(platform_id) + if system is not None: + try: + identity = system.resolve(platform_id, None) + return system.client_for(platform_id, identity) + except Exception: + # Not connected (AccountResolutionError) or build failure. + return None + + from craftos_integrations import get_client + + client = get_client(platform_id) + if not client or not client.has_credentials(): + return None + return client + def _get_auth_headers(self, platform_id: str) -> Optional[dict]: """ Get authentication headers from a platform client. @@ -772,10 +806,8 @@ def _get_auth_headers(self, platform_id: str) -> Optional[dict]: Returns: Dict of auth headers, or None if credentials unavailable. """ - from craftos_integrations import get_client - - client = get_client(platform_id) - if not client or not client.has_credentials(): + client = self._client_for_platform(platform_id) + if client is None: return None # Most clients expose _headers() — use it diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 094e55c2..515aac18 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1605,7 +1605,24 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "integration_disconnect": integration_id = data.get("id", "") account_id = data.get("account_id") - await self._handle_integration_disconnect(integration_id, account_id) + request_id = data.get("request_id") + await self._handle_integration_disconnect( + integration_id, account_id, request_id + ) + + # Multi-account integration handlers + elif msg_type == "integration_accounts_add": + integration_id = data.get("integration_id", "") + request_id = data.get("request_id") + await self._handle_integration_accounts_add(integration_id, request_id) + + elif msg_type == "integration_apply_account_changes": + integration_id = data.get("integration_id", "") + request_id = data.get("request_id") + changes = data.get("changes") or {} + await self._handle_integration_apply_account_changes( + integration_id, request_id, changes + ) # Generic per-integration config (replaces the old bespoke jira/github settings handlers) elif msg_type == "integration_get_config": @@ -6356,19 +6373,106 @@ async def _handle_integration_list(self) -> None: } ) + # ── multi-account integration helpers ────────────────────── + + @staticmethod + def _system_for(integration_id: str): + """Return the IntegrationSystem when it knows this provider id. + + Returns None for legacy integrations (or if bootstrap fails), so + callers fall back to the legacy path unchanged. + """ + try: + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is not None: + return system + except Exception as e: + # Loud on purpose: this degrade silently reroutes v2 providers to + # the LEGACY single-account UI (no Add account, status-parsed + # rows), which looks like a frontend bug. Never let it hide. + logger.error( + f"[INTEGRATIONS] integration-system bootstrap/lookup failed for " + f"{integration_id}; degrading to legacy path: {e!r}" + ) + return None + + @staticmethod + def _accounts_payload(accounts) -> List[Dict[str, Any]]: + """Serialize AccountInfo objects into the wire shape.""" + return [ + { + "identity": a.identity, + "alias": a.alias, + "isPrimary": a.is_primary, + "listen": a.listen, + } + for a in accounts + ] + + def _current_accounts(self, integration_id: str) -> Optional[List[Dict[str, Any]]]: + """Best-effort current account list for error payloads. + + Returns None (NOT []) when the list can't be fetched: the frontend + treats a present ``accounts`` array as the authoritative state and + prunes its staged edits against it, so a fabricated empty list would + blank the Manage modal and silently discard the user's unsaved + edits. Callers must OMIT the ``accounts`` key when this is None. + """ + try: + system = self._system_for(integration_id) + if system is not None: + return self._accounts_payload(system.list_accounts(integration_id)) + except Exception: + pass + return None + + @staticmethod + def _with_accounts( + data: Dict[str, Any], accounts: Optional[List[Dict[str, Any]]] + ) -> Dict[str, Any]: + """Attach ``accounts`` only when a real list is available.""" + if accounts is not None: + data["accounts"] = accounts + return data + async def _handle_integration_info(self, integration_id: str) -> None: """Get detailed info about an integration.""" try: info = get_integration_info(integration_id) if info: + # For providers known to the integrations system, attach the + # multi-account view as a TOP-LEVEL ``accounts`` key — the + # frontend reads ``data.accounts`` (see IntegrationsSettings's + # ``integration_info`` handler and ManagedAccount in types.ts) + # to decide between AccountsManager and the legacy modal body. + # ``info["accounts"]`` (inside ``data.integration``) keeps the + # legacy status-parsed ``{display, id}`` shape untouched so the + # legacy fallback rows can never receive v2-shaped objects. + managed_accounts: Optional[List[Dict[str, Any]]] = None + try: + system = self._system_for(integration_id) + if system is not None: + managed_accounts = self._accounts_payload( + system.list_accounts(integration_id) + ) + except Exception as e: + logger.error( + f"[INTEGRATIONS] v2 accounts for {integration_id} " + f"unavailable, Manage modal degrades to legacy view: {e!r}" + ) + data: Dict[str, Any] = { + "success": True, + "id": integration_id, + "integration": info, + } + if managed_accounts is not None: + data["accounts"] = managed_accounts await self._broadcast( { "type": "integration_info", - "data": { - "success": True, - "id": integration_id, - "integration": info, - }, + "data": data, } ) else: @@ -6397,11 +6501,25 @@ async def _handle_integration_info(self, integration_id: str) -> None: async def _handle_integration_connect_token( self, integration_id: str, credentials: Dict[str, str] ) -> None: - """Connect an integration using token/credentials.""" + """Connect an integration using token/credentials. + + multi-account providers (notion/hubspot/slack manual tokens) validate the token + the same way the legacy handler login does, then store through the + IntegrationSystem — never the legacy single-account save. Legacy + integrations keep the legacy handler path unchanged. + """ try: - success, message = await connect_integration_token( - integration_id, credentials - ) + v2_system = self._system_for(integration_id) + if v2_system is not None: + from app.data.action.integrations._helpers import system_connect_token + + success, message = await asyncio.to_thread( + system_connect_token, v2_system, integration_id, credentials + ) + else: + success, message = await connect_integration_token( + integration_id, credentials + ) await self._broadcast( { "type": "integration_connect_result", @@ -6438,9 +6556,21 @@ async def _handle_integration_connect_oauth(self, integration_id: str) -> None: self._oauth_tasks[integration_id] = task async def _run_oauth_flow(self, integration_id: str) -> None: - """Execute OAuth flow and broadcast result (runs as background task).""" + """Execute OAuth flow and broadcast result (runs as background task). + + multi-account providers route through ``IntegrationSystem.add_account`` (the + multi-account OAuth flow); the broadcast keeps the legacy + ``integration_connect_result`` shape so the frontend needs no + changes. Legacy integrations keep the legacy handler login. + """ try: - success, message = await connect_integration_oauth(integration_id) + v2_system = self._system_for(integration_id) + if v2_system is not None: + success, message, _accounts = await v2_system.add_account( + integration_id + ) + else: + success, message = await connect_integration_oauth(integration_id) await self._broadcast( { "type": "integration_connect_result", @@ -6542,7 +6672,10 @@ async def _handle_integration_connect_cancel(self, integration_id: str) -> None: # Result will be broadcast by the cancelled task's CancelledError handler async def _handle_integration_disconnect( - self, integration_id: str, account_id: Optional[str] = None + self, + integration_id: str, + account_id: Optional[str] = None, + request_id: Optional[str] = None, ) -> None: """Disconnect an integration account. @@ -6551,10 +6684,72 @@ async def _handle_integration_disconnect( the frontend would show stale "connected" state until the teardown finishes. So we run the disconnect in a background task and let this handler return immediately. + + For providers known to the integrations system: + - with ``account_id``: remove just that account via the integration system + (no legacy call — legacy has no notion of a specific account). + - without ``account_id``: remove ALL accounts, then fall through + to the legacy disconnect so old cred/config files are cleaned too. + Legacy integrations take the legacy path unchanged. """ async def _do_disconnect() -> None: try: + system = self._system_for(integration_id) + + if system is not None and account_id: + # Targeted removal — handled entirely by the integration system. + try: + identity = await asyncio.to_thread( + system.remove_account, integration_id, account_id + ) + success, message = ( + True, + f"Removed account '{identity}' from {integration_id}", + ) + except Exception as e: + success, message = False, str(e) + await self._broadcast( + { + "type": "integration_disconnect_result", + "data": self._with_accounts( + { + "success": success, + "message": message, + "id": integration_id, + "requestId": request_id, + }, + self._current_accounts(integration_id), + ), + } + ) + if success: + await self._handle_integration_list() + return + + if system is not None: + # Disconnect-all: drop every account, then fall through + # to the legacy disconnect below for file cleanup. + try: + for account in await asyncio.to_thread( + system.list_accounts, integration_id + ): + try: + await asyncio.to_thread( + system.remove_account, + integration_id, + account.identity, + ) + except Exception as e: + logger.warning( + f"remove_account {integration_id}/" + f"{account.identity} failed: {e}" + ) + except Exception as e: + logger.warning( + f"disconnect-all for {integration_id} failed: {e}" + ) + success, message = await disconnect_integration( integration_id, account_id ) @@ -6565,6 +6760,7 @@ async def _do_disconnect() -> None: "success": success, "message": message, "id": integration_id, + "requestId": request_id, }, } ) @@ -6578,12 +6774,162 @@ async def _do_disconnect() -> None: "success": False, "error": str(e), "id": integration_id, + "requestId": request_id, }, } ) asyncio.create_task(_do_disconnect()) + async def _handle_integration_accounts_add( + self, integration_id: str, request_id: Optional[str] = None + ) -> None: + """Add another account to a multi-account integration (real OAuth — the browser + opens and the flow may take minutes). Runs as a background task so + the WS message loop stays responsive, mirroring the legacy OAuth + connect handlers. Result is broadcast as + ``integration_accounts_add_result``; the frontend correlates via + ``requestId``. + """ + # Cancel any in-flight connect/add flow for this integration. + if integration_id in self._oauth_tasks: + self._oauth_tasks[integration_id].cancel() + + task = asyncio.create_task( + self._run_accounts_add(integration_id, request_id) + ) + self._oauth_tasks[integration_id] = task + + async def _run_accounts_add( + self, integration_id: str, request_id: Optional[str] + ) -> None: + """Execute the add-account OAuth flow and broadcast the result.""" + try: + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is None: + raise LookupError(f"Unknown integration '{integration_id}'") + ok, message, accounts = await system.add_account(integration_id) + await self._broadcast( + { + "type": "integration_accounts_add_result", + "data": { + "id": integration_id, + "requestId": request_id, + "ok": bool(ok), + "message": message, + "accounts": self._accounts_payload(accounts or []), + }, + } + ) + if ok: + await self._handle_integration_list() + except asyncio.CancelledError: + await self._broadcast( + { + "type": "integration_accounts_add_result", + "data": self._with_accounts( + { + "id": integration_id, + "requestId": request_id, + "ok": False, + "message": "Add account cancelled", + }, + self._current_accounts(integration_id), + ), + } + ) + except Exception as e: + # Contract note: the add-result failure text travels in "message" + # (Settings/types.ts IntegrationAccountsAddResult has no "error" + # field), unlike apply_account_changes_result which uses "error". + await self._broadcast( + { + "type": "integration_accounts_add_result", + "data": self._with_accounts( + { + "id": integration_id, + "requestId": request_id, + "ok": False, + "message": str(e), + }, + self._current_accounts(integration_id), + ), + } + ) + finally: + self._oauth_tasks.pop(integration_id, None) + + async def _handle_integration_apply_account_changes( + self, + integration_id: str, + request_id: Optional[str] = None, + changes: Optional[Dict[str, Any]] = None, + ) -> None: + """Apply one batched set of account edits from the Manage modal. + + ``changes`` = {"disconnect": [identity...], "primary": identity|None, + "aliases": {identity: alias|None}, "listen": {identity: bool}}. + The integration system applies disconnects → primary → aliases → listen flags + inside its storage lock. Sync file I/O, so it runs in a thread. On + failure the frontend keeps its staged edits, so the error payload + carries the *current* (unchanged) account list. + """ + try: + from craftos_integrations.contracts import AccountResolutionError + from app.integrations import get_system + + system = get_system() + if system.registry.get(integration_id) is None: + raise LookupError(f"Unknown integration '{integration_id}'") + try: + accounts = await asyncio.to_thread( + system.apply_account_changes, integration_id, changes or {} + ) + await self._broadcast( + { + "type": "integration_apply_account_changes_result", + "data": { + "id": integration_id, + "requestId": request_id, + "ok": True, + "accounts": self._accounts_payload(accounts), + }, + } + ) + await self._handle_integration_list() + except (ValueError, AccountResolutionError) as e: + await self._broadcast( + { + "type": "integration_apply_account_changes_result", + "data": self._with_accounts( + { + "id": integration_id, + "requestId": request_id, + "ok": False, + "error": str(e), + }, + self._current_accounts(integration_id), + ), + } + ) + except Exception as e: + await self._broadcast( + { + "type": "integration_apply_account_changes_result", + "data": self._with_accounts( + { + "id": integration_id, + "requestId": request_id, + "ok": False, + "error": str(e), + }, + self._current_accounts(integration_id), + ), + } + ) + # ========================== # Generic per-integration config # ========================== 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..68023308 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import * as LucideIcons from 'lucide-react' import { Globe, @@ -11,6 +11,9 @@ import { Power, Wrench, HelpCircle, + Star, + UserPlus, + Undo2, } from 'lucide-react' import { Button, Badge, ConfirmModal } from '../../components/ui' import { useToast } from '../../contexts/ToastContext' @@ -23,6 +26,28 @@ import { type Integration, type ConfigField, } from '../../store/slices/integrationsSettingsSlice' +import type { + ManagedAccount, + StagedAccountEdits, + AccountChanges, + IntegrationAccountsAddResult, + IntegrationApplyAccountChangesResult, +} from './types' + +// --- Multi-account staged-edit helpers ------------------- + +const emptyStaged = (): StagedAccountEdits => ({ + disconnect: [], + primary: null, + aliases: {}, + listen: {}, +}) + +const stagedIsEmpty = (s: StagedAccountEdits): boolean => + s.disconnect.length === 0 && + s.primary === null && + Object.keys(s.aliases).length === 0 && + Object.keys(s.listen).length === 0 import { selectIntegrations, selectIntegrationsTotal, @@ -346,14 +371,207 @@ const ConfigForm = ({ ))}
-
) } +// Multi-account manager, rendered in the Manage modal for integrations whose +// ``integration_info`` payload carries a multi-account ``accounts`` array. Rename / +// set-primary / listen-toggle / mark-disconnect are STAGED locally (the +// ``staged`` prop) and committed as one ``integration_apply_account_changes`` +// request on "Save changes". The save bar (Save changes + Discard) renders +// ONLY while staged edits exist, so it can never be confused with the +// Configure section's own save button. "Add account" launches the real OAuth +// flow immediately — no staged step — and may take minutes to resolve, so it +// shows an in-progress state until the result broadcast arrives (no timers). +const AccountsManager = ({ + accounts, + staged, + adding, + saving, + error, + onAliasChange, + onSetPrimary, + onListenChange, + onToggleDisconnect, + onAddAccount, + onDiscard, + onSave, +}: { + accounts: ManagedAccount[] + staged: StagedAccountEdits | undefined + adding: boolean + saving: boolean + error: string + onAliasChange: (account: ManagedAccount, value: string) => void + onSetPrimary: (account: ManagedAccount) => void + onListenChange: (account: ManagedAccount, value: boolean) => void + onToggleDisconnect: (account: ManagedAccount, marked: boolean) => void + onAddAccount: () => void + onDiscard: () => void + onSave: () => void +}) => { + // Effective primary = staged override, falling back to the real primary. + // pruneStagedFor() guarantees a staged primary always refers to a live + // account (a vanished staged primary is reset to null = real primary). + // A staged primary that is ALSO marked for disconnect is ignored here, + // mirroring handleSaveAccountChanges' payload stripping. + const realPrimary = accounts.find(a => a.isPrimary)?.identity ?? null + const stagedPrimary = + staged && staged.primary !== null && !staged.disconnect.includes(staged.primary) + ? staged.primary + : null + const effectivePrimary = stagedPrimary ?? realPrimary + const hasStaged = staged !== undefined && !stagedIsEmpty(staged) + + return ( + <> + {accounts.length === 0 ? ( +

No accounts connected

+ ) : ( +
+ {accounts.map(account => { + const marked = staged?.disconnect.includes(account.identity) ?? false + // Staged values override real ones; ``in`` checks matter because + // a staged alias of null (= clear) is a real override. + const aliasValue = + staged && account.identity in staged.aliases + ? (staged.aliases[account.identity] ?? '') + : (account.alias ?? '') + const listenValue = + staged && account.identity in staged.listen + ? staged.listen[account.identity] + : account.listen + const isPrimary = account.identity === effectivePrimary + const aliasInputId = `alias-${account.identity}` + return ( +
+
+
+ + {account.identity} + + {isPrimary ? ( + + {stagedPrimary === account.identity ? 'Primary (unsaved)' : 'Primary'} + + ) : ( + + )} +
+ {marked ? ( + + ) : ( +
+ {marked ? ( +

+ Will be disconnected when you save changes. +

+ ) : ( +
+
+ + onAliasChange(account, e.target.value)} + /> +
+ +
+ )} +
+ ) + })} +
+ )} + +
+ + {adding && ( +

+ Complete the sign-in in the browser window that opened. This can take a few minutes. +

+ )} +
+ + {error &&
{error}
} + + {/* Dirty-state save bar: exists only while there is something to save, + so the modal never shows two competing idle save buttons. */} + {(hasStaged || saving) && ( +
+ Unsaved account changes + + +
+ )} + + ) +} + export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: boolean } = {}) { const { send, onMessage, isConnected } = useSettingsWebSocket() const { showToast } = useToast() @@ -391,6 +609,89 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // Manage modal state const [showManageModal, setShowManageModal] = useState(false) const [managingIntegration, setManagingIntegration] = useState(null) + // Mirrors ``managingIntegration`` for the WebSocket handlers (same reason + // as ``selectedIntegrationRef`` above — the subscription effect doesn't + // re-run on state changes, so direct reads would be stale). + const managingIntegrationRef = React.useRef(null) + useEffect(() => { + managingIntegrationRef.current = managingIntegration + }, [managingIntegration]) + // True only between an explicit user-triggered ``integration_info`` request + // and its response. ``integration_info`` results NEVER open the Manage + // modal unless this flag is set — broadcasts must not open modals. + const manageRequestedRef = React.useRef(false) + + // --- Multi-account state ------------------------------- + // Real account list for the currently-managed integration, from the + // ``accounts`` field of the ``integration_info`` payload (and refreshed by + // accounts-mutation result broadcasts). null = integration without + // multi-account support → legacy accounts UI. + const [managedAccounts, setManagedAccounts] = useState(null) + // Staged (uncommitted) edits, keyed by integration id. Discarded on every + // modal close path; pruned when identities vanish from refreshed lists. + const [stagedEdits, setStagedEdits] = useState>({}) + const [accountsSaving, setAccountsSaving] = useState(false) + const [accountsError, setAccountsError] = useState('') + // Integration id with an "Add account" OAuth flow in flight. Deliberately + // NOT cleared on modal close (the OAuth flow keeps running server-side and + // can take minutes); cleared only by the matching result broadcast. + const [addingAccountFor, setAddingAccountFor] = useState(null) + // Outstanding request ids WE sent (requestId → integration id). Results are + // broadcast to every client; only ids in these maps may trigger UI + // reactions (toast / spinner clear / staged clear). Foreign results update + // data silently. No wall-clock timers anywhere: entries live until their + // result arrives. + const pendingAddRef = React.useRef>(new Map()) + const pendingApplyRef = React.useRef>(new Map()) + + // Prune staged entries whose identities no longer exist in a refreshed + // account list. A staged primary whose account vanished resets to null, + // i.e. falls back to the real primary. + const pruneStagedFor = useCallback((integrationId: string, accounts: ManagedAccount[]) => { + setStagedEdits(prev => { + const cur = prev[integrationId] + if (!cur) return prev + const ids = new Set(accounts.map(a => a.identity)) + const next: StagedAccountEdits = { + disconnect: cur.disconnect.filter(identity => ids.has(identity)), + primary: cur.primary !== null && ids.has(cur.primary) ? cur.primary : null, + aliases: Object.fromEntries( + Object.entries(cur.aliases).filter(([identity]) => ids.has(identity)), + ), + listen: Object.fromEntries( + Object.entries(cur.listen).filter(([identity]) => ids.has(identity)), + ), + } + if (stagedIsEmpty(next)) { + const { [integrationId]: _gone, ...rest } = prev + return rest + } + return { ...prev, [integrationId]: next } + }) + }, []) + + // Apply a fresh account list from any source (our result, foreign + // broadcast). Updates the open modal's data if it shows this integration; + // never opens anything. + const refreshManagedAccounts = useCallback((integrationId: string, accounts: ManagedAccount[]) => { + const current = managingIntegrationRef.current + if (current && current.id === integrationId) { + setManagedAccounts(accounts) + } + pruneStagedFor(integrationId, accounts) + }, [pruneStagedFor]) + + // Single close path for the Manage modal — every way of closing it (X, + // overlay click, disconnect flows) goes through here so staged edits are + // always discarded. + const closeManageModal = useCallback(() => { + setShowManageModal(false) + setManagingIntegration(null) + setManagedAccounts(null) + setAccountsSaving(false) + setAccountsError('') + setStagedEdits({}) + }, []) // Slow operation overlay — shown during long disconnects (WhatsApp Web's // bridge teardown can take 20–30 seconds; without this the user has no @@ -419,6 +720,28 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // Confirm modal const { modalProps: confirmModalProps, confirm } = useConfirmModal() + // User-gesture close path (X button, overlay click): staged edits are + // real unsent work — closing silently threw them away in the live bug + // (typed alias lost with no warning). Ask first when dirty. Programmatic + // closes (disconnect flows, disconnect_result) still use closeManageModal + // directly: their outcome supersedes any staged edits. + const requestCloseManage = () => { + const dirty = managingIntegration + ? stagedEdits[managingIntegration.id] + : undefined + if (managingIntegration && dirty && !stagedIsEmpty(dirty)) { + confirm({ + title: 'Discard unsaved changes?', + message: `Your account changes for ${managingIntegration.name} haven't been saved yet.`, + confirmText: 'Discard', + cancelText: 'Keep editing', + variant: 'danger', + }, closeManageModal) + return + } + closeManageModal() + } + // Subscribe to side-effect messages (toasts, modal close). The integrations // list itself is updated by the slice via the registry. useEffect(() => { @@ -450,6 +773,8 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool setConnectError('') const just = selectedIntegrationRef.current if (just && just.has_config && (just.config_fields?.length ?? 0) > 0) { + // Deliberate modal open: follow-up to the user's own connect. + manageRequestedRef.current = true send('integration_info', { id: just.id }) } } else { @@ -463,28 +788,105 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool setPendingOp(prev => (prev && d.id && prev.id === d.id) ? null : prev) if (d.success) { showToast('success', d.message || 'Disconnected successfully') - setShowManageModal(false) - setManagingIntegration(null) + closeManageModal() } else { showToast('error', d.error || 'Failed to disconnect') } }), onMessage('integration_info', (data: unknown) => { - const d = data as { success: boolean; integration?: Integration; error?: string } + const d = data as { + success: boolean + integration?: Integration + // multi-account integrations: real account list (identity, + // alias, isPrimary, listen). Absent for legacy integrations. + accounts?: ManagedAccount[] + error?: string + } if (d.success && d.integration) { - setManagingIntegration(d.integration) - setShowManageModal(true) - // If this integration has runtime config, kick off a fetch so the - // Configure section is populated by the time the user scrolls to it. - if (d.integration.has_config) { - setConfigLoading(true) - setConfigValues({}) - send('integration_get_config', { id: d.integration.id }) + if (manageRequestedRef.current) { + // Response to OUR explicit request (Manage click / post-connect + // follow-up) — the only path that may OPEN the modal. + manageRequestedRef.current = false + setManagingIntegration(d.integration) + setShowManageModal(true) + setManagedAccounts(d.accounts ?? null) + if (d.accounts) pruneStagedFor(d.integration.id, d.accounts) + // If this integration has runtime config, kick off a fetch so the + // Configure section is populated by the time the user scrolls to it. + if (d.integration.has_config) { + setConfigLoading(true) + setConfigValues({}) + send('integration_get_config', { id: d.integration.id }) + } + } else if (managingIntegrationRef.current?.id === d.integration.id) { + // Unsolicited info for the integration already on screen — + // refresh the data silently. Never opens the modal. A payload + // WITHOUT ``accounts`` (transient v2 lookup failure server-side) + // must not null out an active AccountsManager: that would swap + // the whole section to the legacy view mid-edit and hide the + // user's staged changes. Keep the last good list instead. + setManagingIntegration(d.integration) + if (d.accounts) { + setManagedAccounts(d.accounts) + pruneStagedFor(d.integration.id, d.accounts) + } } - } else { + } else if (manageRequestedRef.current) { + manageRequestedRef.current = false showToast('error', d.error || 'Failed to get integration info') } }), + // Result broadcast for "Add account" (real OAuth; can take minutes). + // Broadcast to EVERY client — only requestIds we sent may drive UI + // reactions; foreign results refresh data silently. + onMessage('integration_accounts_add_result', (data: unknown) => { + const d = data as IntegrationAccountsAddResult + const mine = Boolean(d.requestId) && pendingAddRef.current.has(d.requestId) + // Fresh account list benefits everyone, ours or not — but ONLY from + // success payloads. Failure payloads carry a best-effort list that + // may be a fabricated empty array; treating it as authoritative + // would blank the modal and prune (= silently discard) every staged + // edit, including an alias mid-typing. + if (d.ok && d.accounts) refreshManagedAccounts(d.id, d.accounts) + if (!mine) return + pendingAddRef.current.delete(d.requestId) + setAddingAccountFor(prev => (prev === d.id ? null : prev)) + if (d.ok) { + showToast('success', d.message || 'Account added') + } else { + showToast('error', d.message || 'Failed to add account') + } + }), + // Result broadcast for the batched "Save changes" request. + onMessage('integration_apply_account_changes_result', (data: unknown) => { + const d = data as IntegrationApplyAccountChangesResult + const mine = Boolean(d.requestId) && pendingApplyRef.current.has(d.requestId) + if (d.ok && d.accounts) { + if (mine) { + // OUR save succeeded — clear this integration's staged edits + // BEFORE rendering the returned list, so no stale overrides + // shadow the authoritative state. + setStagedEdits(prev => { + const { [d.id]: _gone, ...rest } = prev + return rest + }) + } + refreshManagedAccounts(d.id, d.accounts) + } + if (!mine) return + pendingApplyRef.current.delete(d.requestId) + setAccountsSaving(false) + if (d.ok) { + setAccountsError('') + showToast('success', 'Account changes saved') + } else { + // Failure keeps the staged edits (nothing cleared above) so the + // user can retry; surface the error inline and as a toast. + const msg = d.error || 'Failed to apply account changes' + setAccountsError(msg) + showToast('error', msg) + } + }), // Per-integration runtime config (schema-driven; works for every // integration that declares config_class on its handler). onMessage('integration_config', (data: unknown) => { @@ -541,6 +943,8 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool setWhatsappStatus('idle') const just = selectedIntegrationRef.current if (just && just.has_config && (just.config_fields?.length ?? 0) > 0) { + // Deliberate modal open: follow-up to the user's own connect. + manageRequestedRef.current = true send('integration_info', { id: just.id }) } } else if (d.status === 'error' || d.status === 'disconnected') { @@ -566,7 +970,7 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool } return () => cleanups.forEach(c => c()) - }, [isConnected, send, onMessage, hasLoaded, showToast]) + }, [isConnected, send, onMessage, hasLoaded, showToast, closeManageModal, pruneStagedFor, refreshManagedAccounts]) // Start WhatsApp polling when QR is ready useEffect(() => { @@ -633,9 +1037,125 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool } const handleOpenManage = (integration: Integration) => { + // Explicit user click — the only gesture allowed to open the Manage + // modal. The flag lets the integration_info handler distinguish this + // response from unsolicited broadcasts. + manageRequestedRef.current = true send('integration_info', { id: integration.id }) } + // --- Multi-account staging + requests ------------------------------------ + + // Update one integration's staged edits; drops the entry entirely when it + // becomes a no-op so "has staged changes" stays accurate. + const updateStaged = ( + integrationId: string, + fn: (s: StagedAccountEdits) => StagedAccountEdits, + ) => { + setStagedEdits(prev => { + const next = fn(prev[integrationId] ?? emptyStaged()) + if (stagedIsEmpty(next)) { + const { [integrationId]: _gone, ...rest } = prev + return rest + } + return { ...prev, [integrationId]: next } + }) + } + + const stageAlias = (integrationId: string, account: ManagedAccount, value: string) => { + const alias = value.trim() === '' ? null : value + updateStaged(integrationId, s => { + const aliases = { ...s.aliases } + if (alias === (account.alias ?? null)) { + delete aliases[account.identity] // back to the real value → no-op + } else { + aliases[account.identity] = alias + } + return { ...s, aliases } + }) + } + + const stagePrimary = (integrationId: string, account: ManagedAccount) => { + const realPrimary = managedAccounts?.find(a => a.isPrimary)?.identity ?? null + updateStaged(integrationId, s => ({ + ...s, + // Picking the real primary again = clearing the staged override. + primary: account.identity === realPrimary ? null : account.identity, + })) + } + + const stageListen = (integrationId: string, account: ManagedAccount, value: boolean) => { + updateStaged(integrationId, s => { + const listen = { ...s.listen } + if (value === account.listen) { + delete listen[account.identity] + } else { + listen[account.identity] = value + } + return { ...s, listen } + }) + } + + const stageDisconnect = (integrationId: string, identity: string, marked: boolean) => { + updateStaged(integrationId, s => ({ + ...s, + disconnect: marked + ? (s.disconnect.includes(identity) ? s.disconnect : [...s.disconnect, identity]) + : s.disconnect.filter(i => i !== identity), + })) + } + + // "Add account" — immediate real OAuth, no staging. ``send`` goes through + // the shared SocketClient outbox (queued while disconnected, drained on + // reconnect), so the request is never dropped behind a connection guard. + // The spinner is cleared ONLY by the matching result broadcast — OAuth can + // take minutes and we use no wall-clock timers. + const handleAddAccount = () => { + if (!managingIntegration) return + const requestId = crypto.randomUUID() + pendingAddRef.current.set(requestId, managingIntegration.id) + setAddingAccountFor(managingIntegration.id) + send('integration_accounts_add', { + integration_id: managingIntegration.id, + request_id: requestId, + }) + } + + // One batched save for all staged edits. Same queued transport as above. + // Edits referring to accounts that are ALSO marked for disconnect are + // stripped from the payload: the backend applies disconnects first, so a + // stale alias/listen/primary entry for a removed identity would make the + // whole batch fail resolution. (The staged entries themselves are kept + // until the result arrives, so an Undo before save loses nothing.) + const handleSaveAccountChanges = () => { + if (!managingIntegration) return + const staged = stagedEdits[managingIntegration.id] + if (!staged || stagedIsEmpty(staged)) return + const requestId = crypto.randomUUID() + const removing = new Set(staged.disconnect) + const changes: AccountChanges = { + disconnect: staged.disconnect, + primary: + staged.primary !== null && removing.has(staged.primary) + ? null + : staged.primary, + aliases: Object.fromEntries( + Object.entries(staged.aliases).filter(([identity]) => !removing.has(identity)), + ), + listen: Object.fromEntries( + Object.entries(staged.listen).filter(([identity]) => !removing.has(identity)), + ), + } + pendingApplyRef.current.set(requestId, managingIntegration.id) + setAccountsSaving(true) + setAccountsError('') + send('integration_apply_account_changes', { + integration_id: managingIntegration.id, + request_id: requestId, + changes, + }) + } + const handleConnectToken = () => { if (!selectedIntegration) return setIsConnecting(true) @@ -679,8 +1199,7 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // ``integration_disconnect_result`` shows a toast and the next refresh // restores the real state. dispatch(setDisconnected(targetId)) - setShowManageModal(false) - setManagingIntegration(null) + closeManageModal() // Slow disconnects: show a blocking overlay until the result arrives. if (SLOW_DISCONNECT_IDS.has(targetId)) { @@ -1117,17 +1636,43 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool {/* Manage Modal */} {showManageModal && managingIntegration && ( -
setShowManageModal(false)}> +
e.stopPropagation()}>

Manage {managingIntegration.name}

-
-

Connected Accounts

- {managingIntegration.accounts.length === 0 ? ( +

Connected accounts

+ {managedAccounts !== null ? ( + /* multi-account manager — staged edits, one batched save */ + + stageAlias(managingIntegration.id, account, value)} + onSetPrimary={account => + stagePrimary(managingIntegration.id, account)} + onListenChange={(account, value) => + stageListen(managingIntegration.id, account, value)} + onToggleDisconnect={(account, marked) => + stageDisconnect(managingIntegration.id, account.identity, marked)} + onAddAccount={handleAddAccount} + onDiscard={() => { + setStagedEdits(prev => { + const { [managingIntegration.id]: _gone, ...rest } = prev + return rest + }) + setAccountsError('') + }} + onSave={handleSaveAccountChanges} + /> + ) : managingIntegration.accounts.length === 0 ? (

No accounts connected

) : (
@@ -1146,10 +1691,18 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool
)} {/* Configure — schema-driven form, only shown for integrations - whose handler declared ``config_class`` + ``config_fields``. */} + whose handler declared ``config_class`` + ``config_fields``. + Boxed into its own section with its own save action, so it + reads as a separate scope from the accounts above (the live + bug: its "Save" was mistaken for the accounts save). */} {managingIntegration.has_config && (managingIntegration.config_fields?.length ?? 0) > 0 && ( - <> -

Configure

+
+
+

Integration settings

+

+ Applies to {managingIntegration.name} as a whole, not to a single account. +

+
{configLoading ? (
@@ -1171,7 +1724,7 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool }} /> )} - +
)}
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..3afd10b9 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 @@ -612,7 +612,8 @@ display: flex; flex-direction: column; gap: var(--space-2); - margin-bottom: var(--space-4); + /* No margin-bottom: modalBody is a flex column with gap, adding a margin + here would double the spacing to the Add-account button. */ } .accountItem { @@ -629,6 +630,188 @@ color: var(--text-primary); } +/* --- Multi-account manager cards (integrations-v2 Manage modal) --------- + One card per account. The EMAIL/identity is the primary line (it's the + account's real name); the alias is a proper labeled input below it. */ + +.accountCard { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + transition: border-color var(--transition-fast), opacity var(--transition-fast); +} + +/* Card staged for disconnect: dimmed, red-tinted, struck-through identity. + Purely visual — nothing is removed until "Save changes". */ +.accountCardRemoving { + opacity: 0.65; + border-color: rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.05); +} + +.accountCardRemoving .accountEmail { + text-decoration: line-through; +} + +.accountCardHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.accountCardIdentity { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 0; +} + +.accountEmail { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Quiet text action on non-primary rows. */ +.setPrimaryAction { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding: 2px var(--space-1); + background: transparent; + border: none; + border-radius: var(--radius-sm); + font-size: var(--text-xs); + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition-fast); +} + +.setPrimaryAction:hover:not(:disabled) { + color: var(--text-primary); + background: var(--bg-hover); +} + +.setPrimaryAction:disabled { + opacity: 0.5; + cursor: default; +} + +/* Icon-only ghost disconnect: quiet at rest, red on hover. */ +.disconnectGhost:hover { + color: var(--color-red); +} + +.accountRemovalNote { + margin: 0; + font-size: var(--text-xs); + color: var(--color-red); +} + +.accountCardControls { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-3); +} + +.accountAliasField { + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1; + min-width: 0; + max-width: 260px; +} + +.accountAliasField label { + font-size: var(--text-xs); + font-weight: var(--font-medium); + color: var(--text-secondary); +} + +/* Real input affordance (border + background), matching .formGroup input. */ +.accountAliasInput { + padding: var(--space-1) var(--space-2); + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--text-primary); + min-width: 0; + transition: border-color var(--transition-fast); +} + +.accountAliasInput:focus { + outline: none; + border-color: var(--border-hover); +} + +.accountAliasInput::placeholder { + color: var(--text-muted); +} + +.accountListenLabel { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; + padding-bottom: var(--space-1); + font-size: var(--text-xs); + color: var(--text-secondary); + cursor: pointer; +} + +/* Dirty-state save bar: only rendered while staged edits exist. */ +.accountsSaveBar { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-primary-subtle); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); +} + +.accountsSaveHint { + margin-right: auto; + font-size: var(--text-xs); + color: var(--text-secondary); +} + +/* Configure section: boxed sub-scope with its own heading + save, visually + separate from the account cards so its save button can't be mistaken for + the accounts "Save changes". */ +.configSection { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); +} + +.configSectionHeader { + display: flex; + flex-direction: column; + gap: 2px; +} + +.configSectionDesc { + margin: 0; + font-size: var(--text-xs); + color: var(--text-muted); +} + /* Danger Zone */ .dangerZone { margin-top: var(--space-6); diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/types.ts b/app/ui_layer/browser/frontend/src/pages/Settings/types.ts index dc6ac58c..194263fb 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/types.ts +++ b/app/ui_layer/browser/frontend/src/pages/Settings/types.ts @@ -26,6 +26,58 @@ export interface SettingsCategoryItem { icon: React.ReactNode } +// --- Multi-account integrations (Manage modal) ------------ + +// One account row in a multi-account integration's ``integration_info`` +// payload (and in the accounts-mutation result broadcasts). +export interface ManagedAccount { + identity: string + alias: string | null + isPrimary: boolean + listen: boolean +} + +// Locally staged (uncommitted) edits for one integration's accounts. +// Keyed by integration id in component state; committed as a single +// ``integration_apply_account_changes`` request on "Save changes". +export interface StagedAccountEdits { + // Identities marked for disconnect on save. + disconnect: string[] + // Staged new primary identity; null = keep the real primary. + primary: string | null + // Staged alias overrides, keyed by identity (null clears the alias). + aliases: Record + // Staged listen-flag overrides, keyed by identity. + listen: Record +} + +// ``changes`` payload of an ``integration_apply_account_changes`` request. +export interface AccountChanges { + disconnect: string[] + primary: string | null + aliases: Record + listen: Record +} + +// Result broadcast for ``integration_accounts_add``. Broadcast to every +// connected client — correlate by requestId before treating as your own. +export interface IntegrationAccountsAddResult { + id: string + requestId: string + ok: boolean + message?: string + accounts?: ManagedAccount[] +} + +// Result broadcast for ``integration_apply_account_changes``. +export interface IntegrationApplyAccountChangesResult { + id: string + requestId: string + ok: boolean + accounts?: ManagedAccount[] + error?: string +} + export const categories: SettingsCategoryItem[] = [ { id: 'general', diff --git a/app/ui_layer/commands/builtin/cred.py b/app/ui_layer/commands/builtin/cred.py index ca3687ee..0c07119f 100644 --- a/app/ui_layer/commands/builtin/cred.py +++ b/app/ui_layer/commands/builtin/cred.py @@ -76,12 +76,35 @@ async def execute( ) async def _list_credentials(self) -> CommandResult: - """List all configured credentials.""" + """List all configured credentials. + + multi-account provider ids read connection state (and accounts) from the + IntegrationSystem; everything else keeps the legacy check. + """ + from app.data.action.integrations._helpers import system_for + lines = ["Configured credentials:", ""] for name in get_all_handlers(): - connected = is_connected(name) - lines.append(f" {name}: {'connected' if connected else 'not connected'}") + system = system_for(name) + if system is not None: + try: + accounts = system.list_accounts(name) + except Exception: + accounts = [] + if accounts: + label = ", ".join(a.alias or a.identity for a in accounts) + lines.append( + f" {name}: connected ({len(accounts)} account" + f"{'s' if len(accounts) != 1 else ''}: {label})" + ) + else: + lines.append(f" {name}: not connected") + else: + connected = is_connected(name) + lines.append( + f" {name}: {'connected' if connected else 'not connected'}" + ) return CommandResult(success=True, message="\n".join(lines)) diff --git a/craftos_integrations/contracts.py b/craftos_integrations/contracts.py new file mode 100644 index 00000000..a7cd63f3 --- /dev/null +++ b/craftos_integrations/contracts.py @@ -0,0 +1,212 @@ +"""The integrations system — the complete host/provider boundary. + +Every type that crosses between a host application, the core, and a +provider plugin lives here. Providers implement ``Provider``; hosts +implement ``CredentialStore`` / ``OAuthTransport`` / ``EventSink`` (or use +the defaults in ``core/``). Nothing in ``craftos_integrations`` may import +from a host application — see tests/integrations/test_isolation.py. + +Design reference: docs/plans/multi-account-v2-plan.md +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import ( + Any, + Awaitable, + Callable, + ContextManager, + Dict, + List, + Mapping, + Optional, + Protocol, + Sequence, + Tuple, + runtime_checkable, +) + +# Sentinel identity for credentials saved before identity capture existed +# (old LinkedIn/Notion files). Upgraded in place on the next successful +# re-auth — never duplicated into a second account. +LEGACY_IDENTITY = "legacy" + + +class AccountResolutionError(Exception): + """An ``account`` hint could not be resolved to a connected account. + + Messages are written for an LLM to self-correct from: they always + enumerate the valid choices ("No gmail account matches 'x'. + Connected: a@… (work), b@…"). + """ + + +@dataclass(frozen=True) +class AccountInfo: + """UI/agent-facing view of one connected account.""" + + identity: str + alias: Optional[str] + is_primary: bool + listen: bool + added_at: str + + @property + def display(self) -> str: + return self.alias or self.identity + + +@dataclass(frozen=True) +class OAuthSpec: + """Declarative OAuth parameters for one provider. + + ``extra_authorize_params`` is where account-chooser params live + (e.g. Google's ``prompt=consent select_account``). ``has_chooser=False`` + is an explicit declaration that the provider's OAuth has no chooser + (LinkedIn) — the conformance suite requires one or the other, so a + missing chooser param is always a decision, never an oversight. + """ + + authorize_url: str + token_url: str + scopes: Tuple[str, ...] = () + extra_authorize_params: Mapping[str, str] = field(default_factory=dict) + has_chooser: bool = True + + +@dataclass(frozen=True) +class Operation: + """A framework-neutral action: hosts turn these into agent tools. + + ``input_schema`` must NOT contain an ``account`` key — account + selection is injected centrally by the host adapter and resolved by + ``IntegrationSystem.execute()``; operations receive a ready client. + ``destructive`` lets hosts add confirm-or-clarify behavior uniformly. + """ + + name: str + description: str + input_schema: Dict[str, Any] + output_schema: Dict[str, Any] + fn: Callable[[Any, Dict[str, Any]], Awaitable[Dict[str, Any]]] + destructive: bool = False + parallelizable: bool = True + tags: Tuple[str, ...] = () + + +@runtime_checkable +class Listener(Protocol): + """One inbound event source instance for one (provider, account).""" + + async def start(self) -> None: ... + + async def stop(self) -> None: ... + + def cursor(self) -> Optional[Dict[str, Any]]: + """Current poll/dedup state, persisted per account across restarts.""" + ... + + +@runtime_checkable +class Provider(Protocol): + """What an integration plugin implements. Host-blind by contract.""" + + id: str + family: Optional[str] # e.g. "google" — aliases shared across the family + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Provider-stable key for the human account (email/workspace id/…). + + Returning None means the credential predates identity capture; the + core stores it under LEGACY_IDENTITY.""" + ... + + def oauth_spec(self) -> OAuthSpec: ... + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + """Build an API client bound to this one account's credential. + + ``persist`` must be called with the updated credential dict whenever + the client refreshes tokens internally — the system routes it to the + right account entry (a locked single-entry write). Clients must + never write credential files themselves.""" + ... + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Return a refreshed credential dict, or None if non-expiring.""" + ... + + def operations(self) -> List[Operation]: ... + + def guidance(self) -> str: ... + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> Optional[Listener]: + """Per-account listener instance, or None if no inbound events. + + ``emit`` is an already-account-bound async callable — the listener + calls it with each event payload dict, and the core routes it to + ``EventSink.on_event(provider_id, identity, payload)``. Listeners + never know which account they serve.""" + ... + + +# ════════════════════════════════════════════════════════════════════════ +# Host-implemented contracts +# ════════════════════════════════════════════════════════════════════════ + + +class CredentialStore(Protocol): + """Where AccountSet documents persist. Implementations must make + ``replace`` atomic and ``locked`` a real mutual-exclusion boundary.""" + + def load(self, provider_id: str) -> Optional[Dict[str, Any]]: ... + + def replace(self, provider_id: str, data: Dict[str, Any]) -> None: ... + + def delete(self, provider_id: str) -> None: ... + + def locked(self, provider_id: str) -> ContextManager[None]: ... + + def load_legacy(self, provider_id: str) -> Optional[Dict[str, Any]]: + """Bare single-account credential from a pre-multi-account install, if any. + + Read exactly once per provider by the one-time upgrade migration + (``IntegrationSystem._migrate_legacy``: legacy file present, no + AccountSet document). Stores may additionally offer ``has_document`` and + ``delete_legacy`` (both optional, detected via hasattr) — the + latter lets the system delete the legacy file when the last + account is removed, so the migration cannot resurrect it.""" + ... + + +class OAuthTransport(Protocol): + """How an authorize redirect/callback physically happens for this host.""" + + async def authorize(self, url: str) -> Dict[str, str]: + """Send the user to ``url``; return the callback query params.""" + ... + + +class EventSink(Protocol): + """Where listener events go — the host's trigger system.""" + + async def on_event( + self, provider_id: str, identity: str, event: Dict[str, Any] + ) -> None: ... + + +class FamilyLookup(Protocol): + """Maps a provider id to every provider id sharing its alias family + (including itself). The registry implements this; tests fake it.""" + + def __call__(self, provider_id: str) -> Sequence[str]: ... diff --git a/craftos_integrations/core/__init__.py b/craftos_integrations/core/__init__.py new file mode 100644 index 00000000..086e8540 --- /dev/null +++ b/craftos_integrations/core/__init__.py @@ -0,0 +1,25 @@ +"""Integrations core — host-agnostic account/storage/registry machinery. + +Public surface: + + from craftos_integrations.core import ( + AccountManager, FileCredentialStore, IntegrationRegistry, IntegrationSystem, + ) +""" + +from .accounts import AccountManager, AccountRecord, AccountSet +from .listeners import FileCursorStore, ListenerManager +from .registry import IntegrationRegistry +from .storage import FileCredentialStore +from .system import IntegrationSystem + +__all__ = [ + "AccountManager", + "AccountRecord", + "AccountSet", + "FileCredentialStore", + "FileCursorStore", + "IntegrationRegistry", + "IntegrationSystem", + "ListenerManager", +] diff --git a/craftos_integrations/core/accounts.py b/craftos_integrations/core/accounts.py new file mode 100644 index 00000000..43536fef --- /dev/null +++ b/craftos_integrations/core/accounts.py @@ -0,0 +1,485 @@ +"""AccountSet model and every multi-account mutation/resolution rule. + +One AccountSet document per provider: + + {"version": 2, + "primary": "a@x.com", + "accounts": { + "a@x.com": {"credential": {...}, "alias": "work", "listen": true, + "added_at": "...", "alias_updated_at": "..."}, + ...}} + +Invariants (hold through crashes — every mutation is one atomic replace +under the store lock): + - ``primary`` always points at an existing account; a dangling pointer + is repaired on load (oldest account wins, logged). + - Aliases live inside the account record; they die with the account. + - Identities are stored lowercase; all comparison is case-insensitive. + +Resolution contract (agents and UI both) — see AccountResolutionError +messages, which always enumerate valid choices so an LLM self-corrects: + 1. empty hint → primary + 2. exact identity match (identity always outranks alias) + 3. exact alias match + 4. unique substring of identity or alias + 5. ambiguous substring → error listing candidates + 6. no match → error listing connected accounts +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +from ..contracts import ( + AccountInfo, + AccountResolutionError, + CredentialStore, + LEGACY_IDENTITY, +) +from ..logger import get_logger + +logger = get_logger(__name__) + +_VERSION = 2 + + +def _utcnow() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class AccountRecord: + credential: Dict[str, Any] + alias: Optional[str] = None + listen: bool = True + added_at: str = "" + alias_updated_at: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "credential": self.credential, + "alias": self.alias, + "listen": self.listen, + "added_at": self.added_at, + "alias_updated_at": self.alias_updated_at, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AccountRecord": + return cls( + credential=data.get("credential") or {}, + alias=data.get("alias"), + listen=bool(data.get("listen", True)), + added_at=data.get("added_at") or "", + alias_updated_at=data.get("alias_updated_at") or "", + ) + + +@dataclass +class AccountSet: + primary: str + accounts: Dict[str, AccountRecord] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "version": _VERSION, + "primary": self.primary, + "accounts": {i: r.to_dict() for i, r in self.accounts.items()}, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AccountSet": + # Tolerant of unknown keys by construction: only the fields named + # here are read. Documents written during the interim legacy-bridge + # era carry a ``legacy_coupled`` flag — ignored, no longer drives + # any behavior. + return cls( + primary=data.get("primary") or "", + accounts={ + i: AccountRecord.from_dict(r) + for i, r in (data.get("accounts") or {}).items() + }, + ) + + def oldest_identity(self) -> Optional[str]: + if not self.accounts: + return None + return min(self.accounts, key=lambda i: (self.accounts[i].added_at, i)) + + +class AccountManager: + """All AccountSet reads/mutations. Pure w.r.t. providers: identities are + computed by the caller (system layer) and passed in explicitly.""" + + def __init__( + self, + store: CredentialStore, + family_members: Optional[Callable[[str], Sequence[str]]] = None, + clock: Callable[[], str] = _utcnow, + ) -> None: + self._store = store + self._family = family_members or (lambda pid: (pid,)) + self._clock = clock + + # ──────────────────────────────────────────────────────────────────── + # Loading & migration + # ──────────────────────────────────────────────────────────────────── + + def load_set(self, provider_id: str) -> Optional[AccountSet]: + """Load and repair invariants. + + Pre-multi-account single-credential files are deliberately IGNORED here: the + manager only reads AccountSet documents. The one-time upgrade + migration — legacy file present, no AccountSet document — happens above + this layer in ``IntegrationSystem._migrate_legacy``, which can + derive a real identity from the provider. + """ + raw = self._store.load(provider_id) + if raw is None: + return None + account_set = AccountSet.from_dict(raw) + if self._repair(provider_id, account_set): + with self._store.locked(provider_id): + self._store.replace(provider_id, account_set.to_dict()) + return account_set if account_set.accounts else None + + def _repair(self, provider_id: str, account_set: AccountSet) -> bool: + """Re-point a dangling primary. Returns True if anything changed.""" + if account_set.primary in account_set.accounts: + return False + if not account_set.accounts: + return False + oldest = account_set.oldest_identity() + logger.warning( + f"[ACCOUNTS] {provider_id} primary pointer was dangling " + f"({account_set.primary!r}); repaired to {oldest!r}" + ) + account_set.primary = oldest or "" + return True + + # ──────────────────────────────────────────────────────────────────── + # Reads + # ──────────────────────────────────────────────────────────────────── + + def list_accounts(self, provider_id: str) -> List[AccountInfo]: + account_set = self.load_set(provider_id) + if account_set is None: + return [] + infos = [ + AccountInfo( + identity=identity, + alias=record.alias, + is_primary=identity == account_set.primary, + listen=record.listen, + added_at=record.added_at, + ) + for identity, record in account_set.accounts.items() + ] + infos.sort(key=lambda a: (not a.is_primary, a.added_at, a.identity)) + return infos + + def resolve(self, provider_id: str, hint: Optional[str]) -> str: + account_set = self.load_set(provider_id) + if account_set is None: + raise AccountResolutionError(f"{provider_id} is not connected.") + if hint is None or (isinstance(hint, str) and not hint.strip()): + return account_set.primary + if not isinstance(hint, str): + raise AccountResolutionError( + f"account must be a string (email, alias, or unique fragment), " + f"got {type(hint).__name__}. " + + self._connected_summary(provider_id, account_set) + ) + needle = hint.strip().lower() + + # 1. exact identity — always outranks alias, so an alias can never + # shadow another account's real identity + for identity in account_set.accounts: + if identity.lower() == needle: + return identity + # 2. exact alias (uniqueness enforced at set_alias time) + for identity, record in account_set.accounts.items(): + if record.alias and record.alias.lower() == needle: + return identity + # 3. unique substring of identity or alias + matches = [ + identity + for identity, record in account_set.accounts.items() + if needle in identity.lower() + or (record.alias and needle in record.alias.lower()) + ] + if len(matches) == 1: + return matches[0] + if matches: + listed = ", ".join( + self._describe(i, account_set.accounts[i]) for i in sorted(matches) + ) + raise AccountResolutionError( + f"'{hint}' matches multiple {provider_id} accounts: {listed}. " + f"Use the full email/identity or the exact alias." + ) + raise AccountResolutionError( + f"No {provider_id} account matches '{hint}'. " + + self._connected_summary(provider_id, account_set) + ) + + def credential_for(self, provider_id: str, identity: str) -> Dict[str, Any]: + account_set = self.load_set(provider_id) + if account_set is None or identity not in account_set.accounts: + raise AccountResolutionError( + f"{provider_id} account '{identity}' is no longer connected." + ) + return account_set.accounts[identity].credential + + @staticmethod + def _describe(identity: str, record: AccountRecord) -> str: + return f"{identity} ({record.alias})" if record.alias else identity + + def _connected_summary(self, provider_id: str, account_set: AccountSet) -> str: + listed = ", ".join( + self._describe(i, r) for i, r in sorted(account_set.accounts.items()) + ) + return f"Connected {provider_id} accounts: {listed}." + + # ──────────────────────────────────────────────────────────────────── + # Mutations — each is one locked read-modify-replace + # ──────────────────────────────────────────────────────────────────── + + def upsert_account( + self, + provider_id: str, + identity: Optional[str], + credential: Dict[str, Any], + ) -> str: + """Add or update an account after OAuth. Returns the stored identity. + + A LEGACY_IDENTITY record is upgraded in place by the first re-auth + (same credential slot, alias/listen/primary preserved) — the one + deliberate heuristic in this file: we cannot know whether a pre-multi-account + credential belongs to the account that just authenticated, and + upgrading beats duplicating (see plan §5).""" + if not identity: + raise ValueError( + f"{provider_id}: refusing to store a credential without an " + f"identity — the account would be unaddressable. Providers " + f"must re-prompt instead." + ) + identity = identity.strip().lower() + now = self._clock() + with self._store.locked(provider_id): + raw = self._store.load(provider_id) + account_set = AccountSet.from_dict(raw) if raw else AccountSet(primary="") + if identity in account_set.accounts: + account_set.accounts[identity].credential = credential + elif LEGACY_IDENTITY in account_set.accounts: + legacy = account_set.accounts.pop(LEGACY_IDENTITY) + legacy.credential = credential + account_set.accounts[identity] = legacy + if account_set.primary == LEGACY_IDENTITY: + account_set.primary = identity + logger.info( + f"[ACCOUNTS] {provider_id}: legacy credential upgraded to " + f"identity {identity}" + ) + else: + account_set.accounts[identity] = AccountRecord( + credential=credential, added_at=now + ) + if not account_set.primary: + account_set.primary = identity + self._store.replace(provider_id, account_set.to_dict()) + return identity + + def update_credential( + self, provider_id: str, identity: str, credential: Dict[str, Any] + ) -> None: + """Token-refresh write path: touches exactly one account entry.""" + with self._store.locked(provider_id): + raw = self._store.load(provider_id) + if raw is None: + return + account_set = AccountSet.from_dict(raw) + record = account_set.accounts.get(identity) + if record is None: + logger.warning( + f"[ACCOUNTS] refresh for unknown {provider_id} account " + f"{identity}; dropped" + ) + return + record.credential = credential + self._store.replace(provider_id, account_set.to_dict()) + + def remove_account(self, provider_id: str, hint: Optional[str]) -> str: + """Remove one account; promotes the oldest remaining if the primary + was removed; deletes the document when the last account goes. + Raises AccountResolutionError (no side effects) on a bad hint.""" + identity = self.resolve(provider_id, hint) + with self._store.locked(provider_id): + raw = self._store.load(provider_id) + if raw is None: + return identity + account_set = AccountSet.from_dict(raw) + if identity not in account_set.accounts: + return identity + del account_set.accounts[identity] + if not account_set.accounts: + self._store.delete(provider_id) + return identity + if account_set.primary == identity: + account_set.primary = account_set.oldest_identity() or "" + logger.info( + f"[ACCOUNTS] {provider_id}: removed primary {identity}; " + f"promoted {account_set.primary}" + ) + self._store.replace(provider_id, account_set.to_dict()) + return identity + + def set_primary(self, provider_id: str, hint: Optional[str]) -> str: + identity = self.resolve(provider_id, hint) + with self._store.locked(provider_id): + raw = self._store.load(provider_id) + if raw is None: + raise AccountResolutionError(f"{provider_id} is not connected.") + account_set = AccountSet.from_dict(raw) + if identity not in account_set.accounts: + raise AccountResolutionError( + f"{provider_id} account '{identity}' is no longer connected." + ) + account_set.primary = identity + self._store.replace(provider_id, account_set.to_dict()) + return identity + + def set_listening(self, provider_id: str, hint: Optional[str], on: bool) -> str: + identity = self.resolve(provider_id, hint) + with self._store.locked(provider_id): + raw = self._store.load(provider_id) + if raw is None: + raise AccountResolutionError(f"{provider_id} is not connected.") + account_set = AccountSet.from_dict(raw) + record = account_set.accounts.get(identity) + if record is None: + raise AccountResolutionError( + f"{provider_id} account '{identity}' is no longer connected." + ) + record.listen = on + self._store.replace(provider_id, account_set.to_dict()) + return identity + + # ──────────────────────────────────────────────────────────────────── + # Aliases — family-aware + # ──────────────────────────────────────────────────────────────────── + + def set_alias( + self, provider_id: str, hint: Optional[str], alias: Optional[str] + ) -> str: + """Set (or clear, alias=None) an alias; propagates to the same + identity across the provider's family. Enforces family-wide + uniqueness and forbids aliases that equal any connected identity + (they could never win resolution anyway — rule 2 outranks them).""" + identity = self.resolve(provider_id, hint) + if alias is not None: + alias = alias.strip() + if not alias: + alias = None + family = list(self._family(provider_id)) + if alias is not None: + self._check_alias_free(provider_id, family, alias, identity) + now = self._clock() + # Ordered locking (sorted pids) so two concurrent family-wide writes + # can't deadlock; per-file partial failure is healed by + # sync_family_aliases() on the next list_accounts(). + for pid in sorted(set(family) | {provider_id}): + with self._store.locked(pid): + raw = self._store.load(pid) + if raw is None: + continue + account_set = AccountSet.from_dict(raw) + record = account_set.accounts.get(identity) + if record is None: + continue + record.alias = alias + record.alias_updated_at = now + self._store.replace(pid, account_set.to_dict()) + return identity + + def _check_alias_free( + self, provider_id: str, family: Sequence[str], alias: str, identity: str + ) -> None: + needle = alias.lower() + for pid in family: + raw = self._store.load(pid) + if raw is None: + continue + account_set = AccountSet.from_dict(raw) + for other_identity, record in account_set.accounts.items(): + if other_identity == identity: + continue + if other_identity.lower() == needle: + raise ValueError( + f"'{alias}' is another connected account's identity " + f"({other_identity} on {pid}) — pick a different nickname." + ) + if record.alias and record.alias.lower() == needle: + raise ValueError( + f"'{alias}' is already the nickname of {other_identity} " + f"on {pid} — nicknames must be unique." + ) + + def sync_family_aliases(self, provider_id: str) -> None: + """Heal partial family alias writes: for each identity, the alias + with the newest alias_updated_at across the family wins everywhere. + Called from UI-facing paths (list flows), not from resolve().""" + family = sorted(set(self._family(provider_id))) + if len(family) < 2: + return + newest: Dict[str, Tuple[str, Optional[str]]] = {} + sets: Dict[str, AccountSet] = {} + for pid in family: + raw = self._store.load(pid) + if raw is None: + continue + sets[pid] = AccountSet.from_dict(raw) + for identity, record in sets[pid].accounts.items(): + stamp = record.alias_updated_at + if identity not in newest or stamp > newest[identity][0]: + newest[identity] = (stamp, record.alias) + for pid, account_set in sets.items(): + changed = False + for identity, record in account_set.accounts.items(): + stamp, alias = newest.get(identity, ("", None)) + if stamp and (record.alias != alias): + record.alias = alias + record.alias_updated_at = stamp + changed = True + if changed: + with self._store.locked(pid): + self._store.replace(pid, account_set.to_dict()) + + # ──────────────────────────────────────────────────────────────────── + # Batched UI save + # ──────────────────────────────────────────────────────────────────── + + def apply_changes( + self, provider_id: str, batch: Dict[str, Any] + ) -> List[AccountInfo]: + """Apply a staged UI batch in deterministic order: + disconnects → primary → aliases → listen flags. + + ``batch`` = {"disconnect": [hint...], "primary": hint | None, + "aliases": {hint: alias|None}, "listen": {hint: bool}} + + Raises on the first failing step; earlier steps stay applied (each + is individually atomic and valid) and the UI re-renders from the + returned/refetched account list.""" + for hint in batch.get("disconnect") or []: + self.remove_account(provider_id, hint) + if batch.get("primary") is not None: + self.set_primary(provider_id, batch["primary"]) + for hint, alias in (batch.get("aliases") or {}).items(): + self.set_alias(provider_id, hint, alias) + for hint, on in (batch.get("listen") or {}).items(): + self.set_listening(provider_id, hint, bool(on)) + self.sync_family_aliases(provider_id) + return self.list_accounts(provider_id) diff --git a/craftos_integrations/core/listeners.py b/craftos_integrations/core/listeners.py new file mode 100644 index 00000000..bb9b050b --- /dev/null +++ b/craftos_integrations/core/listeners.py @@ -0,0 +1,381 @@ +"""Listener fan-out — one supervised listener per (provider, account). + +``ListenerManager`` owns every inbound-event instance centrally +(multi-account-v2-plan §8): providers only implement +``make_listener(client, cursor, emit)``; the manager decides *which* +instances exist by reconciling desired state (AccountSets × ``listen`` +flags) against running ones, tags every event with its account via the +emit closure, staggers same-provider pollers, isolates crash-loops, and +persists per-account cursors across restarts. + +Host-blind: nothing here imports from a host application. The host wires +``system.listeners = manager`` and the system's mutation paths call +``system.reconcile_listeners()`` so UI changes take effect immediately. +""" + +from __future__ import annotations + +import asyncio +import copy +import json +import os +import stat +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from ..config import ConfigStore +from ..logger import get_logger +from .system import IntegrationSystem +from ..contracts import EventSink, Listener, Provider + +logger = get_logger(__name__) + +PAUSED_STATUS = "listening paused — reconnect to resume" + + +class FileCursorStore: + """Per-account listener cursors, ``/_cursors/.json``. + + Each file is one JSON object ``{identity: cursor_dict}``. Writes are + atomic (tmp + os.replace) so a crash can never tear a file; there is + deliberately no cross-process locking — losing a cursor is harmless + (a poller re-scans and dedups), so locking heroics would buy nothing. + """ + + def __init__(self, root: Optional[Path] = None) -> None: + """``root`` is the credentials directory; cursors live in its + ``_cursors/`` subdirectory. Defaults to the same directory the + default FileCredentialStore uses (resolved lazily — the host sets + ``ConfigStore.project_root`` at startup).""" + self._root = root + + def _dir(self) -> Path: + base = self._root or (ConfigStore.project_root / ".credentials") + path = base / "_cursors" + path.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path, stat.S_IRWXU) + except OSError: + pass + return path + + def _path(self, provider_id: str) -> Path: + return self._dir() / f"{provider_id}.json" + + def load_all(self, provider_id: str) -> Dict[str, Dict[str, Any]]: + path = self._path(provider_id) + if not path.exists(): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e: + # Cursors are disposable dedup state — a bad file is dropped, + # never quarantined; the affected pollers just re-scan. + logger.warning(f"[CURSORS] {path.name} unreadable, ignoring: {e}") + return {} + + def get(self, provider_id: str, identity: str) -> Optional[Dict[str, Any]]: + cursor = self.load_all(provider_id).get(identity) + return cursor if isinstance(cursor, dict) else None + + def set( + self, provider_id: str, identity: str, cursor: Dict[str, Any] + ) -> None: + data = self.load_all(provider_id) + data[identity] = cursor + self._write(provider_id, data) + + def remove(self, provider_id: str, identity: str) -> None: + data = self.load_all(provider_id) + if identity in data: + del data[identity] + self._write(provider_id, data) + + def migrate_legacy(self, provider_id: str, identity: str) -> None: + """Placeholder for legacy single-account cursor migration (§8.3). + + Pre-multi-account listeners kept their poll state inside the host application + (CraftBot's trigger runtime), not in this package — there is no + legacy cursor file here to import, so this is a documented no-op. + If a host has such state, it can subclass and seed the identity's + entry here; a missing cursor is harmless either way (the poller + re-scans and dedups on first cycle). + """ + + def _write(self, provider_id: str, data: Dict[str, Any]) -> None: + path = self._path(provider_id) + # Unique tmp per write: concurrent writers sharing one tmp name race + # on the rename (the loser's os.replace hits ENOENT). + tmp = path.with_suffix(f"{path.suffix}.{uuid.uuid4().hex}.tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + + +@dataclass +class _Instance: + """One supervised listener for one (provider, identity).""" + + provider_id: str + identity: str + listener: Listener + credential: Dict[str, Any] # deepcopy of what it was built with + delay: float = 0.0 # stagger delay before first start + task: Optional[asyncio.Task] = None + state: str = "starting" # starting|running|backoff|paused|stopped + failures: int = 0 # consecutive failures + detail: str = "" + stop_requested: bool = False + + @property + def key(self) -> Tuple[str, str]: + return (self.provider_id, self.identity) + + +class ListenerManager: + """Reconciles, supervises, and isolates per-account listeners. + + ``max_failures`` consecutive crashes disable an instance (state + ``paused``, detail ``PAUSED_STATUS``); a paused instance is rebuilt + only when a later reconcile sees its account's credential change + (re-auth) — plain reconciles leave it paused so a revoked credential + can't crash-loop forever. The extra keyword knobs exist so tests can + run in milliseconds; production uses the defaults. + """ + + def __init__( + self, + system: IntegrationSystem, + sink: EventSink, + cursors: FileCursorStore, + *, + max_failures: int = 5, + backoff_base: float = 1.0, + backoff_cap: float = 60.0, + stagger_default: float = 2.0, + ) -> None: + self.system = system + self.sink = sink + self.cursors = cursors + self.max_failures = max_failures + self._backoff_base = backoff_base + self._backoff_cap = backoff_cap + self._stagger_default = stagger_default + self._instances: Dict[Tuple[str, str], _Instance] = {} + self._lock = asyncio.Lock() + self._stopped = asyncio.Event() + self.loop: Optional[asyncio.AbstractEventLoop] = None + + # ── lifecycle ──────────────────────────────────────────────────────── + + async def start(self) -> None: + """Reconcile to desired state, then hold until ``stop()``.""" + self.loop = asyncio.get_running_loop() + self._stopped.clear() + await self.reconcile() + await self._stopped.wait() + + async def stop(self) -> None: + """Stop every instance, persisting each cursor, and release start().""" + async with self._lock: + for key in list(self._instances): + await self._stop_instance(self._instances.pop(key)) + self._stopped.set() + + async def reconcile(self) -> None: + """Diff desired (provider × listen-true account) vs running. + + Starts exactly the new instances, stops exactly the removed ones, + and restarts any instance whose account credential differs from + the one it was built with (re-auth / token rotation).""" + self.loop = asyncio.get_running_loop() + async with self._lock: + desired: Dict[Tuple[str, str], Provider] = {} + for provider in self.system.providers(): + try: + accounts = self.system.accounts.list_accounts(provider.id) + except Exception as e: + logger.warning( + f"[LISTEN] listing {provider.id} accounts failed: {e}" + ) + continue + for account in accounts: + if account.listen: + desired[(provider.id, account.identity)] = provider + + # Stop instances whose account vanished or stopped listening. + for key in list(self._instances): + if key not in desired: + await self._stop_instance(self._instances.pop(key)) + + # Restart instances whose credential changed underneath them — + # this is also the only path that revives a paused instance. + for key, instance in list(self._instances.items()): + if self._credential_changed(instance): + await self._stop_instance(self._instances.pop(key)) + + # Build the missing ones, staggered per provider. + new_by_provider: Dict[str, List[Tuple[str, str]]] = {} + for key in desired: + if key not in self._instances: + new_by_provider.setdefault(key[0], []).append(key) + for provider_id, keys in new_by_provider.items(): + started: List[_Instance] = [] + for _, identity in sorted(keys): + instance = self._build_instance( + desired[(provider_id, identity)], identity + ) + if instance is not None: + started.append(instance) + count = len(started) + for k, instance in enumerate(started): + instance.delay = self._stagger_delay(instance, k, count) + self._instances[instance.key] = instance + instance.task = asyncio.create_task( + self._supervise(instance), + name=f"listener:{provider_id}:{instance.identity}", + ) + + def status(self) -> Dict[str, Dict[str, Any]]: + """Per-instance state, keyed ``":"``.""" + return { + f"{i.provider_id}:{i.identity}": { + "state": i.state, + "failures": i.failures, + "detail": i.detail, + "delay": i.delay, + } + for i in self._instances.values() + } + + # ── instance machinery ─────────────────────────────────────────────── + + def _credential_changed(self, instance: _Instance) -> bool: + try: + current = self.system.accounts.credential_for( + instance.provider_id, instance.identity + ) + except Exception: + return True # account gone mid-flight; reconcile drops it next + return current != instance.credential + + def _build_instance( + self, provider: Provider, identity: str + ) -> Optional[_Instance]: + provider_id = provider.id + try: + credential = copy.deepcopy( + self.system.accounts.credential_for(provider_id, identity) + ) + client = self.system.client_for(provider_id, identity) + cursor = self.cursors.get(provider_id, identity) + if cursor is None: + self.cursors.migrate_legacy(provider_id, identity) + cursor = self.cursors.get(provider_id, identity) + + async def emit(event: Dict[str, Any]) -> None: + await self.sink.on_event(provider_id, identity, event) + + listener = provider.make_listener(client, cursor, emit) + if listener is None: + return None + return _Instance( + provider_id=provider_id, + identity=identity, + listener=listener, + credential=credential, + ) + except Exception as e: + logger.warning( + f"[LISTEN] building {provider_id}/{identity} listener failed: {e}" + ) + return None + + def _stagger_delay(self, instance: _Instance, k: int, count: int) -> float: + if k == 0: + return 0.0 + interval = getattr(instance.listener, "poll_interval", None) + if isinstance(interval, (int, float)) and interval > 0 and count > 0: + return k * (float(interval) / count) + return k * self._stagger_default + + async def _supervise(self, instance: _Instance) -> None: + """Run listener.start() forever with backoff; pause on crash-loop.""" + try: + if instance.delay > 0: + await asyncio.sleep(instance.delay) + backoff = self._backoff_base + while not instance.stop_requested: + instance.state = "running" + try: + await instance.listener.start() + except asyncio.CancelledError: + raise + except Exception as e: + instance.failures += 1 + instance.detail = str(e) + if instance.failures >= self.max_failures: + instance.state = "paused" + instance.detail = PAUSED_STATUS + logger.warning( + f"[LISTEN] {instance.provider_id}/{instance.identity} " + f"failed {instance.failures}x; {PAUSED_STATUS}" + ) + return + instance.state = "backoff" + await asyncio.sleep(backoff) + backoff = min(backoff * 2, self._backoff_cap) + continue + # Clean return = one successful cycle. + self._persist_cursor(instance) + instance.failures = 0 + instance.detail = "" + backoff = self._backoff_base + if instance.stop_requested: + return + instance.state = "idle" + await asyncio.sleep(self._backoff_base) + except asyncio.CancelledError: + pass + finally: + if instance.stop_requested: + instance.state = "stopped" + + async def _stop_instance(self, instance: _Instance) -> None: + instance.stop_requested = True + try: + await instance.listener.stop() + except Exception as e: + logger.warning( + f"[LISTEN] stopping {instance.provider_id}/" + f"{instance.identity} raised: {e}" + ) + if instance.task is not None and not instance.task.done(): + instance.task.cancel() + try: + await instance.task + except (asyncio.CancelledError, Exception): + pass + self._persist_cursor(instance) + instance.state = "stopped" + + def _persist_cursor(self, instance: _Instance) -> None: + try: + cursor = instance.listener.cursor() + if cursor is not None: + self.cursors.set(instance.provider_id, instance.identity, cursor) + except Exception as e: + logger.warning( + f"[LISTEN] persisting {instance.provider_id}/" + f"{instance.identity} cursor failed: {e}" + ) diff --git a/craftos_integrations/core/registry.py b/craftos_integrations/core/registry.py new file mode 100644 index 00000000..172b1265 --- /dev/null +++ b/craftos_integrations/core/registry.py @@ -0,0 +1,69 @@ +"""Provider registry + per-account client instance cache. + +Cache keys are ``(provider_id, resolved_identity)`` — resolution happens +BEFORE the cache (in IntegrationSystem), so alias spellings share one +client, bad hints never pollute the cache, and cache size is bounded by +real accounts. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from ..contracts import Provider +from ..logger import get_logger + +logger = get_logger(__name__) + + +class IntegrationRegistry: + def __init__(self) -> None: + self._providers: Dict[str, Provider] = {} + self._clients: Dict[Tuple[str, str], Any] = {} + + # ── providers ──────────────────────────────────────────────────────── + + def register(self, provider: Provider) -> None: + if provider.id in self._providers: + raise ValueError(f"Provider '{provider.id}' registered twice") + self._providers[provider.id] = provider + + def get(self, provider_id: str) -> Optional[Provider]: + return self._providers.get(provider_id) + + def all_providers(self) -> List[Provider]: + return list(self._providers.values()) + + def family_members(self, provider_id: str) -> Sequence[str]: + """Every provider id sharing this provider's alias family, + including itself. Providers with family=None are their own family.""" + provider = self._providers.get(provider_id) + if provider is None or not provider.family: + return (provider_id,) + return tuple( + pid for pid, p in self._providers.items() if p.family == provider.family + ) + + # ── client instance cache ──────────────────────────────────────────── + + def get_cached_client(self, provider_id: str, identity: str) -> Optional[Any]: + return self._clients.get((provider_id, identity)) + + def cache_client(self, provider_id: str, identity: str, client: Any) -> None: + self._clients[(provider_id, identity)] = client + + def invalidate(self, provider_id: str, identity: Optional[str] = None) -> None: + """Drop cached clients so the next use rebuilds from disk. With no + identity, drops every account's client for the provider. Alias and + primary changes re-point routing, so their cached resolutions must + die immediately (issue #314 class).""" + if identity is not None: + self._clients.pop((provider_id, identity), None) + return + for key in [k for k in self._clients if k[0] == provider_id]: + self._clients.pop(key, None) + + def reset(self) -> None: + """Testing: drop all providers and cached clients.""" + self._providers.clear() + self._clients.clear() diff --git a/craftos_integrations/core/storage.py b/craftos_integrations/core/storage.py new file mode 100644 index 00000000..31465aca --- /dev/null +++ b/craftos_integrations/core/storage.py @@ -0,0 +1,144 @@ +"""Default filesystem CredentialStore for AccountSet documents. + +Layout (same directory as the legacy store, ``/.credentials``): + + gmail.accounts.json # AccountSet document + .gmail.accounts.lock # advisory-lock sidecar (empty) + gmail.accounts.json.corrupt # quarantined unparseable document + gmail.json # legacy single-credential file (pre-multi-account installs; + # read once by the upgrade migration, deleted + # when the last account is removed) + +Guarantees: + - ``replace`` is atomic (tmp file + os.replace) — a crash mid-write can + never leave a torn document; the previous version survives. + - ``locked`` serializes read-modify-write cycles across processes via + fcntl.flock on the sidecar (the sidecar never gets replaced, so the + lock's inode is stable — locking the data file itself would race with + os.replace swapping inodes underneath the lock holder). + - Unparseable documents are quarantined loudly, never silently treated + as "no accounts" (which would look like a logout and destroy the + evidence). +""" + +from __future__ import annotations + +import fcntl +import json +import os +import stat +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Dict, Iterator, Mapping, Optional + +from ..config import ConfigStore +from ..logger import get_logger + +logger = get_logger(__name__) + + +class FileCredentialStore: + def __init__( + self, + root: Optional[Path] = None, + legacy_filenames: Optional[Mapping[str, str]] = None, + ) -> None: + """``root`` defaults to the legacy store's directory so migration can + find pre-multi-account files. ``legacy_filenames`` maps provider ids whose old + cred file isn't simply ``.json``.""" + self._root = root + self._legacy_filenames = dict(legacy_filenames or {}) + + # Resolved lazily: ConfigStore.project_root is set by the host at + # startup, which may be after this store is constructed. + def _dir(self) -> Path: + path = self._root or (ConfigStore.project_root / ".credentials") + path.mkdir(parents=True, exist_ok=True) + try: + os.chmod(path, stat.S_IRWXU) + except OSError: + pass + return path + + def _path(self, provider_id: str) -> Path: + return self._dir() / f"{provider_id}.accounts.json" + + # ──────────────────────────────────────────────────────────────────── + # CredentialStore protocol + # ──────────────────────────────────────────────────────────────────── + + def load(self, provider_id: str) -> Optional[Dict[str, Any]]: + path = self._path(provider_id) + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + quarantine = path.with_suffix(path.suffix + ".corrupt") + os.replace(path, quarantine) + logger.error( + f"[STORE] {path.name} is unparseable ({e}); quarantined to " + f"{quarantine.name}. {provider_id} will read as disconnected — " + f"the file is preserved for inspection/recovery." + ) + return None + + def replace(self, provider_id: str, data: Dict[str, Any]) -> None: + path = self._path(provider_id) + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as f: + os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + + def delete(self, provider_id: str) -> None: + path = self._path(provider_id) + if path.exists(): + path.unlink() + logger.info(f"[STORE] Removed {path.name}") + + @contextmanager + def locked(self, provider_id: str) -> Iterator[None]: + lock_path = self._dir() / f".{provider_id}.accounts.lock" + with open(lock_path, "a+") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + def has_document(self, provider_id: str) -> bool: + return self._path(provider_id).exists() + + def _legacy_path(self, provider_id: str) -> Path: + filename = self._legacy_filenames.get(provider_id, f"{provider_id}.json") + return self._dir() / filename + + def delete_legacy(self, provider_id: str) -> None: + """Remove the pre-multi-account single-account credential file, if present. + + Called by the system when the last account is removed: the + one-time upgrade migration re-imports any surviving legacy file + into a provider with no AccountSet document, so a disconnect must delete + both the document AND the legacy file or the just-removed account + would resurrect on the next load.""" + legacy = self._legacy_path(provider_id) + if legacy.exists(): + legacy.unlink() + logger.info(f"[STORE] Removed legacy {legacy.name}") + + def load_legacy(self, provider_id: str) -> Optional[Dict[str, Any]]: + path = self._legacy_path(provider_id) + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + # A corrupt legacy file just means "nothing to migrate" — + # leave it in place for inspection. + logger.warning(f"[STORE] Legacy {path.name} unparseable, skipping: {e}") + return None diff --git a/craftos_integrations/core/system.py b/craftos_integrations/core/system.py new file mode 100644 index 00000000..17aeef15 --- /dev/null +++ b/craftos_integrations/core/system.py @@ -0,0 +1,291 @@ +"""IntegrationSystem — the single object a host embeds. + +Multi-account is handled HERE, uniformly: ``execute()`` resolves +``account → identity → client`` once, centrally. Providers and their +operations never see account selection — they receive a ready client. +Host adapters advertise the ``account`` input on every generated action +schema in one place, so partial coverage is impossible by construction. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Tuple + +from ..contracts import ( + AccountInfo, + CredentialStore, + EventSink, + LEGACY_IDENTITY, + OAuthTransport, + Operation, + Provider, +) +from ..logger import get_logger +from .accounts import AccountManager +from .registry import IntegrationRegistry + +logger = get_logger(__name__) + + +class IntegrationSystem: + def __init__( + self, + store: CredentialStore, + oauth: Optional[OAuthTransport] = None, + sink: Optional[EventSink] = None, + providers: Optional[List[Provider]] = None, + ) -> None: + self.registry = IntegrationRegistry() + for provider in providers or []: + self.registry.register(provider) + self.accounts = AccountManager( + store, family_members=self.registry.family_members + ) + self._store = store + self._oauth = oauth + self._sink = sink + # Optional ListenerManager, attached by the host after construction + # (system.listeners = manager). Mutation paths poke it via + # reconcile_listeners() so UI changes take effect immediately. + self.listeners: Optional[Any] = None + + # ── capability discovery ───────────────────────────────────────────── + + def providers(self) -> List[Provider]: + return self.registry.all_providers() + + def operations(self, provider_id: Optional[str] = None) -> List[Operation]: + if provider_id is not None: + provider = self._require_provider(provider_id) + return provider.operations() + return [op for p in self.registry.all_providers() for op in p.operations()] + + def guidance(self, connected_only: bool = True) -> str: + sections = [] + for provider in self.registry.all_providers(): + if connected_only and not self.accounts.list_accounts(provider.id): + continue + text = provider.guidance().strip() + if text: + sections.append(text) + return "\n\n".join(sections) + + # ── execution ──────────────────────────────────────────────────────── + + async def execute( + self, + provider_id: str, + op_name: str, + input_data: Dict[str, Any], + account: Optional[str] = None, + ) -> Dict[str, Any]: + """Run one operation against one resolved account. + + Raises AccountResolutionError for bad hints (hosts map it to their + error envelope — the message is written for LLM self-correction). + Operation-level failures are whatever the operation returns/raises.""" + provider = self._require_provider(provider_id) + operation = next( + (op for op in provider.operations() if op.name == op_name), None + ) + if operation is None: + raise LookupError(f"{provider_id} has no operation '{op_name}'") + self._migrate_legacy(provider) + identity = self.accounts.resolve(provider_id, account) + client = self._client_for(provider, identity) + return await operation.fn(client, input_data) + + def _migrate_legacy(self, provider: Provider) -> None: + """One-time upgrade migration for pre-multi-account installs (≤ V1.4.2). + + A legacy single-account credential file with NO AccountSet document is + imported as the provider's first account, under a + provider-derived identity (the LEGACY sentinel when the credential + predates identity capture — upgraded in place on the next re-auth). + Once an AccountSet document exists the legacy file is never consulted again; + removing the last account deletes BOTH files (see + ``remove_account``), so a disconnect can never resurrect through + this path. + """ + store = self._store + if not hasattr(store, "load_legacy"): + return + pid = provider.id + try: + if hasattr(store, "has_document"): + if store.has_document(pid): + return + elif store.load(pid) is not None: + return + credential = store.load_legacy(pid) + if not credential: + return + identity = provider.identity_of(credential) or LEGACY_IDENTITY + stored = self.accounts.upsert_account(pid, identity, credential) + self.registry.invalidate(pid, stored) + logger.info( + f"[INTEGRATIONS] migrated legacy {pid} credential to account '{stored}'" + ) + except Exception as e: + logger.warning(f"[INTEGRATIONS] legacy migration for {pid} failed: {e}") + + def _delete_legacy_if_disconnected(self, provider_id: str) -> None: + """After a removal that may have deleted the AccountSet document (last + account gone), delete the legacy credential file too — otherwise + the one-time upgrade migration would re-import it on the next load + and resurrect the just-disconnected account. Best-effort.""" + store = self._store + if not hasattr(store, "delete_legacy"): + return + try: + if hasattr(store, "has_document"): + if store.has_document(provider_id): + return + elif store.load(provider_id) is not None: + return + store.delete_legacy(provider_id) + except Exception as e: + logger.warning( + f"[INTEGRATIONS] legacy cleanup for {provider_id} failed: {e}" + ) + + def _client_for(self, provider: Provider, identity: str) -> Any: + cached = self.registry.get_cached_client(provider.id, identity) + if cached is not None: + return cached + credential = self.accounts.credential_for(provider.id, identity) + + def persist(updated: Dict[str, Any]) -> None: + self.accounts.update_credential(provider.id, identity, updated) + + client = provider.build_client(credential, persist) + self.registry.cache_client(provider.id, identity, client) + return client + + def client_for(self, provider_id: str, identity: str) -> Any: + """Public client path for core plumbing (e.g. ListenerManager): + cached-or-built client bound to one resolved account.""" + return self._client_for(self._require_provider(provider_id), identity) + + def reconcile_listeners(self) -> None: + """Fire-and-forget listener reconcile, safe from any context. + + No-op when no manager is attached. Never raises — listener + fan-out is best-effort from mutation paths; the next startup + reconcile catches anything missed here.""" + manager = self.listeners + if manager is None: + return + try: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is not None: + loop.create_task(manager.reconcile()) + return + # Called from sync/non-loop context: hop onto the manager's + # own loop if it has one running; otherwise skip quietly. + manager_loop = getattr(manager, "loop", None) + if manager_loop is not None and manager_loop.is_running(): + asyncio.run_coroutine_threadsafe( + manager.reconcile(), manager_loop + ) + except Exception as e: + logger.warning(f"[INTEGRATIONS] listener reconcile scheduling failed: {e}") + + # ── account management (drives any settings UI) ────────────────────── + + def list_accounts(self, provider_id: str) -> List[AccountInfo]: + provider = self.registry.get(provider_id) + if provider is not None: + self._migrate_legacy(provider) + self.accounts.sync_family_aliases(provider_id) + return self.accounts.list_accounts(provider_id) + + def resolve(self, provider_id: str, hint: Optional[str]) -> str: + return self.accounts.resolve(provider_id, hint) + + def set_alias( + self, provider_id: str, hint: Optional[str], alias: Optional[str] + ) -> str: + identity = self.accounts.set_alias(provider_id, hint, alias) + for pid in self.registry.family_members(provider_id): + self.registry.invalidate(pid, identity) + return identity + + async def add_account(self, provider_id: str) -> Tuple[bool, str, List[AccountInfo]]: + """Interactive OAuth add-account flow, driven by the provider's + ``run_login()``. Returns (ok, message, accounts-after). + + A provider without ``run_login`` (token-entry-only integrations) + raises LookupError — hosts surface that as "connect via settings". + An identity-less success is still stored (under LEGACY_IDENTITY, + upgraded in place on the next re-auth).""" + provider = self._require_provider(provider_id) + run_login = getattr(provider, "run_login", None) + if run_login is None: + raise LookupError( + f"{provider_id} does not support interactive login" + ) + identity, credential, message = await run_login() + if not credential: + return False, message, self.list_accounts(provider_id) + self.store_credential(provider_id, identity or LEGACY_IDENTITY, credential) + accounts = self.list_accounts(provider_id) + self.reconcile_listeners() + return True, message, accounts + + def set_primary(self, provider_id: str, hint: Optional[str]) -> str: + identity = self.accounts.set_primary(provider_id, hint) + self.registry.invalidate(provider_id) + return identity + + def set_listening(self, provider_id: str, hint: Optional[str], on: bool) -> str: + identity = self.accounts.set_listening(provider_id, hint, on) + self.reconcile_listeners() + return identity + + def remove_account(self, provider_id: str, hint: Optional[str]) -> str: + identity = self.accounts.remove_account(provider_id, hint) + self.registry.invalidate(provider_id, identity) + self._delete_legacy_if_disconnected(provider_id) + self.reconcile_listeners() + return identity + + def apply_account_changes( + self, provider_id: str, batch: Dict[str, Any] + ) -> List[AccountInfo]: + result = self.accounts.apply_changes(provider_id, batch) + # Batch may have re-pointed primary/aliases arbitrarily — drop the + # provider's whole cache (and family siblings', for alias moves). + for pid in self.registry.family_members(provider_id): + self.registry.invalidate(pid) + # A batch may disconnect the last account — same resurrection + # hazard as remove_account. + self._delete_legacy_if_disconnected(provider_id) + self.reconcile_listeners() + return result + + def store_credential( + self, provider_id: str, identity: Optional[str], credential: Dict[str, Any] + ) -> str: + """OAuth-completion write path (used by add_account / re-auth).""" + stored = self.accounts.upsert_account(provider_id, identity, credential) + self.registry.invalidate(provider_id, stored) + return stored + + def update_credential( + self, provider_id: str, identity: str, credential: Dict[str, Any] + ) -> None: + """Token-refresh write path.""" + self.accounts.update_credential(provider_id, identity, credential) + + # ── internals ──────────────────────────────────────────────────────── + + def _require_provider(self, provider_id: str) -> Provider: + provider = self.registry.get(provider_id) + if provider is None: + raise LookupError(f"Unknown integration '{provider_id}'") + return provider diff --git a/craftos_integrations/integrations/whatsapp_web/bridge.js b/craftos_integrations/integrations/whatsapp_web/bridge.js index 20a1a84f..2490cce5 100644 --- a/craftos_integrations/integrations/whatsapp_web/bridge.js +++ b/craftos_integrations/integrations/whatsapp_web/bridge.js @@ -347,6 +347,36 @@ c.on("auth_failure", (msg) => { emitEvent("auth_failure", { message: String(msg) }); }); +// Lean unread-chat scan that bypasses wwebjs's getChats(). getChats() +// serializes every chat model and is the first thing to break when +// WhatsApp ships a build ahead of whatsapp-web.js; catchup only needs +// ids + unread counters, which we can read straight off the page's chat +// collection (same window.require pattern as resolveOwnerLid — probing +// window.Store.* here silently returns empty on wwebjs ≥1.31). +async function leanUnreadChats() { + return await client.pupPage.evaluate(() => { + const out = []; + const models = window + .require("WAWebChatCollection") + .ChatCollection.getModelsArray(); + for (const chat of models) { + try { + if (!chat.unreadCount || chat.unreadCount <= 0) continue; + const id = chat.id && chat.id._serialized; + if (!id) continue; + out.push({ + id, + name: chat.formattedTitle || chat.name || id, + unread_count: chat.unreadCount, + is_group: !!(chat.isGroup || (chat.id && chat.id.server === "g.us")), + is_muted: !!(chat.mute && (chat.mute.isMuted || chat.mute.expiration > 0)), + }); + } catch (e) { /* skip malformed chat model */ } + } + return out; + }); +} + c.on("ready", async () => { isReady = true; readyTimestamp = Math.floor(Date.now() / 1000); @@ -383,28 +413,39 @@ c.on("ready", async () => { wid: client.info?.wid?._serialized || "", }); - // Catch-up: send current unread chats + // Catch-up: send current unread chats. Prefer wwebjs getChats() (richer), + // falling back immediately to the lean in-page scan when getChats() is + // broken by a WhatsApp build ahead of whatsapp-web.js (observed live + // 2026-08-12: getChats() consistently failed with minified "r" while the + // lean scan worked — retrying only delayed catchup, so we don't). + let unread = null; try { const chats = await client.getChats(); - const unread = []; - for (const chat of chats) { - if (chat.unreadCount > 0) { - unread.push({ - id: chat.id._serialized, - name: chat.name || chat.id._serialized, - unread_count: chat.unreadCount, - is_group: chat.isGroup, - is_muted: chat.isMuted, - }); - } + unread = chats + .filter((chat) => chat.unreadCount > 0) + .map((chat) => ({ + id: chat.id._serialized, + name: chat.name || chat.id._serialized, + unread_count: chat.unreadCount, + is_group: chat.isGroup, + is_muted: chat.isMuted, + })); + } catch (err) { + log(`Catchup getChats failed, using lean fallback: ${errStr(err)}`); + } + if (unread === null) { + try { + unread = await leanUnreadChats(); + log("Catchup used lean in-page fallback"); + } catch (err) { + log(`Catchup lean fallback failed: ${errStr(err)}`); } + } + if (unread !== null) { emitEvent("catchup", { unread_chats: unread }); - catchupDone = true; log(`Catchup complete: ${unread.length} unread chat(s)`); - } catch (err) { - log(`Catchup error: ${errStr(err)}`); - catchupDone = true; // proceed anyway } + catchupDone = true; // proceed even if every path failed }); c.on("disconnected", (reason) => { diff --git a/craftos_integrations/manager.py b/craftos_integrations/manager.py index fced6733..462122ac 100644 --- a/craftos_integrations/manager.py +++ b/craftos_integrations/manager.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from .base import PlatformMessage from .config import ConfigStore, MessageCallback @@ -27,10 +27,26 @@ class ExternalCommsManager: - def __init__(self, on_message: MessageCallback): + def __init__( + self, + on_message: MessageCallback, + exclude_platforms: Optional[List[str]] = None, + ): self._on_message = on_message self._active_clients: Dict[str, Any] = {} self._running = False + # Platforms whose listening is owned elsewhere (the + # ListenerManager) — this manager must never start them. + self._excluded = set(exclude_platforms or []) + + def _is_excluded(self, platform_id: str) -> bool: + if platform_id in self._excluded: + logger.info( + f"[INTEGRATIONS] {platform_id} excluded from legacy listening " + "(owned by integrations listener manager)" + ) + return True + return False async def start(self) -> None: if self._running: @@ -44,6 +60,8 @@ async def start(self) -> None: logger.info(f"[INTEGRATIONS] Registered platforms: {list(all_clients.keys())}") for platform_id, client in all_clients.items(): + if self._is_excluded(platform_id): + continue if not client.supports_listening: continue if not client.has_credentials(): @@ -98,6 +116,9 @@ async def start_platform(self, platform_id: str) -> bool: reusing it would keep routing to the wrong account until restart (issue #314). """ + if self._is_excluded(platform_id): + return False + await self.reset_platform(platform_id) autoload_integrations() @@ -160,7 +181,9 @@ async def reload(self) -> Dict[str, Any]: should_be_active = { pid for pid, c in all_clients.items() - if c.supports_listening and c.has_credentials() + if c.supports_listening + and c.has_credentials() + and not self._is_excluded(pid) } for pid in currently_active - should_be_active: @@ -238,11 +261,17 @@ async def initialize_manager( *, on_message: MessageCallback, auto_start: bool = True, + exclude_platforms: Optional[List[str]] = None, ) -> ExternalCommsManager: - """Create the manager and (by default) start listeners.""" + """Create the manager and (by default) start listeners. + + ``exclude_platforms``: platform ids this manager must never listen on + (their listening is owned by the ListenerManager). Actions and + account handling for those platforms are unaffected. + """ global _manager ConfigStore.on_message = on_message - _manager = ExternalCommsManager(on_message) + _manager = ExternalCommsManager(on_message, exclude_platforms=exclude_platforms) if auto_start: await _manager.start() return _manager diff --git a/craftos_integrations/providers/__init__.py b/craftos_integrations/providers/__init__.py new file mode 100644 index 00000000..9dca0625 --- /dev/null +++ b/craftos_integrations/providers/__init__.py @@ -0,0 +1,42 @@ +"""Integrations providers — one folder per integration. + +Each provider implements the ``Provider`` protocol from +``craftos_integrations.contracts`` and is host-blind: no imports from the +host application, no direct credential-file access (credentials are +injected by the core, refreshed tokens go back through ``persist``). + +``default_providers()`` returns instances of every shipped provider — +what a host passes to ``IntegrationSystem(providers=...)``. +""" + +from __future__ import annotations + +from typing import List + +from ..contracts import Provider + + +def default_providers() -> List[Provider]: + from .gmail import GmailProvider + from .google_calendar import GoogleCalendarProvider + from .google_docs import GoogleDocsProvider + from .google_drive import GoogleDriveProvider + from .google_youtube import GoogleYoutubeProvider + from .hubspot import HubSpotProvider + from .linkedin import LinkedInProvider + from .notion import NotionProvider + from .outlook import OutlookProvider + from .slack import SlackProvider + + return [ + GmailProvider(), + GoogleCalendarProvider(), + GoogleDocsProvider(), + GoogleDriveProvider(), + GoogleYoutubeProvider(), + HubSpotProvider(), + LinkedInProvider(), + NotionProvider(), + OutlookProvider(), + SlackProvider(), + ] diff --git a/craftos_integrations/providers/_google.py b/craftos_integrations/providers/_google.py new file mode 100644 index 00000000..8d26677a --- /dev/null +++ b/craftos_integrations/providers/_google.py @@ -0,0 +1,191 @@ +"""Google family provider base — shared by gmail/calendar/drive/docs/youtube. + +Reuses the battle-tested API client classes from +``craftos_integrations.integrations.*`` but replaces their credential +plumbing: clients are bound to ONE injected account credential and +persist refreshed tokens through the core (never to spec.cred_file, which +is single-account and would cross-wire secondaries). + +The OAuth spec carries the multi-account fix this whole feature started +from: ``prompt=consent select_account`` forces Google's account chooser, +so "Add account" can actually add a *different* account (space-delimited +prompt values are valid per Google's OAuth docs; ``consent`` keeps +refresh-token issuance for re-auths). +""" + +from __future__ import annotations + +import time +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ..contracts import LEGACY_IDENTITY, OAuthSpec, Operation +from ..helpers import request as http_request +from ..integrations._google_common import ( + GOOGLE_AUTH_URL, + GOOGLE_TOKEN_URL, + GoogleCredential, + USERINFO_SCOPES, + make_google_oauth, +) +from ..logger import get_logger + +logger = get_logger(__name__) + +GOOGLE_FAMILY = "google" + +_CRED_FIELDS = {f.name for f in fields(GoogleCredential)} + +# The chooser fix. NOT plain "consent" (old behavior: silently re-auths the +# browser-session account) and NOT dropped for Outlook-style reasons — if +# this regresses token issuance somewhere, that's a review conversation. +GOOGLE_AUTH_PARAMS = { + "access_type": "offline", + "prompt": "consent select_account", +} + + +class GoogleClientBinding: + """Overrides GoogleApiClientMixin's disk plumbing on a legacy client + class: credential is injected per account, refresh persists through the + core. MRO puts this before the mixin: + + class BoundGmailClient(GoogleClientBinding, GmailClient): pass + """ + + _cred: Optional[GoogleCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = GoogleCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> GoogleCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + def refresh_access_token(self) -> Optional[str]: + cred = self._load() + if not all([cred.client_id, cred.client_secret, cred.refresh_token]): + return None + result = http_request( + "POST", + GOOGLE_TOKEN_URL, + data={ + "client_id": cred.client_id, + "client_secret": cred.client_secret, + "refresh_token": cred.refresh_token, + "grant_type": "refresh_token", + }, + expected=(200,), + ) + if "error" in result: + logger.warning(f"[GOOGLE] token refresh failed: {result['error']}") + return None + data = result["result"] + cred.access_token = data["access_token"] + cred.token_expiry = time.time() + data.get("expires_in", 3600) - 60 + self._persist(asdict(cred)) + return cred.access_token + + +class GoogleProviderBase: + """Subclasses set: id, display_name, scopes, client_cls (bound + class), and implement operations()/guidance().""" + + id: str = "" + display_name: str = "" + scopes: str = "" + client_cls: type = None # GoogleClientBinding subclass + family = GOOGLE_FAMILY + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + email = credential.get("email") + if isinstance(email, str) and email.strip(): + return email.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=GOOGLE_AUTH_URL, + token_url=GOOGLE_TOKEN_URL, + scopes=tuple(f"{self.scopes} {USERINFO_SCOPES}".split()), + extra_authorize_params=GOOGLE_AUTH_PARAMS, + has_chooser=True, + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Out-of-band refresh (listener wake-up etc.); operations normally + refresh inline via the binding.""" + holder: Dict[str, Any] = {} + client = self.build_client(credential, holder.update) + token = client.refresh_access_token() + return holder or None if token else None + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the package's OAuthFlow (localhost + callback or host-injected oauth_runner). Returns + (identity, credential, message). Refuses identity-less results — + an unaddressable account is worse than a failed login.""" + from ..config import ConfigStore + + oauth = make_google_oauth(self.scopes) + oauth.extra_auth_params = dict(GOOGLE_AUTH_PARAMS) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"{self.display_name} OAuth failed: {result['error']}" + email = (result.get("userinfo") or {}).get("email", "").strip().lower() + if not email: + return None, None, ( + f"{self.display_name} sign-in completed but Google returned no " + f"email address — cannot store an unaddressable account. " + f"Please try again." + ) + credential = asdict( + 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 email, credential, f"{self.display_name} connected as {email}" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ): + return None # calendar/drive/docs/youtube have no inbound events + + # subclasses implement: + def operations(self) -> List[Operation]: + raise NotImplementedError + + def guidance(self) -> str: + raise NotImplementedError + + +# Back-compat re-export: providers import read_guidance from here or from +# _shared; the implementation now lives in _shared (it isn't Google-specific). +from ._shared import read_guidance # noqa: E402 (re-export) diff --git a/craftos_integrations/providers/_shared.py b/craftos_integrations/providers/_shared.py new file mode 100644 index 00000000..1f6653f4 --- /dev/null +++ b/craftos_integrations/providers/_shared.py @@ -0,0 +1,172 @@ +"""Shared plumbing for authoring operations and listeners. + +``client_op`` turns "call this client method with these schema'd inputs" +into an Operation, keeping per-provider operations.py files declarative. +The result-envelope shaping mirrors the host's historical behavior +(``_shape_result`` in the old action helpers) so ported operations return +identical dicts to what agents already expect. + +``platform_message_payload`` is the listener-side twin: it converts a +legacy ``PlatformMessage`` into the exact event-dict shape the legacy +``ExternalCommsManager._handle_platform_message`` built, so integration listener +events are byte-for-byte what the host's trigger system already expects. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + +from ..contracts import Operation + +STATUS_OUTPUT = {"status": {"type": "string", "example": "success"}} + +# Type of the account-bound event callable the core hands make_listener. +EmitFn = Callable[[Dict[str, Any]], Awaitable[None]] + + +def platform_message_payload(msg: Any) -> Dict[str, Any]: + """Legacy ``PlatformMessage`` → host event payload. + + Mirrors ``manager.ExternalCommsManager._handle_platform_message`` + exactly — same keys, same fallbacks — so listeners ported from the + legacy clients emit identical events. + """ + raw = msg.raw if isinstance(msg.raw, dict) else {} + return { + "source": msg.platform.replace("_", " ").title(), + "integrationType": msg.platform, + "contactId": msg.sender_id, + "contactName": msg.sender_name or msg.sender_id, + "messageBody": msg.text, + "channelId": msg.channel_id, + "channelName": msg.channel_name, + "messageId": msg.message_id, + "is_self_message": raw.get("is_self_message", False), + "raw": raw, + } + + +def emit_callback(emit: EmitFn) -> Callable[[Any], Awaitable[None]]: + """Adapt an account-bound ``emit`` into the legacy client callback. + + Legacy poll loops call ``self._message_callback(PlatformMessage)``; + this shim converts each message to the host payload shape and awaits + ``emit`` — the only plumbing the ported listeners have to replace. + """ + + async def _callback(msg: Any) -> None: + await emit(platform_message_payload(msg)) + + return _callback + + +def read_guidance(package_file: str) -> str: + """Load GUIDANCE.md sitting next to a provider module.""" + from pathlib import Path + + path = Path(package_file).parent / "GUIDANCE.md" + try: + return path.read_text(encoding="utf-8") + except OSError: + return "" + + +def shape_result( + raw: Any, + *, + unwrap_envelope: bool = False, + success_message: Optional[str] = None, + fail_message: str = "Operation failed", +) -> Dict[str, Any]: + """Normalize a client return value into {"status": ..., ...}.""" + if isinstance(raw, dict): + if raw.get("ok") is True: + if success_message: + return {"status": "success", "message": success_message} + if set(raw.keys()) == {"ok", "result"}: + return {"status": "success", "result": raw["result"]} + return { + "status": "success", + "result": {k: v for k, v in raw.items() if k != "ok"}, + } + if raw.get("ok") is False: + return {"status": "error", "message": raw.get("error", fail_message)} + if "error" in raw and ( + unwrap_envelope or set(raw.keys()) <= {"error", "details"} + ): + return { + "status": "error", + "message": raw.get("error", fail_message), + "details": raw.get("details"), + } + if raw.get("status") == "error": + return { + "status": "error", + "message": raw.get("message") or raw.get("error", fail_message), + } + if success_message: + return {"status": "success", "message": success_message} + return {"status": "success", "result": raw} + + +def client_op( + name: str, + method: str, + *, + description: str, + input_schema: Dict[str, Any], + output_schema: Optional[Dict[str, Any]] = None, + destructive: bool = False, + parallelizable: bool = True, + tags: Tuple[str, ...] = (), + unwrap_envelope: bool = False, + success_message: Optional[str] = None, + fail_message: str = "Operation failed", + arg_map: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None, +) -> Operation: + """Operation that calls ``client.(**kwargs)``. + + Default kwargs are the input keys present in the request (missing + optionals are NOT passed as None, so client-side defaults apply). + ``arg_map`` overrides that for input→kwarg renames or computed args. + Sync client methods run on a worker thread; async ones are awaited. + """ + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + if arg_map is not None: + kwargs = arg_map(input_data) + else: + kwargs = {k: input_data[k] for k in input_schema if k in input_data} + try: + target = getattr(client, method, None) + if target is None: + return { + "status": "error", + "message": f"Method {method!r} not found on client", + } + if asyncio.iscoroutinefunction(target): + raw = await target(**kwargs) + else: + raw = await asyncio.to_thread(target, **kwargs) + if asyncio.iscoroutine(raw): + raw = await raw + return shape_result( + raw, + unwrap_envelope=unwrap_envelope, + success_message=success_message, + fail_message=fail_message, + ) + except Exception as e: + return {"status": "error", "message": str(e)} + + return Operation( + name=name, + description=description, + input_schema=input_schema, + output_schema=output_schema or STATUS_OUTPUT, + fn=fn, + destructive=destructive, + parallelizable=parallelizable, + tags=tags, + ) diff --git a/craftos_integrations/providers/gmail/GUIDANCE.md b/craftos_integrations/providers/gmail/GUIDANCE.md new file mode 100644 index 00000000..d324d43a --- /dev/null +++ b/craftos_integrations/providers/gmail/GUIDANCE.md @@ -0,0 +1,24 @@ +# Gmail + +Email — read, search, send, drafts, labels, threads. + +## Multi-account +- Every Gmail action accepts an optional `account` (email, nickname, or a + unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my school email", "the work + inbox"), pass it as `account` — never silently default to primary. +- Message/thread/draft ids are **account-scoped**: an id returned by + `search_gmail` with `account="work"` must be used with `account="work"` + on every follow-up action (get/trash/reply/etc.). +- For destructive actions (delete, batch operations) with multiple + accounts connected and no account named: ask the user which account + before acting. + +## Behavior +- "Any updates / what's new" questions: if the unread check comes back + empty, don't answer a flat "no updates" — say there's nothing unread and + either offer or show the most recent messages (`unread_only=false`). +- `send_gmail` with no `to` sends to the connected account's own address. +- Prefer `trash_gmail` (reversible) over `delete_gmail` (permanent). +- Use Gmail search syntax in `search_gmail` (`from:`, `subject:`, + `newer_than:7d`, `has:attachment`, ...). diff --git a/craftos_integrations/providers/gmail/__init__.py b/craftos_integrations/providers/gmail/__init__.py new file mode 100644 index 00000000..cc440d3f --- /dev/null +++ b/craftos_integrations/providers/gmail/__init__.py @@ -0,0 +1,3 @@ +from .provider import GmailProvider + +__all__ = ["GmailProvider"] diff --git a/craftos_integrations/providers/gmail/listener.py b/craftos_integrations/providers/gmail/listener.py new file mode 100644 index 00000000..2e094aa0 --- /dev/null +++ b/craftos_integrations/providers/gmail/listener.py @@ -0,0 +1,100 @@ +"""Gmail listener — the legacy poll loop re-homed onto a bound client. + +The loop machinery is NOT rewritten: ``BoundGmailClient`` inherits the legacy +``GmailClient``'s ``_poll_loop`` / ``_check_history`` / +``_fetch_and_dispatch`` (history.list on INBOX every POLL_INTERVAL, +404-expired-historyId recovery, seen-id dedup, self-message filtering) +unchanged. This class replaces only the two things that were host-global +in the legacy design: + +* callback plumbing — ``_message_callback`` is a shim converting each + ``PlatformMessage`` into the host event payload and awaiting the + account-bound ``emit``; +* startup state — instead of always baselining from the live profile's + ``historyId``, a persisted cursor seeds ``_history_id`` + + ``_seen_message_ids`` so a restart resumes where it left off (catching + mail that arrived while the host was down) without re-emitting events. + +Config gating: the legacy ``GmailConfig.process_incoming`` toggle needs no +porting — the inherited ``_fetch_and_dispatch`` re-reads +``gmail_config.json`` on every dispatch and drops incoming mail when the +toggle is off, so it keeps working exactly as before for the integration listeners. + +Token refresh during long polls is the binding's job: ``_auth_header`` +resolves through ``GoogleClientBinding.refresh_access_token``, which +persists rotated tokens through the core. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +from ...integrations.gmail import POLL_INTERVAL +from ...logger import get_logger +from .._shared import EmitFn, emit_callback + +logger = get_logger(__name__) + +# How many recently-seen message ids survive into the cursor. Matches the +# legacy in-memory trim floor (sets over 500 were cut back to 200). +CURSOR_SEEN_IDS = 200 + + +class GmailListener: + """One Gmail inbox poll loop for one bound account.""" + + def __init__( + self, client: Any, cursor: Optional[Dict[str, Any]], emit: EmitFn + ) -> None: + self._client = client + self._initial_cursor = dict(cursor) if cursor else None + self._emit = emit + self.poll_interval: float = POLL_INTERVAL # legacy cadence (5s) + + async def start(self) -> None: + client = self._client + if client._listening: + return + client._message_callback = emit_callback(self._emit) + + saved = self._initial_cursor or {} + history_id = saved.get("history_id") + if history_id: + # Resume: trust the persisted baseline so mail that arrived + # while we were down is still delivered (history.list replays + # from it); seen ids stop replayed records from double-emitting. + client._history_id = str(history_id) + client._seen_message_ids = set(saved.get("seen_ids") or []) + else: + # Fresh start: baseline at the live profile, exactly like the + # legacy start_listening — no historical backfill. + try: + profile = await client._async_get_profile() + except Exception as e: + raise RuntimeError(f"Failed to connect to Gmail: {e}") + client._history_id = profile.get("historyId") + client._seen_message_ids = set() + logger.info( + f"[GMAIL] listener baseline: {profile.get('emailAddress')}, " + f"historyId: {client._history_id}" + ) + + client._listening = True + client._poll_task = asyncio.create_task(client._poll_loop()) + + async def stop(self) -> None: + # Legacy stop_listening already does exactly what we need: + # flag off, cancel the poll task, await it. + await self._client.stop_listening() + + def cursor(self) -> Optional[Dict[str, Any]]: + client = self._client + if not client._history_id: + # Never started (or fresh baseline failed): hand back what we + # were given so a persisted cursor is never destroyed. + return self._initial_cursor + return { + "history_id": str(client._history_id), + "seen_ids": sorted(client._seen_message_ids)[-CURSOR_SEEN_IDS:], + } diff --git a/craftos_integrations/providers/gmail/operations.py b/craftos_integrations/providers/gmail/operations.py new file mode 100644 index 00000000..348761dd --- /dev/null +++ b/craftos_integrations/providers/gmail/operations.py @@ -0,0 +1,885 @@ +"""Gmail operations — ported from the legacy gmail_actions.py schemas. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). + +Complete port of app/data/action/integrations/google_workspace/ +gmail_actions.py, minus the two backwards-compat aliases +(send_google_workspace_email / read_recent_google_workspace_emails): +they existed only to keep old skill/memory action names working in the +single-account system, and send_google_workspace_email's ``from_email`` +input is account selection — handled centrally by the system. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Dict, List + +from ...contracts import Operation +from .._shared import client_op + + +def _get_gmail_thread_op() -> Operation: + """get_gmail_thread with the legacy lean-shaping of the raw thread.""" + base = client_op( + "get_gmail_thread", + "get_thread", + description=( + "Get a thread (conversation) and its messages. Default returns " + "per-message {id, from, to, subject, date, snippet}; set " + "include_metadata for the raw thread." + ), + tags=("gmail_threads", "gmail"), + unwrap_envelope=True, + fail_message="Failed to get thread.", + input_schema={ + "thread_id": {"type": "string", "description": "Thread ID.", "example": ""}, + "fmt": { + "type": "string", + "description": "metadata | full | minimal.", + "example": "metadata", + }, + "include_metadata": { + "type": "boolean", + "description": "Return the raw thread resource (default false = lean).", + "example": False, + }, + }, + arg_map=lambda d: { + "thread_id": d["thread_id"], + "fmt": d.get("fmt", "metadata"), + }, + ) + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + res = await inner(client, input_data) + if not input_data.get("include_metadata") and res.get("status") == "success": + thread = res.get("result") + if isinstance(thread, dict): + lean_messages = [] + for msg in thread.get("messages", []) or []: + if not isinstance(msg, dict): + continue + headers = { + h.get("name", ""): h.get("value", "") + for h in msg.get("payload", {}).get("headers", []) + } + lean_messages.append( + { + "id": msg.get("id"), + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + } + ) + res = { + **res, + "result": {"id": thread.get("id"), "messages": lean_messages}, + } + return res + + return replace(base, fn=fn) + + +def _get_gmail_draft_op() -> Operation: + """get_gmail_draft with the legacy lean-shaping of the raw draft.""" + base = client_op( + "get_gmail_draft", + "get_draft", + description=( + "Get a Gmail draft by ID. Default returns {id, message_id, to, " + "subject, snippet}; set include_metadata for the raw draft." + ), + tags=("gmail_drafts",), + unwrap_envelope=True, + fail_message="Failed to get draft.", + input_schema={ + "draft_id": {"type": "string", "description": "Draft ID.", "example": ""}, + "fmt": { + "type": "string", + "description": "metadata | full | minimal.", + "example": "metadata", + }, + "include_metadata": { + "type": "boolean", + "description": "Return the raw draft resource (default false = lean).", + "example": False, + }, + }, + arg_map=lambda d: { + "draft_id": d["draft_id"], + "fmt": d.get("fmt", "metadata"), + }, + ) + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + res = await inner(client, input_data) + if not input_data.get("include_metadata") and res.get("status") == "success": + draft = res.get("result") + if isinstance(draft, dict): + msg = draft.get("message") or {} + headers = { + h.get("name", ""): h.get("value", "") + for h in msg.get("payload", {}).get("headers", []) + } + res = { + **res, + "result": { + "id": draft.get("id"), + "message_id": msg.get("id"), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "snippet": msg.get("snippet", ""), + }, + } + return res + + return replace(base, fn=fn) + + +def build_operations() -> List[Operation]: + return [ + # ── Mail — send / list / get / search / reply / forward / lifecycle ── + client_op( + "send_gmail", + "send_email", + description="Send an email via Gmail.", + destructive=True, # outward-facing send — hosts confirm/clarify + parallelizable=False, + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + success_message="Email sent.", + fail_message="Failed to send email.", + input_schema={ + "to": { + "type": "string", + "description": ( + "Recipient email address. OMIT to send to the user's " + "own address (the connected account) — never store or " + "guess the user's email." + ), + "example": "user@example.com", + }, + "subject": { + "type": "string", + "description": "Email subject.", + "example": "Meeting Follow-up", + }, + "body": { + "type": "string", + "description": "Email body text.", + "example": "Hi, here are the notes...", + }, + "attachments": { + "type": "array", + "description": "Optional list of file paths to attach.", + "example": [], + }, + }, + arg_map=lambda d: { + # Omitted/empty `to` → the client sends to the account owner. + "to": d.get("to"), + "subject": d["subject"], + "body": d["body"], + "attachments": d.get("attachments"), + }, + ), + client_op( + "list_gmail", + "list_emails", + description="List recent emails from Gmail inbox.", + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to list emails.", + input_schema={ + "count": { + "type": "integer", + "description": "Number of recent emails to list.", + "example": 5, + }, + "unread_only": { + "type": "boolean", + "description": "Only unread emails.", + "example": True, + }, + }, + arg_map=lambda d: { + "n": d.get("count", 5), + "unread_only": d.get("unread_only", True), + }, + ), + client_op( + "get_gmail", + "get_email", + description="Get a single Gmail message by id.", + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to get email.", + input_schema={ + "message_id": { + "type": "string", + "description": "Gmail message id (from list/search).", + "example": "18c2f...", + }, + "full_body": { + "type": "boolean", + "description": "Return the full body instead of a snippet.", + "example": False, + }, + }, + ), + client_op( + "read_top_emails", + "read_top_emails", + description="Read the top N recent emails with details.", + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to read emails.", + input_schema={ + "count": { + "type": "integer", + "description": "Number of emails to read.", + "example": 5, + }, + "full_body": { + "type": "boolean", + "description": "Include full body text.", + "example": False, + }, + }, + arg_map=lambda d: { + "n": d.get("count", 5), + "full_body": d.get("full_body", False), + }, + ), + client_op( + "search_gmail", + "search_messages", + description="Search Gmail with a query (Gmail search syntax).", + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to search emails.", + input_schema={ + "query": { + "type": "string", + "description": "Gmail search query.", + "example": "from:alice subject:invoice newer_than:7d", + }, + "max_results": { + "type": "integer", + "description": "Maximum number of results.", + "example": 10, + }, + }, + ), + client_op( + "reply_gmail", + "reply_to_message", + description="Reply to a Gmail message (keeps the thread).", + destructive=True, + parallelizable=False, + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + success_message="Reply sent.", + fail_message="Failed to send reply.", + input_schema={ + "message_id": { + "type": "string", + "description": "Id of the message being replied to.", + "example": "18c2f...", + }, + "body": { + "type": "string", + "description": "Reply body text.", + "example": "Thanks — confirmed for Tuesday.", + }, + "reply_all": { + "type": "boolean", + "description": "Reply to all recipients.", + "example": False, + }, + }, + ), + client_op( + "forward_gmail", + "forward_message", + description="Forward a Gmail message to another address.", + destructive=True, # outward-facing send + parallelizable=False, + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to forward.", + input_schema={ + "message_id": { + "type": "string", + "description": "Original message ID.", + "example": "", + }, + "to": { + "type": "string", + "description": "Recipient email.", + "example": "bob@example.com", + }, + "body": { + "type": "string", + "description": "Optional intro text.", + "example": "", + }, + "attachments": { + "type": "array", + "description": "Optional attachment file paths.", + "example": [], + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "to": d["to"], + "body": d.get("body", ""), + "attachments": d.get("attachments"), + }, + ), + client_op( + "modify_gmail_labels", + "modify_message_labels", + description=( + "Add/remove labels on a Gmail message. Common label IDs: " + "INBOX, UNREAD, STARRED, IMPORTANT, TRASH, SPAM, " + "CATEGORY_PERSONAL." + ), + parallelizable=False, + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to modify labels.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "add_label_ids": { + "type": "array", + "description": "Label IDs to add.", + "example": ["STARRED"], + }, + "remove_label_ids": { + "type": "array", + "description": "Label IDs to remove.", + "example": ["UNREAD"], + }, + }, + ), + client_op( + "trash_gmail", + "trash_message", + description="Move a Gmail message to Trash (reversible).", + parallelizable=False, + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + success_message="Message moved to Trash.", + fail_message="Failed to trash message.", + input_schema={ + "message_id": { + "type": "string", + "description": "Gmail message id.", + "example": "18c2f...", + }, + }, + ), + client_op( + "untrash_gmail", + "untrash_message", + description="Recover a Gmail message from Trash.", + parallelizable=False, + tags=("gmail_mail",), + unwrap_envelope=True, + fail_message="Failed to untrash.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + }, + ), + client_op( + "delete_gmail", + "delete_message", + description="Permanently delete a Gmail message (NOT reversible — prefer trash_gmail).", + destructive=True, + parallelizable=False, + tags=("gmail_mail",), + unwrap_envelope=True, + success_message="Message permanently deleted.", + fail_message="Failed to delete message.", + input_schema={ + "message_id": { + "type": "string", + "description": "Gmail message id.", + "example": "18c2f...", + }, + }, + ), + client_op( + "batch_modify_gmail", + "batch_modify_messages", + description="Bulk add/remove labels across multiple messages in one call.", + parallelizable=False, + tags=("gmail_mail",), + unwrap_envelope=True, + fail_message="Failed to batch modify.", + input_schema={ + "message_ids": { + "type": "array", + "description": "List of message IDs.", + "example": [], + }, + "add_label_ids": { + "type": "array", + "description": "Label IDs to add.", + "example": [], + }, + "remove_label_ids": { + "type": "array", + "description": "Label IDs to remove.", + "example": [], + }, + }, + ), + client_op( + "batch_delete_gmail", + "batch_delete_messages", + description="Permanently delete multiple messages. Irreversible.", + destructive=True, # permanent delete + parallelizable=False, + tags=("gmail_mail",), + unwrap_envelope=True, + fail_message="Failed to batch delete.", + input_schema={ + "message_ids": { + "type": "array", + "description": "List of message IDs.", + "example": [], + }, + }, + ), + # ── Threads ────────────────────────────────────────────────────── + client_op( + "list_gmail_threads", + "list_threads", + description="List Gmail conversation threads.", + tags=("gmail_threads", "gmail"), + unwrap_envelope=True, + fail_message="Failed to list threads.", + input_schema={ + "query": { + "type": "string", + "description": "Optional Gmail q query.", + "example": "", + }, + "label_ids": { + "type": "array", + "description": "Optional label filter.", + "example": ["INBOX"], + }, + "max_results": { + "type": "integer", + "description": "Max threads.", + "example": 25, + }, + }, + arg_map=lambda d: { + "query": d.get("query") or None, + "label_ids": d.get("label_ids"), + "max_results": d.get("max_results", 25), + }, + ), + _get_gmail_thread_op(), + client_op( + "modify_gmail_thread_labels", + "modify_thread_labels", + description="Add/remove labels on every message in a thread.", + parallelizable=False, + tags=("gmail_threads",), + unwrap_envelope=True, + fail_message="Failed to modify thread labels.", + input_schema={ + "thread_id": { + "type": "string", + "description": "Thread ID.", + "example": "", + }, + "add_label_ids": { + "type": "array", + "description": "Labels to add.", + "example": [], + }, + "remove_label_ids": { + "type": "array", + "description": "Labels to remove.", + "example": [], + }, + }, + ), + client_op( + "trash_gmail_thread", + "trash_thread", + description="Move an entire Gmail thread to Trash.", + parallelizable=False, + tags=("gmail_threads",), + unwrap_envelope=True, + fail_message="Failed to trash thread.", + input_schema={ + "thread_id": { + "type": "string", + "description": "Thread ID.", + "example": "", + }, + }, + ), + client_op( + "untrash_gmail_thread", + "untrash_thread", + description="Recover a Gmail thread from Trash.", + parallelizable=False, + tags=("gmail_threads",), + unwrap_envelope=True, + fail_message="Failed to untrash thread.", + input_schema={ + "thread_id": { + "type": "string", + "description": "Thread ID.", + "example": "", + }, + }, + ), + client_op( + "delete_gmail_thread", + "delete_thread", + description="Permanently delete a Gmail thread (all messages). Irreversible.", + destructive=True, # permanent delete + parallelizable=False, + tags=("gmail_threads",), + unwrap_envelope=True, + fail_message="Failed to delete thread.", + input_schema={ + "thread_id": { + "type": "string", + "description": "Thread ID.", + "example": "", + }, + }, + ), + # ── Drafts ─────────────────────────────────────────────────────── + client_op( + "list_gmail_drafts", + "list_drafts", + description="List Gmail drafts.", + tags=("gmail_drafts", "gmail"), + unwrap_envelope=True, + fail_message="Failed to list drafts.", + input_schema={ + "max_results": { + "type": "integer", + "description": "Max drafts.", + "example": 25, + }, + "query": { + "type": "string", + "description": "Optional q query.", + "example": "", + }, + }, + arg_map=lambda d: { + "max_results": d.get("max_results", 25), + "query": d.get("query") or None, + }, + ), + _get_gmail_draft_op(), + client_op( + "create_gmail_draft", + "create_draft", + description="Create a Gmail draft (not sent).", + tags=("gmail_drafts", "gmail"), + unwrap_envelope=True, + fail_message="Failed to create draft.", + input_schema={ + "to": { + "type": "string", + "description": "Recipient email address.", + "example": "user@example.com", + }, + "subject": { + "type": "string", + "description": "Draft subject.", + "example": "Q3 report", + }, + "body": { + "type": "string", + "description": "Draft body text.", + "example": "Draft text...", + }, + }, + ), + client_op( + "update_gmail_draft", + "update_draft", + description="Replace a Gmail draft's content. All fields are required (PUT semantics).", + parallelizable=False, + tags=("gmail_drafts",), + unwrap_envelope=True, + fail_message="Failed to update draft.", + input_schema={ + "draft_id": { + "type": "string", + "description": "Draft ID.", + "example": "", + }, + "to": {"type": "string", "description": "Recipient.", "example": ""}, + "subject": {"type": "string", "description": "Subject.", "example": ""}, + "body": {"type": "string", "description": "Body text.", "example": ""}, + "cc": {"type": "string", "description": "Optional CC.", "example": ""}, + "bcc": {"type": "string", "description": "Optional BCC.", "example": ""}, + "attachments": { + "type": "array", + "description": "Local file paths.", + "example": [], + }, + }, + arg_map=lambda d: { + "draft_id": d["draft_id"], + "to": d["to"], + "subject": d["subject"], + "body": d["body"], + "cc": d.get("cc") or None, + "bcc": d.get("bcc") or None, + "attachments": d.get("attachments"), + }, + ), + client_op( + "send_gmail_draft", + "send_draft", + description="Send a previously-created Gmail draft.", + destructive=True, # outward-facing send + parallelizable=False, + tags=("gmail_drafts", "gmail"), + unwrap_envelope=True, + fail_message="Failed to send draft.", + input_schema={ + "draft_id": { + "type": "string", + "description": "Draft ID.", + "example": "", + }, + }, + ), + client_op( + "delete_gmail_draft", + "delete_draft", + description="Permanently delete a Gmail draft.", + destructive=True, # permanent delete (drafts have no trash) + parallelizable=False, + tags=("gmail_drafts",), + unwrap_envelope=True, + fail_message="Failed to delete draft.", + input_schema={ + "draft_id": { + "type": "string", + "description": "Draft ID.", + "example": "", + }, + }, + ), + # ── Labels ─────────────────────────────────────────────────────── + client_op( + "list_gmail_labels", + "list_labels", + description="List all Gmail labels (system + user).", + tags=("gmail_labels", "gmail"), + unwrap_envelope=True, + fail_message="Failed to list labels.", + input_schema={}, + ), + client_op( + "get_gmail_label", + "get_label", + description="Get a single Gmail label by ID.", + tags=("gmail_labels",), + unwrap_envelope=True, + fail_message="Failed to get label.", + input_schema={ + "label_id": { + "type": "string", + "description": "Label ID.", + "example": "", + }, + }, + ), + client_op( + "create_gmail_label", + "create_label", + description=( + "Create a new user label. label_list_visibility: " + "labelShow|labelShowIfUnread|labelHide. " + "message_list_visibility: show|hide." + ), + parallelizable=False, + tags=("gmail_labels", "gmail"), + unwrap_envelope=True, + fail_message="Failed to create label.", + input_schema={ + "name": { + "type": "string", + "description": "Label name (use '/' for nesting, e.g. 'Work/Clients').", + "example": "Receipts", + }, + "label_list_visibility": { + "type": "string", + "description": "labelShow / labelShowIfUnread / labelHide.", + "example": "labelShow", + }, + "message_list_visibility": { + "type": "string", + "description": "show / hide.", + "example": "show", + }, + "background_color": { + "type": "string", + "description": "Hex color (optional, requires text_color).", + "example": "", + }, + "text_color": { + "type": "string", + "description": "Hex color (optional, requires background_color).", + "example": "", + }, + }, + arg_map=lambda d: { + "name": d["name"], + "label_list_visibility": d.get("label_list_visibility", "labelShow"), + "message_list_visibility": d.get("message_list_visibility", "show"), + "background_color": d.get("background_color") or None, + "text_color": d.get("text_color") or None, + }, + ), + client_op( + "update_gmail_label", + "update_label", + description="Update (rename / recolor) a Gmail label.", + parallelizable=False, + tags=("gmail_labels",), + unwrap_envelope=True, + fail_message="Failed to update label.", + input_schema={ + "label_id": { + "type": "string", + "description": "Label ID.", + "example": "", + }, + "name": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "label_list_visibility": { + "type": "string", + "description": "labelShow / labelShowIfUnread / labelHide.", + "example": "", + }, + "message_list_visibility": { + "type": "string", + "description": "show / hide.", + "example": "", + }, + "background_color": { + "type": "string", + "description": "Hex color (optional).", + "example": "", + }, + "text_color": { + "type": "string", + "description": "Hex color (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "label_id": d["label_id"], + "name": d.get("name") or None, + "label_list_visibility": d.get("label_list_visibility") or None, + "message_list_visibility": d.get("message_list_visibility") or None, + "background_color": d.get("background_color") or None, + "text_color": d.get("text_color") or None, + }, + ), + client_op( + "delete_gmail_label", + "delete_label", + description="Delete a Gmail label (also removes it from all messages/threads).", + destructive=True, # permanent delete + parallelizable=False, + tags=("gmail_labels",), + unwrap_envelope=True, + fail_message="Failed to delete label.", + input_schema={ + "label_id": { + "type": "string", + "description": "Label ID.", + "example": "", + }, + }, + ), + # ── Attachments + profile ──────────────────────────────────────── + client_op( + "download_gmail_attachment", + "download_attachment", + description=( + "Download a Gmail attachment to a local path. " + "First call get_gmail with full_body=true to get the attachments list — " + "each entry has attachment_id and filename. " + "Pass save_to as a directory path and filename separately, or as a full file path." + ), + parallelizable=False, + tags=("gmail_attachments", "gmail"), + unwrap_envelope=True, + fail_message="Failed to download attachment.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "attachment_id": { + "type": "string", + "description": "Attachment ID from get_gmail(full_body=true).attachments[].attachment_id.", + "example": "", + }, + "save_to": { + "type": "string", + "description": "Local path to save to. May be a directory; use filename to set the file name.", + "example": "C:/Users/me/downloads/", + }, + "filename": { + "type": "string", + "description": "Filename to use when save_to is a directory. Use the filename from get_gmail attachments list.", + "example": "invoice.pdf", + }, + }, + ), + client_op( + "get_gmail_profile", + "get_profile", + description=( + "Get the authenticated user's Gmail profile: email address, " + "message/thread totals, historyId." + ), + tags=("gmail_mail", "gmail"), + unwrap_envelope=True, + fail_message="Failed to get profile.", + input_schema={}, + ), + ] diff --git a/craftos_integrations/providers/gmail/provider.py b/craftos_integrations/providers/gmail/provider.py new file mode 100644 index 00000000..63a73ae1 --- /dev/null +++ b/craftos_integrations/providers/gmail/provider.py @@ -0,0 +1,43 @@ +"""Gmail provider — the multi-account reference implementation. + +API surface comes from the legacy ``GmailClient`` (all Gmail REST methods +live there and are unchanged); this class only rebinds its credential +plumbing to the injected per-account credential. +""" + +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from ...contracts import Operation +from ...integrations._google_common import GMAIL_SCOPES +from ...integrations.gmail import GmailClient +from .._google import GoogleProviderBase, GoogleClientBinding, read_guidance +from .listener import GmailListener +from .operations import build_operations + + +class BoundGmailClient(GoogleClientBinding, GmailClient): + """GmailClient with per-account credential binding (see GoogleClientBinding).""" + + +class GmailProvider(GoogleProviderBase): + id = "gmail" + display_name = "Gmail" + scopes = GMAIL_SCOPES + client_cls = BoundGmailClient + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> GmailListener: + """INBOX poll listener (legacy loop re-homed — see listener.py).""" + return GmailListener(client, cursor, emit) diff --git a/craftos_integrations/providers/google_calendar/GUIDANCE.md b/craftos_integrations/providers/google_calendar/GUIDANCE.md new file mode 100644 index 00000000..cd341960 --- /dev/null +++ b/craftos_integrations/providers/google_calendar/GUIDANCE.md @@ -0,0 +1,44 @@ +# Google Calendar + +Events, free/busy availability, Meet links, calendar sharing and settings. + +## Multi-account +- Every Calendar action accepts an optional `account` (email, nickname, or a + unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my school calendar", "the + work account"), pass it as `account` — never silently default to primary. +- Event and calendar ids are **account-scoped**: an id returned by + `list_google_calendar_events` with `account="work"` must be used with + `account="work"` on every follow-up action (get/update/delete/etc.). + Note `calendar_id="primary"` names a *different* calendar on each account. +- For destructive actions (`delete_google_calendar_event`, + `delete_google_calendar`, `clear_google_calendar`, + `delete_google_calendar_acl_rule`) with multiple accounts connected and + no account named: ask the user which account before acting. + +## Behavior +- `calendar_id` defaults to `"primary"` — the connected account's main + calendar. Don't ask which calendar to use unless the user explicitly + mentions a shared one. Other calendar IDs are email-like + (e.g. `team@group.calendar.google.com`); discover them via + `list_google_calendars`. +- Event IDs are opaque Google strings. Pull them from + `list_google_calendar_events` / `get_google_calendar_event`; never + construct them. +- Times are ISO 8601 with timezone (e.g. `2026-05-20T09:00:00-04:00` or + `...Z`). The integration knows the connected account's email but NOT its + default timezone — if the user gives a bare time ("3pm"), establish the + timezone first (`get_google_calendar_setting` with + `setting_id="timezone"` returns it). +- Recurring events expand on read: `list_google_calendar_events` returns + expanded single instances, each with its own `id`. Deleting one instance + does not affect the series; use `list_google_calendar_event_instances` + to enumerate a series. +- Meet links: use `create_google_meet` (or pass a + `conferenceData.createRequest` block in `event_data` to + `create_google_calendar_event`). The returned `hangoutLink` is the share + URL — never construct meeting URLs by hand. +- No event listening: Calendar never pushes incoming changes. Don't promise + the user "I'll notify you when X is scheduled." +- The connected account's own email is known to the integration — never ask + the user for "your email" to invite themselves. diff --git a/craftos_integrations/providers/google_calendar/__init__.py b/craftos_integrations/providers/google_calendar/__init__.py new file mode 100644 index 00000000..9f120772 --- /dev/null +++ b/craftos_integrations/providers/google_calendar/__init__.py @@ -0,0 +1,3 @@ +from .provider import GoogleCalendarProvider + +__all__ = ["GoogleCalendarProvider"] diff --git a/craftos_integrations/providers/google_calendar/operations.py b/craftos_integrations/providers/google_calendar/operations.py new file mode 100644 index 00000000..e1d1df67 --- /dev/null +++ b/craftos_integrations/providers/google_calendar/operations.py @@ -0,0 +1,1232 @@ +"""Google Calendar operations — ported from google_calendar_actions.py. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). + +The legacy actions post-process results (``pick_result`` key reduction on +writes, lean-event reduction on reads); ``_with_post`` reproduces that on +top of the declarative ``client_op`` so ported operations return dicts +identical to what agents already expect. +""" + +from __future__ import annotations + +import asyncio +import uuid +from dataclasses import replace +from datetime import datetime +from typing import Any, Callable, Dict, List, Sequence + +from ...contracts import Operation +from .._shared import STATUS_OUTPUT, client_op, shape_result + +# The id + key fields writes return (agents fetch the full object with the +# matching get_* operation) — mirrors the legacy pick_result key list. +KEY_EVENT_FIELDS = ("id", "summary", "start", "end", "htmlLink", "hangoutLink", "status") + + +def _lean_event(ev: Dict[str, Any]) -> Dict[str, Any]: + """Reduce a raw Calendar Event resource to the fields an agent acts on.""" + out = { + k: ev.get(k) + for k in ( + "id", + "summary", + "description", + "location", + "start", + "end", + "status", + "recurrence", + "recurringEventId", + "htmlLink", + "hangoutLink", + ) + if ev.get(k) is not None + } + attendees = ev.get("attendees") + if attendees: + out["attendees"] = [ + { + k: a.get(k) + for k in ("email", "displayName", "responseStatus", "organizer") + if a.get(k) is not None + } + for a in attendees + if isinstance(a, dict) + ] + return out + + +def _pick_result(res: Dict[str, Any], keys: Sequence[str]) -> Dict[str, Any]: + """Reduce a successful result to the named top-level keys (legacy + pick_result: non-dict results, errors, and missing keys pass through).""" + if res.get("status") == "success" and isinstance(res.get("result"), dict): + r = res["result"] + picked = {k: r.get(k) for k in keys if r.get(k) is not None} + if picked: + res = {**res, "result": picked} + return res + + +def _with_post( + op: Operation, + post: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], +) -> Operation: + """Wrap an operation's fn with a (result, input_data) post-processor.""" + inner = op.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + return post(await inner(client, input_data), input_data) + + return replace(op, fn=fn) + + +def _pick_event(op: Operation) -> Operation: + return _with_post(op, lambda res, _d: _pick_result(res, KEY_EVENT_FIELDS)) + + +def _lean_list_post(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + res = { + **res, + "result": [_lean_event(e) for e in items if isinstance(e, dict)], + } + return res + + +def _lean_single_post(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if not input_data.get("include_metadata") and res.get("status") == "success": + ev = res.get("result") + if isinstance(ev, dict): + res = {**res, "result": _lean_event(ev)} + return res + + +def _lean_instances_post( + res: Dict[str, Any], input_data: Dict[str, Any] +) -> Dict[str, Any]: + if not input_data.get("include_metadata") and res.get("status") == "success": + result = res.get("result") + if isinstance(result, dict) and isinstance(result.get("instances"), list): + res = { + **res, + "result": { + "instances": [ + _lean_event(e) + for e in result["instances"] + if isinstance(e, dict) + ] + }, + } + return res + + +# ── composite: check_availability_and_schedule ────────────────────────── + + +async def _check_availability_and_schedule( + client: Any, input_data: Dict[str, Any] +) -> Dict[str, Any]: + try: + start_time = datetime.fromisoformat(input_data["start_time"]) + end_time = datetime.fromisoformat(input_data["end_time"]) + except Exception as e: + return {"status": "error", "message": str(e)} + + try: + raw = await asyncio.to_thread( + client.check_availability, + calendar_id="primary", + time_min=start_time.isoformat() + "Z", + time_max=end_time.isoformat() + "Z", + ) + except Exception as e: + return {"status": "error", "message": str(e)} + avail = shape_result( + raw, unwrap_envelope=True, fail_message="Google Calendar FreeBusy API error" + ) + if avail["status"] == "error": + return { + "status": "error", + "reason": "Google Calendar FreeBusy API error", + "details": avail, + } + + busy_slots = ( + avail.get("result", {}).get("calendars", {}).get("primary", {}).get("busy", []) + ) + if busy_slots: + return { + "status": "busy", + "reason": "Time slot is already occupied", + "conflicting_events": busy_slots, + } + + attendees = input_data.get("attendees") or [] + event_payload = { + "summary": input_data["summary"], + "description": input_data.get("description", ""), + "start": {"dateTime": start_time.isoformat() + "Z", "timeZone": "UTC"}, + "end": {"dateTime": end_time.isoformat() + "Z", "timeZone": "UTC"}, + "attendees": [{"email": a} for a in attendees], + "conferenceData": { + "createRequest": { + "requestId": f"meet-{uuid.uuid4()}", + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + }, + } + try: + raw = await asyncio.to_thread( + client.create_meet_event, calendar_id="primary", event_data=event_payload + ) + except Exception as e: + return {"status": "error", "message": str(e)} + result = shape_result( + raw, unwrap_envelope=True, fail_message="Google Calendar API error" + ) + if result["status"] == "error": + return { + "status": "error", + "reason": "Google Calendar API error", + "details": result, + } + event = result.get("result", result) + if isinstance(event, dict): + event = { + k: event.get(k) + for k in ("id", "hangoutLink", "htmlLink", "start", "end") + if event.get(k) is not None + } + return { + "status": "success", + "reason": "Meeting scheduled successfully.", + "event": event, + } + + +# ── shared schema fragments ───────────────────────────────────────────── + +_CAL_ID_DEFAULT = { + "type": "string", + "description": "Calendar ID (default: primary).", + "example": "primary", +} +_SEND_UPDATES = { + "type": "string", + "description": "none, all, externalOnly.", + "example": "none", +} + + +def build_operations() -> List[Operation]: + return [ + # ── Convenience helpers ───────────────────────────────────────── + _pick_event( + client_op( + "create_google_meet", + "create_meet_event", + description=( + "Create a Google Calendar event with a Google Meet link. " + "Returns id, hangoutLink + key fields." + ), + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to create event.", + input_schema={ + "event_data": { + "type": "object", + "description": ( + "Calendar event data with summary, start, end, " + "conferenceData." + ), + "example": {}, + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "result": { + "type": "object", + "example": { + "id": "...", + "hangoutLink": "https://meet.google.com/...", + }, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_data": d.get("event_data"), + }, + ) + ), + client_op( + "check_calendar_availability", + "check_availability", + description="Check Google Calendar free/busy availability.", + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to check availability.", + input_schema={ + "time_min": { + "type": "string", + "description": "Start time in ISO 8601 format.", + "example": "2024-01-15T09:00:00Z", + }, + "time_max": { + "type": "string", + "description": "End time in ISO 8601 format.", + "example": "2024-01-15T17:00:00Z", + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "time_min": d.get("time_min"), + "time_max": d.get("time_max"), + }, + ), + Operation( + name="check_availability_and_schedule", + description="Schedule meeting if free.", + input_schema={ + "start_time": { + "type": "string", + "description": "Start time.", + "example": "2024-01-01T10:00:00", + }, + "end_time": { + "type": "string", + "description": "End time.", + "example": "2024-01-01T11:00:00", + }, + "summary": { + "type": "string", + "description": "Summary.", + "example": "Meeting", + }, + "description": { + "type": "string", + "description": "Description.", + "example": "Details", + }, + "attendees": { + "type": "array", + "description": "Attendees.", + "example": ["a@b.com"], + }, + "from_email": { + "type": "string", + "description": "Sender.", + "example": "me@example.com", + }, + }, + output_schema=dict(STATUS_OUTPUT), + fn=_check_availability_and_schedule, + tags=("google_calendar_events", "google_calendar"), + ), + # ── Events ────────────────────────────────────────────────────── + _with_post( + client_op( + "list_google_calendar_events", + "list_events", + description=( + "List events on a calendar between time_min and time_max. " + "Returns expanded single events sorted by start time. Lean " + "event fields by default (id, summary, description, " + "location, start, end, status, attendees, recurrence, " + "htmlLink, hangoutLink); set include_metadata for raw " + "Event resources." + ), + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to list events.", + input_schema={ + "calendar_id": dict(_CAL_ID_DEFAULT), + "time_min": { + "type": "string", + "description": "ISO 8601 lower bound (optional).", + "example": "2026-05-20T00:00:00Z", + }, + "time_max": { + "type": "string", + "description": "ISO 8601 upper bound (optional).", + "example": "2026-05-27T00:00:00Z", + }, + "max_results": { + "type": "integer", + "description": "Max events to return.", + "example": 50, + }, + "include_metadata": { + "type": "boolean", + "description": ( + "Return full raw Event resources (default false = lean)." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "time_min": d.get("time_min"), + "time_max": d.get("time_max"), + "max_results": d.get("max_results", 50), + }, + ), + _lean_list_post, + ), + _with_post( + client_op( + "get_google_calendar_event", + "get_event", + description=( + "Get a single event by ID. Lean event fields by default; " + "set include_metadata for the raw Event resource." + ), + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to get event.", + input_schema={ + "event_id": { + "type": "string", + "description": "Event ID.", + "example": "", + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "include_metadata": { + "type": "boolean", + "description": ( + "Return the full raw Event resource (default false = lean)." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "event_id": d["event_id"], + "calendar_id": d.get("calendar_id", "primary"), + }, + ), + _lean_single_post, + ), + _pick_event( + client_op( + "create_google_calendar_event", + "insert_event", + description=( + "Create a calendar event. event_data is the full Event " + "resource (summary, start, end, attendees, etc.). Use " + "create_google_meet for events with a Meet link. Returns " + "id + key fields." + ), + parallelizable=False, + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to create event.", + input_schema={ + "event_data": { + "type": "object", + "description": ( + "Event resource: summary, description, start, end, " + "attendees, recurrence, etc." + ), + "example": {}, + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "send_updates": { + "type": "string", + "description": "none, all, or externalOnly — who gets notified.", + "example": "none", + }, + "supports_attachments": { + "type": "boolean", + "description": "Set true if event_data includes attachments.", + "example": False, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_data": d["event_data"], + "send_updates": d.get("send_updates", "none"), + "supports_attachments": bool(d.get("supports_attachments", False)), + }, + ) + ), + _pick_event( + client_op( + "update_google_calendar_event", + "update_event", + description=( + "Replace an event entirely (PUT). For partial updates use " + "patch_google_calendar_event. Returns id + key fields." + ), + parallelizable=False, + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to update event.", + input_schema={ + "event_id": { + "type": "string", + "description": "Event ID.", + "example": "", + }, + "event_data": { + "type": "object", + "description": "Full Event resource — replaces existing.", + "example": {}, + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "send_updates": dict(_SEND_UPDATES), + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_id": d["event_id"], + "event_data": d["event_data"], + "send_updates": d.get("send_updates", "none"), + }, + ) + ), + _pick_event( + client_op( + "patch_google_calendar_event", + "patch_event", + description=( + "Patch (partial update) an event. event_data contains ONLY " + "the fields to change. Returns id + key fields." + ), + parallelizable=False, + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to patch event.", + input_schema={ + "event_id": { + "type": "string", + "description": "Event ID.", + "example": "", + }, + "event_data": { + "type": "object", + "description": "Partial event fields to update.", + "example": {"summary": "New title"}, + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "send_updates": dict(_SEND_UPDATES), + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_id": d["event_id"], + "event_data": d["event_data"], + "send_updates": d.get("send_updates", "none"), + }, + ) + ), + client_op( + "delete_google_calendar_event", + "delete_event", + description="Delete a calendar event.", + destructive=True, + parallelizable=False, + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to delete event.", + input_schema={ + "event_id": { + "type": "string", + "description": "Event ID.", + "example": "", + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + }, + arg_map=lambda d: { + "event_id": d["event_id"], + "calendar_id": d.get("calendar_id", "primary"), + }, + ), + _pick_event( + client_op( + "move_google_calendar_event", + "move_event", + description=( + "Move an event from one calendar to another. Returns id + " + "key fields." + ), + parallelizable=False, + tags=("google_calendar_events",), + unwrap_envelope=True, + fail_message="Failed to move event.", + input_schema={ + "event_id": { + "type": "string", + "description": "Event ID.", + "example": "", + }, + "calendar_id": { + "type": "string", + "description": "Current calendar ID.", + "example": "primary", + }, + "destination_calendar_id": { + "type": "string", + "description": "Target calendar ID.", + "example": "", + }, + "send_updates": dict(_SEND_UPDATES), + }, + arg_map=lambda d: { + "event_id": d["event_id"], + "calendar_id": d.get("calendar_id", "primary"), + "destination_calendar_id": d["destination_calendar_id"], + "send_updates": d.get("send_updates", "none"), + }, + ) + ), + _pick_event( + client_op( + "quick_add_google_calendar_event", + "quick_add_event", + description=( + "Create an event from a natural-language string (e.g. " + "'Lunch with Alice tomorrow at noon'). Returns id + key " + "fields." + ), + parallelizable=False, + tags=("google_calendar_events", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to quick-add event.", + input_schema={ + "text": { + "type": "string", + "description": "Natural-language event description.", + "example": "Lunch with Alice tomorrow at noon", + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "send_updates": dict(_SEND_UPDATES), + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "text": d["text"], + "send_updates": d.get("send_updates", "none"), + }, + ) + ), + _with_post( + client_op( + "list_google_calendar_event_instances", + "list_event_instances", + description=( + "Expand a recurring event into its individual instances. " + "Lean event fields by default; set include_metadata for " + "raw Event resources." + ), + tags=("google_calendar_events",), + unwrap_envelope=True, + fail_message="Failed to list instances.", + input_schema={ + "event_id": { + "type": "string", + "description": "Recurring event ID.", + "example": "", + }, + "calendar_id": dict(_CAL_ID_DEFAULT), + "time_min": { + "type": "string", + "description": "ISO 8601 lower bound (optional).", + "example": "", + }, + "time_max": { + "type": "string", + "description": "ISO 8601 upper bound (optional).", + "example": "", + }, + "max_results": { + "type": "integer", + "description": "Max instances.", + "example": 50, + }, + "include_metadata": { + "type": "boolean", + "description": ( + "Return full raw Event resources (default false = lean)." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_id": d["event_id"], + "time_min": d.get("time_min"), + "time_max": d.get("time_max"), + "max_results": d.get("max_results", 50), + }, + ), + _lean_instances_post, + ), + _pick_event( + client_op( + "import_google_calendar_event", + "import_event", + description=( + "Import a pre-existing event (with its own iCal UID) into " + "a calendar — preserves identity across calendars. " + "Distinct from create. Returns id + key fields." + ), + parallelizable=False, + tags=("google_calendar_events",), + unwrap_envelope=True, + fail_message="Failed to import event.", + input_schema={ + "event_data": { + "type": "object", + "description": "Event resource including iCalUID.", + "example": {}, + }, + "calendar_id": { + "type": "string", + "description": "Target calendar ID.", + "example": "primary", + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "event_data": d["event_data"], + }, + ) + ), + # ── Calendars (the calendar resources themselves) ─────────────── + client_op( + "list_google_calendars", + "list_calendars", + description=( + "List calendars the user has access to (from their calendarList)." + ), + tags=("google_calendar_admin", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to list calendars.", + input_schema={}, + arg_map=lambda d: {}, + ), + client_op( + "get_google_calendar", + "get_calendar", + description=( + "Get metadata for a single calendar (summary, timezone, description)." + ), + tags=("google_calendar_admin", "google_calendar"), + unwrap_envelope=True, + fail_message="Failed to get calendar.", + input_schema={ + "calendar_id": dict(_CAL_ID_DEFAULT), + }, + arg_map=lambda d: {"calendar_id": d.get("calendar_id", "primary")}, + ), + client_op( + "create_google_calendar", + "create_calendar", + description=( + "Create a new (secondary) calendar owned by the authenticated user." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to create calendar.", + input_schema={ + "summary": { + "type": "string", + "description": "Calendar name.", + "example": "Team events", + }, + "description": { + "type": "string", + "description": "Description (optional).", + "example": "", + }, + "time_zone": { + "type": "string", + "description": "IANA tz (optional, e.g. Asia/Tokyo).", + "example": "UTC", + }, + "location": { + "type": "string", + "description": "Default location (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "summary": d["summary"], + "description": d.get("description") or None, + "time_zone": d.get("time_zone") or None, + "location": d.get("location") or None, + }, + ), + client_op( + "update_google_calendar", + "update_calendar", + description=( + "Replace a calendar's metadata (PUT). For partial updates use " + "patch_google_calendar." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to update calendar.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "", + }, + "summary": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "description": { + "type": "string", + "description": "New description (optional).", + "example": "", + }, + "time_zone": { + "type": "string", + "description": "New IANA tz (optional).", + "example": "", + }, + "location": { + "type": "string", + "description": "New location (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "calendar_id": d["calendar_id"], + "summary": d.get("summary") or None, + "description": d["description"] if "description" in d else None, + "time_zone": d.get("time_zone") or None, + "location": d["location"] if "location" in d else None, + }, + ), + client_op( + "patch_google_calendar", + "patch_calendar", + description="Patch (partial update) a calendar's metadata.", + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to patch calendar.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "", + }, + "summary": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "description": { + "type": "string", + "description": "New description (optional).", + "example": "", + }, + "time_zone": { + "type": "string", + "description": "New IANA tz (optional).", + "example": "", + }, + "location": { + "type": "string", + "description": "New location (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "calendar_id": d["calendar_id"], + "summary": d.get("summary") or None, + "description": d["description"] if "description" in d else None, + "time_zone": d.get("time_zone") or None, + "location": d["location"] if "location" in d else None, + }, + ), + client_op( + "delete_google_calendar", + "delete_calendar", + description=( + "DELETE a secondary calendar. Cannot be used on the primary calendar." + ), + destructive=True, + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to delete calendar.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID to delete.", + "example": "", + }, + }, + arg_map=lambda d: {"calendar_id": d["calendar_id"]}, + ), + client_op( + "clear_google_calendar", + "clear_calendar", + description=( + "Delete ALL events on the user's PRIMARY calendar. " + "Irreversible. No-op on secondary calendars." + ), + destructive=True, + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to clear calendar.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Must be 'primary'.", + "example": "primary", + }, + }, + arg_map=lambda d: {"calendar_id": d.get("calendar_id", "primary")}, + ), + # ── CalendarList (subscriptions, colors, visibility) ──────────── + client_op( + "get_google_calendar_list_entry", + "get_calendar_list_entry", + description=( + "Get the user's per-calendar settings (color, visibility, " + "summary override)." + ), + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to get calendar list entry.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "", + }, + }, + arg_map=lambda d: {"calendar_id": d["calendar_id"]}, + ), + client_op( + "subscribe_google_calendar", + "subscribe_calendar", + description=( + "Subscribe to (add to the user's calendar list) an existing " + "calendar by ID." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to subscribe to calendar.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID to subscribe to.", + "example": "", + }, + "color_id": { + "type": "string", + "description": ( + "Color ID from get_google_calendar_colors (optional)." + ), + "example": "", + }, + "summary_override": { + "type": "string", + "description": "User-side display name (optional).", + "example": "", + }, + "selected": { + "type": "boolean", + "description": "Show in UI (optional).", + "example": True, + }, + "hidden": { + "type": "boolean", + "description": "Hide from UI (optional).", + "example": False, + }, + }, + arg_map=lambda d: { + "calendar_id": d["calendar_id"], + "color_id": d.get("color_id") or None, + "summary_override": d.get("summary_override") or None, + "selected": d["selected"] if "selected" in d else None, + "hidden": d["hidden"] if "hidden" in d else None, + }, + ), + client_op( + "update_google_calendar_list_entry", + "update_calendar_list_entry", + description=( + "Update the user's per-calendar settings (color, visibility, " + "display name)." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to update calendar list entry.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "", + }, + "color_id": { + "type": "string", + "description": "Color ID (optional).", + "example": "", + }, + "summary_override": { + "type": "string", + "description": "Display name (optional).", + "example": "", + }, + "selected": { + "type": "boolean", + "description": "Show in UI (optional).", + "example": True, + }, + "hidden": { + "type": "boolean", + "description": "Hide from UI (optional).", + "example": False, + }, + }, + arg_map=lambda d: { + "calendar_id": d["calendar_id"], + "color_id": d.get("color_id") or None, + "summary_override": d["summary_override"] + if "summary_override" in d + else None, + "selected": d["selected"] if "selected" in d else None, + "hidden": d["hidden"] if "hidden" in d else None, + }, + ), + client_op( + "unsubscribe_google_calendar", + "unsubscribe_calendar", + description=( + "Remove a calendar from the user's calendar list. Does NOT " + "delete the calendar itself." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to unsubscribe.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID to unsubscribe from.", + "example": "", + }, + }, + arg_map=lambda d: {"calendar_id": d["calendar_id"]}, + ), + # ── ACL (per-calendar sharing) ────────────────────────────────── + client_op( + "list_google_calendar_acl", + "list_calendar_acl", + description="List ACL rules (who has what access) on a calendar.", + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to list ACL.", + input_schema={ + "calendar_id": dict(_CAL_ID_DEFAULT), + }, + arg_map=lambda d: {"calendar_id": d.get("calendar_id", "primary")}, + ), + client_op( + "get_google_calendar_acl_rule", + "get_calendar_acl_rule", + description="Get a single ACL rule by ID.", + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to get ACL rule.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "primary", + }, + "rule_id": { + "type": "string", + "description": "ACL rule ID.", + "example": "", + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "rule_id": d["rule_id"], + }, + ), + client_op( + "add_google_calendar_acl_rule", + "add_calendar_acl_rule", + description=( + "Grant calendar access. scope_type: user/group/domain/default. " + "role: none/freeBusyReader/reader/writer/owner." + ), + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to add ACL rule.", + input_schema={ + "calendar_id": dict(_CAL_ID_DEFAULT), + "scope_type": { + "type": "string", + "description": "user, group, domain, or default.", + "example": "user", + }, + "scope_value": { + "type": "string", + "description": ( + "Email, group address, or domain (empty for 'default')." + ), + "example": "alice@example.com", + }, + "role": { + "type": "string", + "description": "none, freeBusyReader, reader, writer, or owner.", + "example": "reader", + }, + "send_notifications": { + "type": "boolean", + "description": "Email the grantee.", + "example": True, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "scope_type": d["scope_type"], + "scope_value": d.get("scope_value", ""), + "role": d["role"], + "send_notifications": bool(d.get("send_notifications", True)), + }, + ), + client_op( + "update_google_calendar_acl_rule", + "update_calendar_acl_rule", + description="Change the role of an existing ACL rule.", + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to update ACL rule.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "primary", + }, + "rule_id": { + "type": "string", + "description": "ACL rule ID.", + "example": "", + }, + "role": { + "type": "string", + "description": "New role.", + "example": "writer", + }, + "scope_type": { + "type": "string", + "description": "New scope type (optional).", + "example": "", + }, + "scope_value": { + "type": "string", + "description": "New scope value (optional).", + "example": "", + }, + "send_notifications": { + "type": "boolean", + "description": "Email the grantee.", + "example": True, + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "rule_id": d["rule_id"], + "role": d["role"], + "scope_type": d.get("scope_type") or None, + "scope_value": d.get("scope_value") or None, + "send_notifications": bool(d.get("send_notifications", True)), + }, + ), + client_op( + "delete_google_calendar_acl_rule", + "delete_calendar_acl_rule", + description="Revoke access by deleting an ACL rule.", + destructive=True, + parallelizable=False, + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to delete ACL rule.", + input_schema={ + "calendar_id": { + "type": "string", + "description": "Calendar ID.", + "example": "primary", + }, + "rule_id": { + "type": "string", + "description": "ACL rule ID.", + "example": "", + }, + }, + arg_map=lambda d: { + "calendar_id": d.get("calendar_id", "primary"), + "rule_id": d["rule_id"], + }, + ), + # ── Settings & colors ─────────────────────────────────────────── + client_op( + "list_google_calendar_settings", + "list_calendar_settings", + description=( + "List the authenticated user's Calendar settings (timezone, " + "locale, weekStart, etc.) as a dict." + ), + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to list settings.", + input_schema={}, + arg_map=lambda d: {}, + ), + client_op( + "get_google_calendar_setting", + "get_calendar_setting", + description=( + "Get a single user setting by ID. Common IDs: timezone, " + "locale, autoAddHangouts, weekStart." + ), + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to get setting.", + input_schema={ + "setting_id": { + "type": "string", + "description": "Setting ID.", + "example": "timezone", + }, + }, + arg_map=lambda d: {"setting_id": d["setting_id"]}, + ), + client_op( + "get_google_calendar_colors", + "get_calendar_colors", + description=( + "Get the color palette available for calendars and events " + "(color_id → hex map)." + ), + tags=("google_calendar_admin",), + unwrap_envelope=True, + fail_message="Failed to get colors.", + input_schema={}, + arg_map=lambda d: {}, + ), + ] diff --git a/craftos_integrations/providers/google_calendar/provider.py b/craftos_integrations/providers/google_calendar/provider.py new file mode 100644 index 00000000..b32a4eb2 --- /dev/null +++ b/craftos_integrations/providers/google_calendar/provider.py @@ -0,0 +1,33 @@ +"""Google Calendar provider — multi-account port of the legacy calendar integration. + +API surface comes from the legacy ``GoogleCalendarClient`` (all Calendar +REST methods live there and are unchanged); this class only rebinds its +credential plumbing to the injected per-account credential. +""" + +from __future__ import annotations + +from typing import List + +from ...contracts import Operation +from ...integrations._google_common import CALENDAR_SCOPES +from ...integrations.google_calendar import GoogleCalendarClient +from .._google import GoogleProviderBase, GoogleClientBinding, read_guidance +from .operations import build_operations + + +class BoundGoogleCalendarClient(GoogleClientBinding, GoogleCalendarClient): + """GoogleCalendarClient with per-account credential binding (see GoogleClientBinding).""" + + +class GoogleCalendarProvider(GoogleProviderBase): + id = "google_calendar" + display_name = "Google Calendar" + scopes = CALENDAR_SCOPES + client_cls = BoundGoogleCalendarClient + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) diff --git a/craftos_integrations/providers/google_docs/GUIDANCE.md b/craftos_integrations/providers/google_docs/GUIDANCE.md new file mode 100644 index 00000000..c2387e36 --- /dev/null +++ b/craftos_integrations/providers/google_docs/GUIDANCE.md @@ -0,0 +1,37 @@ +# Google Docs + +Documents — create, read, edit, style, tables, images, export. + +## Multi-account +- Every Google Docs action accepts an optional `account` (email, nickname, + or a unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my school account", "the + work Drive"), pass it as `account` — never silently default to primary. +- Document ids are **account-scoped**: an id returned by + `list_google_docs` or `search_google_docs` with `account="work"` must be + used with `account="work"` on every follow-up action + (get/append/style/delete/export/etc.). +- For destructive actions (deletes, range deletes) with multiple accounts + connected and no account named: ask the user which account before + acting. + +## Behavior +- Document IDs are long opaque strings (embedded in URLs as + `/document/d/{id}/edit`). Never construct them — discover via + `search_google_docs` (title fragment) or `list_google_docs`. +- `append_to_google_doc` is not idempotent: it reads the doc's current + end-index, then inserts. If an append errored but may have landed + server-side, verify with `get_google_doc_text` before retrying. +- `get_google_doc_text` (and the default `get_google_doc`) flatten body + text only — tables, images, and embedded objects are dropped. For + structured reads (needed for index-based edits) use `get_google_doc` + with `include_metadata=true` 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. +- The connected account's email comes from the credential — never ask the + user for it. +- Uses the broad Drive scope so list/search can see docs the user already + owns (not just integration-created files); the OAuth consent screen may + show an "unverified app" warning. +- No event listening — Docs is purely request-response. diff --git a/craftos_integrations/providers/google_docs/__init__.py b/craftos_integrations/providers/google_docs/__init__.py new file mode 100644 index 00000000..e900e859 --- /dev/null +++ b/craftos_integrations/providers/google_docs/__init__.py @@ -0,0 +1,3 @@ +from .provider import GoogleDocsProvider + +__all__ = ["GoogleDocsProvider"] diff --git a/craftos_integrations/providers/google_docs/operations.py b/craftos_integrations/providers/google_docs/operations.py new file mode 100644 index 00000000..eb4c79ce --- /dev/null +++ b/craftos_integrations/providers/google_docs/operations.py @@ -0,0 +1,1046 @@ +"""Google Docs operations — ported from the legacy google_docs_actions.py. + +Faithful port: names, descriptions, schemas, arg mapping, and result +shaping match the legacy actions one-to-one. Deletes are flagged +``destructive=True`` (wrong-account mistakes can't be undone through the +API) and stay ``parallelizable=False`` like the legacy actions. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List + +from ...contracts import Operation +from .._shared import STATUS_OUTPUT, client_op, shape_result + +_DOC_ID = { + "type": "string", + "description": "The Google Doc's document ID.", + "example": "1abcDEF...", +} +_DOC_ID_SHORT = { + "type": "string", + "description": "Document ID.", + "example": "1abcDEF...", +} + + +def _get_google_doc_op() -> Operation: + """``get_google_doc`` needs post-processing (the include_metadata + flatten), so it is hand-written instead of using ``client_op``. + + Behavior matches the legacy action: default returns the body + flattened to plain text (the client's ``get_document_text`` uses the + identical flattening); ``include_metadata=True`` returns the raw + structured document JSON from ``get_document``. + """ + + input_schema = { + "document_id": dict(_DOC_ID), + "include_metadata": { + "type": "boolean", + "description": ( + "Return the full structured document JSON " + "(default false = plain text)." + ), + "example": False, + }, + } + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + try: + if input_data.get("include_metadata"): + raw = await asyncio.to_thread( + client.get_document, document_id=input_data["document_id"] + ) + else: + raw = await asyncio.to_thread( + client.get_document_text, document_id=input_data["document_id"] + ) + return shape_result( + raw, + unwrap_envelope=True, + fail_message="Failed to fetch document.", + ) + except Exception as e: + return {"status": "error", "message": str(e)} + + return Operation( + name="get_google_doc", + description=( + "Fetch a Google Doc. Default returns {document_id, title, text} " + "(body flattened to plain text); set include_metadata for the raw " + "structured JSON (needed for index-based edits)." + ), + input_schema=input_schema, + output_schema=dict(STATUS_OUTPUT), + fn=fn, + tags=("google_docs_files", "google_docs"), + ) + + +def build_operations() -> List[Operation]: + return [ + # ── File-level: create / get / list / search / delete / copy / export + client_op( + "create_google_doc", + "create_document", + description=( + "Create a new blank Google Doc with the given title. Returns " + "the document ID and editable URL." + ), + tags=("google_docs_files", "google_docs"), + unwrap_envelope=True, + fail_message="Failed to create Google Doc.", + input_schema={ + "title": { + "type": "string", + "description": "Title for the new document.", + "example": "Meeting Notes", + }, + }, + ), + _get_google_doc_op(), + client_op( + "get_google_doc_text", + "get_document_text", + description=( + "Get a Google Doc as plain text. Returns title and the doc " + "body flattened to a string." + ), + tags=("google_docs_files", "google_docs"), + unwrap_envelope=True, + fail_message="Failed to read document.", + input_schema={"document_id": dict(_DOC_ID)}, + ), + client_op( + "list_google_docs", + "list_documents", + description=( + "List Google Docs the user owns or has access to, most " + "recent first." + ), + tags=("google_docs_files", "google_docs"), + unwrap_envelope=True, + fail_message="Failed to list docs.", + input_schema={ + "max_results": { + "type": "integer", + "description": "Max number of docs to return.", + "example": 50, + }, + }, + arg_map=lambda d: {"max_results": d.get("max_results", 50)}, + ), + client_op( + "search_google_docs", + "search_documents", + description="Search for Google Docs by title fragment.", + tags=("google_docs_files", "google_docs"), + unwrap_envelope=True, + fail_message="Failed to search docs.", + input_schema={ + "query": { + "type": "string", + "description": "Title fragment to search for.", + "example": "Meeting", + }, + "max_results": { + "type": "integer", + "description": "Max number of docs to return.", + "example": 50, + }, + }, + arg_map=lambda d: { + "query": d["query"], + "max_results": d.get("max_results", 50), + }, + ), + client_op( + "delete_google_doc", + "delete_document", + description="Move a Google Doc to the Drive trash.", + destructive=True, + parallelizable=False, + tags=("google_docs_files", "google_docs"), + unwrap_envelope=True, + success_message="Document deleted.", + fail_message="Failed to delete document.", + input_schema={"document_id": dict(_DOC_ID)}, + ), + client_op( + "copy_google_doc", + "copy_document", + description="Copy an existing Google Doc to a new file with a new title.", + parallelizable=False, + tags=("google_docs_files",), + unwrap_envelope=True, + fail_message="Failed to copy document.", + input_schema={ + "document_id": { + "type": "string", + "description": "Source document ID.", + "example": "1abcDEF...", + }, + "new_title": { + "type": "string", + "description": "Title for the copy.", + "example": "Meeting Notes (copy)", + }, + }, + ), + client_op( + "export_google_doc", + "export_document", + description=( + "Export a Google Doc to PDF, DOCX, ODT, plain text, or HTML " + "and save to a local file path." + ), + tags=("google_docs_files",), + unwrap_envelope=True, + fail_message="Failed to export document.", + input_schema={ + "document_id": { + "type": "string", + "description": "Source document ID.", + "example": "1abcDEF...", + }, + "mime_type": { + "type": "string", + "description": ( + "Export MIME type. application/pdf | " + "application/vnd.openxmlformats-officedocument." + "wordprocessingml.document | " + "application/vnd.oasis.opendocument.text | " + "text/plain | text/html." + ), + "example": "application/pdf", + }, + "dest_path": { + "type": "string", + "description": "Local file path to write to.", + "example": "/tmp/doc.pdf", + }, + }, + ), + # ── Content: insert / delete text, append, replace ──────────────── + client_op( + "append_to_google_doc", + "append_text", + description="Append text to the end of a Google Doc.", + parallelizable=False, + tags=("google_docs_content", "google_docs"), + unwrap_envelope=True, + success_message="Text appended.", + fail_message="Failed to append text.", + input_schema={ + "document_id": dict(_DOC_ID), + "text": { + "type": "string", + "description": "Text to append.", + "example": "\\n\\nFollow-up: ...", + }, + }, + ), + client_op( + "insert_text_into_google_doc", + "insert_text", + description=( + "Insert text at a specific UTF-16 index in the document. " + "Index 1 is the start of the body." + ), + parallelizable=False, + tags=("google_docs_content", "google_docs"), + unwrap_envelope=True, + success_message="Text inserted.", + fail_message="Failed to insert text.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "text": { + "type": "string", + "description": "Text to insert.", + "example": "Introduction\\n", + }, + "index": { + "type": "integer", + "description": "Position (UTF-16 index). Index 1 = start of body.", + "example": 1, + }, + }, + ), + client_op( + "delete_google_doc_range", + "delete_content_range", + description="Delete content in a range (between startIndex and endIndex).", + destructive=True, + parallelizable=False, + tags=("google_docs_content", "google_docs"), + unwrap_envelope=True, + success_message="Range deleted.", + fail_message="Failed to delete range.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "start_index": { + "type": "integer", + "description": "Start UTF-16 index (inclusive).", + "example": 10, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index (exclusive).", + "example": 30, + }, + }, + ), + client_op( + "replace_google_doc_text", + "replace_text", + description=( + "Find-and-replace across the entire Google Doc body. Returns " + "the number of occurrences changed." + ), + parallelizable=False, + tags=("google_docs_content", "google_docs"), + unwrap_envelope=True, + fail_message="Failed to replace text.", + input_schema={ + "document_id": dict(_DOC_ID), + "find": { + "type": "string", + "description": "Text to find.", + "example": "TODO", + }, + "replace": { + "type": "string", + "description": "Replacement text.", + "example": "DONE", + }, + "match_case": { + "type": "boolean", + "description": "Whether the search is case-sensitive.", + "example": False, + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "find": d["find"], + "replace": d["replace"], + "match_case": d.get("match_case", False), + }, + ), + # ── Styling: text + paragraph ───────────────────────────────────── + client_op( + "style_google_doc_text", + "update_text_style", + description=( + "Apply text-level styling (bold, italic, font size, color, " + "link) to a range. Only supplied fields change; others stay " + "untouched." + ), + parallelizable=False, + tags=("google_docs_styling", "google_docs"), + unwrap_envelope=True, + success_message="Text styled.", + fail_message="Failed to style text.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "start_index": { + "type": "integer", + "description": "Start UTF-16 index.", + "example": 10, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index (exclusive).", + "example": 30, + }, + "bold": { + "type": "boolean", + "description": "Toggle bold.", + "example": True, + }, + "italic": { + "type": "boolean", + "description": "Toggle italic.", + "example": False, + }, + "underline": { + "type": "boolean", + "description": "Toggle underline.", + "example": False, + }, + "strikethrough": { + "type": "boolean", + "description": "Toggle strikethrough.", + "example": False, + }, + "font_size_pt": { + "type": "number", + "description": "Font size in points.", + "example": 14, + }, + "font_family": { + "type": "string", + "description": "Font family name.", + "example": "Arial", + }, + "foreground_color_hex": { + "type": "string", + "description": "Foreground color (#RRGGBB).", + "example": "#FF0000", + }, + "background_color_hex": { + "type": "string", + "description": "Background color (#RRGGBB).", + "example": "#FFFF00", + }, + "link_url": { + "type": "string", + "description": "Turn range into a hyperlink to this URL.", + "example": "https://example.com", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "start_index": d["start_index"], + "end_index": d["end_index"], + "bold": d.get("bold"), + "italic": d.get("italic"), + "underline": d.get("underline"), + "strikethrough": d.get("strikethrough"), + "font_size_pt": d.get("font_size_pt"), + "font_family": d.get("font_family") or None, + "foreground_color_hex": d.get("foreground_color_hex") or None, + "background_color_hex": d.get("background_color_hex") or None, + "link_url": d.get("link_url") or None, + }, + ), + client_op( + "style_google_doc_paragraph", + "update_paragraph_style", + description=( + "Apply paragraph-level styling (heading, alignment, line " + "spacing) to a range." + ), + parallelizable=False, + tags=("google_docs_styling", "google_docs"), + unwrap_envelope=True, + success_message="Paragraph styled.", + fail_message="Failed to style paragraph.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "start_index": { + "type": "integer", + "description": "Start UTF-16 index.", + "example": 1, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index (exclusive).", + "example": 20, + }, + "named_style_type": { + "type": "string", + "description": "NORMAL_TEXT | TITLE | SUBTITLE | HEADING_1..HEADING_6.", + "example": "HEADING_1", + }, + "alignment": { + "type": "string", + "description": "START | CENTER | END | JUSTIFIED.", + "example": "CENTER", + }, + "line_spacing": { + "type": "number", + "description": "Percentage (100 = single).", + "example": 150, + }, + "keep_with_next": { + "type": "boolean", + "description": "Keep with following paragraph.", + "example": True, + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "start_index": d["start_index"], + "end_index": d["end_index"], + "named_style_type": d.get("named_style_type") or None, + "alignment": d.get("alignment") or None, + "line_spacing": d.get("line_spacing"), + "keep_with_next": d.get("keep_with_next"), + }, + ), + # ── Lists ───────────────────────────────────────────────────────── + client_op( + "create_google_doc_bullets", + "create_paragraph_bullets", + description="Turn paragraphs in a range into a bulleted or numbered list.", + parallelizable=False, + tags=("google_docs_lists",), + unwrap_envelope=True, + success_message="Bullets created.", + fail_message="Failed to create bullets.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "start_index": { + "type": "integer", + "description": "Start UTF-16 index.", + "example": 10, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index.", + "example": 60, + }, + "bullet_preset": { + "type": "string", + "description": ( + "BULLET_DISC_CIRCLE_SQUARE | NUMBERED_DECIMAL_NESTED | " + "BULLET_CHECKBOX | NUMBERED_DECIMAL_ALPHA_ROMAN | " + "BULLET_ARROW_DIAMOND_DISC." + ), + "example": "BULLET_DISC_CIRCLE_SQUARE", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "start_index": d["start_index"], + "end_index": d["end_index"], + "bullet_preset": d.get("bullet_preset", "BULLET_DISC_CIRCLE_SQUARE"), + }, + ), + client_op( + "delete_google_doc_bullets", + "delete_paragraph_bullets", + description="Remove bullet/numbered list formatting from a range.", + destructive=True, + parallelizable=False, + tags=("google_docs_lists",), + unwrap_envelope=True, + success_message="Bullets removed.", + fail_message="Failed to remove bullets.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "start_index": { + "type": "integer", + "description": "Start UTF-16 index.", + "example": 10, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index.", + "example": 60, + }, + }, + ), + # ── Tables ──────────────────────────────────────────────────────── + client_op( + "insert_google_doc_table", + "insert_table", + description="Insert a new empty table at a specific document index.", + parallelizable=False, + tags=("google_docs_tables", "google_docs"), + unwrap_envelope=True, + success_message="Table inserted.", + fail_message="Failed to insert table.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "rows": { + "type": "integer", + "description": "Number of rows.", + "example": 3, + }, + "columns": { + "type": "integer", + "description": "Number of columns.", + "example": 3, + }, + "index": { + "type": "integer", + "description": "Position to insert at.", + "example": 1, + }, + }, + ), + client_op( + "insert_google_doc_table_row", + "insert_table_row", + description="Insert a row above or below a table cell.", + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to insert row.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "The table's start index in the document.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Reference cell row (0-based).", + "example": 0, + }, + "column_index": { + "type": "integer", + "description": "Reference cell column (0-based).", + "example": 0, + }, + "insert_below": { + "type": "boolean", + "description": "True = below, False = above.", + "example": True, + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "table_start_index": d["table_start_index"], + "row_index": d["row_index"], + "column_index": d["column_index"], + "insert_below": d.get("insert_below", True), + }, + ), + client_op( + "insert_google_doc_table_column", + "insert_table_column", + description="Insert a column left or right of a table cell.", + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to insert column.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "Table start index.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Reference cell row.", + "example": 0, + }, + "column_index": { + "type": "integer", + "description": "Reference cell column.", + "example": 0, + }, + "insert_right": { + "type": "boolean", + "description": "True = right, False = left.", + "example": True, + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "table_start_index": d["table_start_index"], + "row_index": d["row_index"], + "column_index": d["column_index"], + "insert_right": d.get("insert_right", True), + }, + ), + client_op( + "delete_google_doc_table_row", + "delete_table_row", + description="Delete a row at the specified cell location.", + destructive=True, + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to delete row.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "Table start index.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Row to delete.", + "example": 1, + }, + "column_index": { + "type": "integer", + "description": "Any column index in the row.", + "example": 0, + }, + }, + ), + client_op( + "delete_google_doc_table_column", + "delete_table_column", + description="Delete a column at the specified cell location.", + destructive=True, + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to delete column.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "Table start index.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Any row index in the column.", + "example": 0, + }, + "column_index": { + "type": "integer", + "description": "Column to delete.", + "example": 1, + }, + }, + ), + client_op( + "merge_google_doc_table_cells", + "merge_table_cells", + description="Merge a rectangular range of table cells into one.", + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to merge cells.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "Table start index.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Top-left cell row.", + "example": 0, + }, + "column_index": { + "type": "integer", + "description": "Top-left cell column.", + "example": 0, + }, + "row_span": { + "type": "integer", + "description": "Rows to span.", + "example": 2, + }, + "column_span": { + "type": "integer", + "description": "Columns to span.", + "example": 2, + }, + }, + ), + client_op( + "unmerge_google_doc_table_cells", + "unmerge_table_cells", + description="Reverse a cell merge in a table range.", + parallelizable=False, + tags=("google_docs_tables",), + unwrap_envelope=True, + fail_message="Failed to unmerge cells.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "table_start_index": { + "type": "integer", + "description": "Table start index.", + "example": 5, + }, + "row_index": { + "type": "integer", + "description": "Top-left cell row.", + "example": 0, + }, + "column_index": { + "type": "integer", + "description": "Top-left cell column.", + "example": 0, + }, + "row_span": { + "type": "integer", + "description": "Rows in merged region.", + "example": 2, + }, + "column_span": { + "type": "integer", + "description": "Columns in merged region.", + "example": 2, + }, + }, + ), + # ── Images ──────────────────────────────────────────────────────── + client_op( + "insert_google_doc_image", + "insert_inline_image", + description=( + "Insert an inline image (referenced by public URI) at a " + "document index." + ), + parallelizable=False, + tags=("google_docs_images", "google_docs"), + unwrap_envelope=True, + success_message="Image inserted.", + fail_message="Failed to insert image.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "image_uri": { + "type": "string", + "description": "Publicly accessible image URL.", + "example": "https://example.com/logo.png", + }, + "index": { + "type": "integer", + "description": "Insertion index.", + "example": 1, + }, + "width_pt": { + "type": "number", + "description": "Optional width in points.", + "example": 200, + }, + "height_pt": { + "type": "number", + "description": "Optional height in points.", + "example": 150, + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "image_uri": d["image_uri"], + "index": d["index"], + "width_pt": d.get("width_pt"), + "height_pt": d.get("height_pt"), + }, + ), + client_op( + "replace_google_doc_image", + "replace_image", + description=( + "Replace an existing inline image with a new URI (keeps " + "position and size)." + ), + parallelizable=False, + tags=("google_docs_images",), + unwrap_envelope=True, + success_message="Image replaced.", + fail_message="Failed to replace image.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "image_object_id": { + "type": "string", + "description": "Inline image object ID.", + "example": "kix.xxxx", + }, + "image_uri": { + "type": "string", + "description": "New image URI.", + "example": "https://example.com/new.png", + }, + }, + ), + # ── Structure: page/section breaks, headers/footers, named ranges ─ + client_op( + "insert_google_doc_page_break", + "insert_page_break", + description="Insert a page break at a document index.", + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Page break inserted.", + fail_message="Failed to insert page break.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "index": { + "type": "integer", + "description": "Insertion index.", + "example": 1, + }, + }, + ), + client_op( + "insert_google_doc_section_break", + "insert_section_break", + description=( + "Insert a section break (NEXT_PAGE or CONTINUOUS) at a " + "document index." + ), + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Section break inserted.", + fail_message="Failed to insert section break.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "index": { + "type": "integer", + "description": "Insertion index.", + "example": 1, + }, + "section_type": { + "type": "string", + "description": "NEXT_PAGE | CONTINUOUS.", + "example": "NEXT_PAGE", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "index": d["index"], + "section_type": d.get("section_type", "NEXT_PAGE"), + }, + ), + client_op( + "create_google_doc_header", + "create_header", + description=( + "Create a document header. Returns the header ID for further " + "edits." + ), + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Header created.", + fail_message="Failed to create header.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "header_type": { + "type": "string", + "description": "DEFAULT | FIRST_PAGE_HEADER.", + "example": "DEFAULT", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "header_type": d.get("header_type", "DEFAULT"), + }, + ), + client_op( + "create_google_doc_footer", + "create_footer", + description=( + "Create a document footer. Returns the footer ID for further " + "edits." + ), + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Footer created.", + fail_message="Failed to create footer.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "footer_type": { + "type": "string", + "description": "DEFAULT | FIRST_PAGE_FOOTER.", + "example": "DEFAULT", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "footer_type": d.get("footer_type", "DEFAULT"), + }, + ), + client_op( + "delete_google_doc_header", + "delete_header", + description="Delete a header by its ID.", + destructive=True, + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Header deleted.", + fail_message="Failed to delete header.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "header_id": { + "type": "string", + "description": "Header ID.", + "example": "kix.xxxx", + }, + }, + ), + client_op( + "delete_google_doc_footer", + "delete_footer", + description="Delete a footer by its ID.", + destructive=True, + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Footer deleted.", + fail_message="Failed to delete footer.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "footer_id": { + "type": "string", + "description": "Footer ID.", + "example": "kix.xxxx", + }, + }, + ), + client_op( + "create_google_doc_named_range", + "create_named_range", + description=( + "Create a named range over a document range so it can be " + "referenced later." + ), + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Named range created.", + fail_message="Failed to create named range.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "name": { + "type": "string", + "description": "Range name.", + "example": "intro_section", + }, + "start_index": { + "type": "integer", + "description": "Start UTF-16 index.", + "example": 1, + }, + "end_index": { + "type": "integer", + "description": "End UTF-16 index.", + "example": 50, + }, + }, + ), + client_op( + "delete_google_doc_named_range", + "delete_named_range", + description="Delete a named range by name or by ID.", + destructive=True, + parallelizable=False, + tags=("google_docs_structure",), + unwrap_envelope=True, + success_message="Named range deleted.", + fail_message="Failed to delete named range.", + input_schema={ + "document_id": dict(_DOC_ID_SHORT), + "name": { + "type": "string", + "description": "Range name to delete (one of name or id required).", + "example": "intro_section", + }, + "named_range_id": { + "type": "string", + "description": "Named range ID (alternative to name).", + "example": "", + }, + }, + arg_map=lambda d: { + "document_id": d["document_id"], + "name": d.get("name") or None, + "named_range_id": d.get("named_range_id") or None, + }, + ), + ] diff --git a/craftos_integrations/providers/google_docs/provider.py b/craftos_integrations/providers/google_docs/provider.py new file mode 100644 index 00000000..e1d85704 --- /dev/null +++ b/craftos_integrations/providers/google_docs/provider.py @@ -0,0 +1,37 @@ +"""Google Docs provider — multi-account port of the granular Docs integration. + +API surface comes from the legacy ``GoogleDocsClient`` (all Docs/Drive +REST methods live there and are unchanged); this class only rebinds its +credential plumbing to the injected per-account credential. + +Scopes mirror the legacy handler's ``make_google_oauth`` string +(``DOCS_AND_DRIVE_SCOPES`` = documents + full drive): the Docs scope +covers document bodies, and the broad Drive scope lets list/search find +docs the user already owns — not just files created by the integration. +""" + +from __future__ import annotations + +from typing import List + +from ...contracts import Operation +from ...integrations.google_docs import DOCS_AND_DRIVE_SCOPES, GoogleDocsClient +from .._google import GoogleProviderBase, GoogleClientBinding, read_guidance +from .operations import build_operations + + +class BoundGoogleDocsClient(GoogleClientBinding, GoogleDocsClient): + """GoogleDocsClient with per-account credential binding (see GoogleClientBinding).""" + + +class GoogleDocsProvider(GoogleProviderBase): + id = "google_docs" + display_name = "Google Docs" + scopes = DOCS_AND_DRIVE_SCOPES + client_cls = BoundGoogleDocsClient + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) diff --git a/craftos_integrations/providers/google_drive/GUIDANCE.md b/craftos_integrations/providers/google_drive/GUIDANCE.md new file mode 100644 index 00000000..17bfb846 --- /dev/null +++ b/craftos_integrations/providers/google_drive/GUIDANCE.md @@ -0,0 +1,44 @@ +# Google Drive + +Files — list, search, upload, download, export, share, comments, +revisions, shared drives. + +## Multi-account +- Every Drive action accepts an optional `account` (email, nickname, or a + unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my school Drive", "the + work account"), pass it as `account` — never silently default to + primary. +- File/folder/permission/comment/revision ids are **account-scoped**: an + id returned by `search_drive_files` with `account="work"` must be used + with `account="work"` on every follow-up action (get/move/share/etc.). +- Permission grants come FROM the selected account: + `add_drive_permission` shares the file as that account, and the grantee + receives access (and any notification email) from that account's + address. +- For destructive actions (delete, empty trash, permission changes) with + multiple accounts connected and no account named: ask the user which + account before acting. + +## Behavior +- No event listening — Drive is purely request-response. +- File and folder IDs are opaque strings; never construct them. Discover + them with `search_drive_files` (Drive q-query syntax), + `find_drive_folder_by_name`, or `list_drive_files`. +- `"root"` is the special folder ID for the account's My Drive root. +- Include `trashed = false` in q-queries — omitting it returns deleted + files too. +- Folders are files with `mimeType = "application/vnd.google-apps.folder"`; + filter by mimeType to separate them in search results. +- Sharing requires an email address, not a name or handle. Roles are + case-sensitive: `reader`, `commenter`, `writer`, `owner`. Google's + permission sync can lag a few seconds — don't assume the recipient sees + it instantly. +- Move = re-parent: `move_drive_file` swaps the file's `parents`; there + is no path rename. +- Prefer `update_drive_file_metadata` with `trashed=true` (reversible) + over `delete_drive_file` (permanent). +- For Google-native files (Docs/Sheets/Slides) use `export_drive_file`; + `download_drive_file` only works for regular binary files. +- The connected account's email is known from the credential — never ask + the user for it. diff --git a/craftos_integrations/providers/google_drive/__init__.py b/craftos_integrations/providers/google_drive/__init__.py new file mode 100644 index 00000000..9c6a21f4 --- /dev/null +++ b/craftos_integrations/providers/google_drive/__init__.py @@ -0,0 +1,3 @@ +from .provider import GoogleDriveProvider + +__all__ = ["GoogleDriveProvider"] diff --git a/craftos_integrations/providers/google_drive/operations.py b/craftos_integrations/providers/google_drive/operations.py new file mode 100644 index 00000000..e74b7777 --- /dev/null +++ b/craftos_integrations/providers/google_drive/operations.py @@ -0,0 +1,1116 @@ +"""Google Drive operations — ported from the legacy google_drive_actions.py. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). The legacy ``from_email`` inputs on +find_drive_folder_by_name / resolve_drive_folder_path were dead +account-hint keys (never forwarded to the client) and are dropped for the +same reason. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List + +from ...contracts import Operation +from .._shared import STATUS_OUTPUT, client_op, shape_result + + +async def _resolve_drive_folder_path( + client: Any, input_data: Dict[str, Any] +) -> Dict[str, Any]: + """Walks the path one segment at a time — custom 'not_found' shape.""" + parts = [p for p in input_data["path"].split("/") if p] + if parts and parts[0].lower() == "root": + parts = parts[1:] + current_folder_id = "root" + + for part in parts: + try: + raw = await asyncio.to_thread( + client.find_drive_folder_by_name, + name=part, + parent_folder_id=current_folder_id, + ) + except Exception as e: + return {"status": "error", "reason": str(e)} + result = shape_result( + raw, + unwrap_envelope=True, + fail_message=f"Failed to look up '{part}'", + ) + if result["status"] == "error": + return {"status": "error", "reason": result.get("message", "API error")} + folder = result.get("result") + if not folder: + return { + "status": "not_found", + "reason": f"Folder '{part}' not found", + "folder_id": None, + } + current_folder_id = folder["id"] + + return {"status": "success", "folder_id": current_folder_id} + + +def build_operations() -> List[Operation]: + return [ + # ── Files — list / search / get / folder / upload / download / + # export / copy / move / delete ────────────────────────────────── + client_op( + "list_drive_files", + "list_drive_files", + description="List files in a specific Google Drive folder.", + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to list files.", + input_schema={ + "folder_id": { + "type": "string", + "description": ( + "Google Drive folder ID. Use 'root' for the user's " + "My Drive." + ), + "example": "root", + }, + }, + arg_map=lambda d: {"folder_id": d["folder_id"]}, + ), + client_op( + "search_drive_files", + "search_drive", + description=( + "Free-form search across all of Drive using Drive's q-query " + "syntax (e.g. \"name contains 'report' and mimeType = " + "'application/pdf'\")." + ), + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to search files.", + input_schema={ + "query": { + "type": "string", + "description": "Drive q-query.", + "example": "name contains 'budget' and trashed = false", + }, + "max_results": { + "type": "integer", + "description": "Max results.", + "example": 50, + }, + }, + arg_map=lambda d: { + "query": d["query"], + "max_results": d.get("max_results", 50), + }, + ), + client_op( + "get_drive_file", + "get_drive_file", + description="Get metadata for a single Drive file or folder.", + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to get file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "fields": { + "type": "string", + "description": "Comma-separated field list (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "fields": d.get("fields") or None, + }, + ), + client_op( + "create_drive_folder", + "create_drive_folder", + description="Create a new folder in Google Drive.", + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to create folder.", + input_schema={ + "name": { + "type": "string", + "description": "Folder name.", + "example": "Project Files", + }, + "parent_folder_id": { + "type": "string", + "description": "Optional parent folder ID.", + "example": "", + }, + }, + arg_map=lambda d: { + "name": d["name"], + "parent_folder_id": d.get("parent_folder_id"), + }, + ), + client_op( + "upload_drive_file", + "upload_drive_file", + description=( + "Upload a local file to Google Drive. Reads from file_path on " + "the agent host. MIME type is auto-detected if omitted." + ), + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to upload file.", + input_schema={ + "file_path": { + "type": "string", + "description": "Absolute path to the local file.", + "example": "C:/Users/me/report.pdf", + }, + "name": { + "type": "string", + "description": "Drive filename (defaults to local filename).", + "example": "", + }, + "mime_type": { + "type": "string", + "description": "MIME type (defaults to autodetect).", + "example": "", + }, + "parent_folder_id": { + "type": "string", + "description": "Target folder ID (defaults to root).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_path": d["file_path"], + "name": d.get("name") or None, + "mime_type": d.get("mime_type") or None, + "parent_folder_id": d.get("parent_folder_id") or None, + }, + ), + client_op( + "update_drive_file_content", + "update_drive_file_content", + description=( + "Replace an existing Drive file's binary content with a local " + "file. Does NOT change metadata." + ), + parallelizable=False, + tags=("google_drive_files",), + unwrap_envelope=True, + fail_message="Failed to update file content.", + input_schema={ + "file_id": { + "type": "string", + "description": "Drive file ID to overwrite.", + "example": "", + }, + "file_path": { + "type": "string", + "description": "Absolute path to the new local content.", + "example": "C:/Users/me/report_v2.pdf", + }, + "mime_type": { + "type": "string", + "description": "MIME type (defaults to autodetect).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "file_path": d["file_path"], + "mime_type": d.get("mime_type") or None, + }, + ), + client_op( + "download_drive_file", + "download_drive_file", + description=( + "Download a regular (non-Google-native) Drive file to a local " + "path. For Google Docs/Sheets/Slides use export_drive_file " + "instead." + ), + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to download file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "save_to": { + "type": "string", + "description": ( + "Local path to save to. Parent directories will be " + "created." + ), + "example": "C:/Users/me/downloads/report.pdf", + }, + }, + ), + client_op( + "export_drive_file", + "export_drive_file", + description=( + "Export a Google-native file (Doc/Sheet/Slide/Drawing) to a " + "local path in another format. Common mime_type values: " + "application/pdf, application/vnd.openxmlformats-officedocument" + ".wordprocessingml.document (.docx), application/vnd." + "openxmlformats-officedocument.spreadsheetml.sheet (.xlsx), " + "text/plain, text/csv. Limit: 10 MB." + ), + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to export file.", + input_schema={ + "file_id": { + "type": "string", + "description": "Google-native file ID.", + "example": "", + }, + "save_to": { + "type": "string", + "description": "Local path to save to.", + "example": "C:/Users/me/report.pdf", + }, + "mime_type": { + "type": "string", + "description": "Target export MIME type.", + "example": "application/pdf", + }, + }, + ), + client_op( + "copy_drive_file", + "copy_drive_file", + description=( + "Duplicate a Drive file. Optionally rename and/or place in a " + "different folder." + ), + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to copy file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID to copy.", + "example": "", + }, + "name": { + "type": "string", + "description": "Name for the copy (optional).", + "example": "", + }, + "parent_folder_id": { + "type": "string", + "description": "Target folder ID (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "name": d.get("name") or None, + "parent_folder_id": d.get("parent_folder_id") or None, + }, + ), + client_op( + "move_drive_file", + "move_drive_file", + description="Move a file to a different Google Drive folder.", + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to move file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID to move.", + "example": "abc123", + }, + "destination_folder_id": { + "type": "string", + "description": "Destination folder ID.", + "example": "def456", + }, + "source_folder_id": { + "type": "string", + "description": "Current parent folder ID.", + "example": "root", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "add_parents": d["destination_folder_id"], + "remove_parents": d.get("source_folder_id", ""), + }, + ), + client_op( + "update_drive_file_metadata", + "update_drive_file_metadata", + description=( + "Rename / re-describe / star / trash a Drive file. Use " + "trashed=true to send to trash without permanent delete." + ), + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to update file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "name": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "description": { + "type": "string", + "description": "New description (optional).", + "example": "", + }, + "starred": { + "type": "boolean", + "description": "Star/unstar (optional).", + "example": False, + }, + "trashed": { + "type": "boolean", + "description": ( + "Send to trash without deleting (optional)." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "name": d.get("name") or None, + "description": d["description"] if "description" in d else None, + "starred": d["starred"] if "starred" in d else None, + "trashed": d["trashed"] if "trashed" in d else None, + }, + ), + client_op( + "delete_drive_file", + "delete_drive_file", + description=( + "Permanently delete a Drive file. Irreversible. To send to " + "trash instead, use update_drive_file_metadata with " + "trashed=true." + ), + destructive=True, + parallelizable=False, + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to delete file.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + }, + ), + client_op( + "empty_drive_trash", + "empty_drive_trash", + description=( + "Permanently delete EVERYTHING in the user's Drive trash. " + "Irreversible." + ), + destructive=True, + parallelizable=False, + tags=("google_drive_files",), + unwrap_envelope=True, + fail_message="Failed to empty trash.", + input_schema={}, + ), + client_op( + "get_drive_about", + "get_drive_about", + description=( + "Get Drive account info: user, storage quota, max upload " + "size. Set include_metadata to also get the supported " + "export/import format maps." + ), + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to get Drive info.", + input_schema={ + "include_metadata": { + "type": "boolean", + "description": ( + "Include exportFormats/importFormats maps " + "(default false)." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "include_metadata": bool(d.get("include_metadata", False)), + }, + ), + client_op( + "find_drive_folder_by_name", + "find_drive_folder_by_name", + description="Find folder by name.", + tags=("google_drive_files", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to find folder.", + input_schema={ + "name": { + "type": "string", + "description": "Name.", + "example": "Folder", + }, + "parent_folder_id": { + "type": "string", + "description": "Parent.", + "example": "root", + }, + }, + arg_map=lambda d: { + "name": d["name"], + "parent_folder_id": d.get("parent_folder_id"), + }, + ), + Operation( + name="resolve_drive_folder_path", + description="Resolve folder path.", + input_schema={ + "path": { + "type": "string", + "description": "Path.", + "example": "Root/Folder", + }, + }, + output_schema=dict(STATUS_OUTPUT), + fn=_resolve_drive_folder_path, + tags=("google_drive_files",), + ), + # ── Permissions (sharing) ──────────────────────────────────────── + client_op( + "list_drive_permissions", + "list_drive_permissions", + description=( + "List who has access to a Drive file or folder, with their " + "role." + ), + tags=("google_drive_permissions", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to list permissions.", + input_schema={ + "file_id": { + "type": "string", + "description": "File or folder ID.", + "example": "", + }, + }, + ), + client_op( + "get_drive_permission", + "get_drive_permission", + description="Get one specific permission by ID.", + tags=("google_drive_permissions",), + unwrap_envelope=True, + fail_message="Failed to get permission.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "permission_id": { + "type": "string", + "description": "Permission ID.", + "example": "", + }, + }, + ), + client_op( + "add_drive_permission", + "create_drive_permission", + description=( + "Share a Drive file/folder. perm_type: user|group|domain|" + "anyone. role: reader|commenter|writer|owner." + ), + destructive=True, + parallelizable=False, + tags=("google_drive_permissions", "google_drive"), + unwrap_envelope=True, + fail_message="Failed to add permission.", + input_schema={ + "file_id": { + "type": "string", + "description": "File or folder ID.", + "example": "", + }, + "role": { + "type": "string", + "description": "reader, commenter, writer, or owner.", + "example": "reader", + }, + "perm_type": { + "type": "string", + "description": "user, group, domain, or anyone.", + "example": "user", + }, + "email_address": { + "type": "string", + "description": "Email (for user/group types).", + "example": "alice@example.com", + }, + "domain": { + "type": "string", + "description": "Domain (for domain type).", + "example": "", + }, + "send_notification": { + "type": "boolean", + "description": "Email the grantee.", + "example": True, + }, + "email_message": { + "type": "string", + "description": "Custom notification message (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "role": d["role"], + "perm_type": d.get("perm_type", "user"), + "email_address": d.get("email_address") or None, + "domain": d.get("domain") or None, + "send_notification": bool(d.get("send_notification", True)), + "email_message": d.get("email_message") or None, + }, + ), + client_op( + "update_drive_permission", + "update_drive_permission", + description="Change a permission's role.", + destructive=True, + parallelizable=False, + tags=("google_drive_permissions",), + unwrap_envelope=True, + fail_message="Failed to update permission.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "permission_id": { + "type": "string", + "description": "Permission ID.", + "example": "", + }, + "role": { + "type": "string", + "description": "New role.", + "example": "writer", + }, + }, + ), + client_op( + "remove_drive_permission", + "delete_drive_permission", + description="Revoke access by deleting a permission.", + destructive=True, + parallelizable=False, + tags=("google_drive_permissions",), + unwrap_envelope=True, + fail_message="Failed to remove permission.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "permission_id": { + "type": "string", + "description": "Permission ID.", + "example": "", + }, + }, + ), + # ── Comments + replies ─────────────────────────────────────────── + client_op( + "list_drive_comments", + "list_drive_comments", + description="List comments on a Drive file.", + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to list comments.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "include_deleted": { + "type": "boolean", + "description": "Include soft-deleted comments.", + "example": False, + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "include_deleted": bool(d.get("include_deleted", False)), + }, + ), + client_op( + "get_drive_comment", + "get_drive_comment", + description="Get a single comment with its replies.", + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to get comment.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + }, + ), + client_op( + "create_drive_comment", + "create_drive_comment", + description=( + "Post a top-level comment on a Drive file. anchor is an " + "optional region anchor (Google's structured anchor format)." + ), + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to create comment.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "content": { + "type": "string", + "description": "Comment text.", + "example": "Please review.", + }, + "anchor": { + "type": "string", + "description": "Optional anchor (structured format).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "content": d["content"], + "anchor": d.get("anchor") or None, + }, + ), + client_op( + "update_drive_comment", + "update_drive_comment", + description="Edit a comment's content or mark it resolved.", + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to update comment.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + "content": { + "type": "string", + "description": "New content (optional).", + "example": "", + }, + "resolved": { + "type": "boolean", + "description": "Mark as resolved (optional).", + "example": True, + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "comment_id": d["comment_id"], + "content": d["content"] if "content" in d else None, + "resolved": d["resolved"] if "resolved" in d else None, + }, + ), + client_op( + "delete_drive_comment", + "delete_drive_comment", + description="Delete a comment.", + destructive=True, + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to delete comment.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + }, + ), + client_op( + "list_drive_comment_replies", + "list_drive_comment_replies", + description="List replies on a comment.", + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to list replies.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + }, + ), + client_op( + "create_drive_comment_reply", + "create_drive_comment_reply", + description="Reply to a comment.", + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to create reply.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + "content": { + "type": "string", + "description": "Reply text.", + "example": "", + }, + }, + ), + client_op( + "update_drive_comment_reply", + "update_drive_comment_reply", + description="Edit a reply.", + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to update reply.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + "reply_id": { + "type": "string", + "description": "Reply ID.", + "example": "", + }, + "content": { + "type": "string", + "description": "New content.", + "example": "", + }, + }, + ), + client_op( + "delete_drive_comment_reply", + "delete_drive_comment_reply", + description="Delete a reply.", + destructive=True, + parallelizable=False, + tags=("google_drive_comments",), + unwrap_envelope=True, + fail_message="Failed to delete reply.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "comment_id": { + "type": "string", + "description": "Comment ID.", + "example": "", + }, + "reply_id": { + "type": "string", + "description": "Reply ID.", + "example": "", + }, + }, + ), + # ── Revisions (version history) ────────────────────────────────── + client_op( + "list_drive_revisions", + "list_drive_revisions", + description="List revisions (version history) of a Drive file.", + tags=("google_drive_revisions",), + unwrap_envelope=True, + fail_message="Failed to list revisions.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + }, + ), + client_op( + "get_drive_revision", + "get_drive_revision", + description="Get details of a specific revision.", + tags=("google_drive_revisions",), + unwrap_envelope=True, + fail_message="Failed to get revision.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "revision_id": { + "type": "string", + "description": "Revision ID.", + "example": "", + }, + }, + ), + client_op( + "update_drive_revision", + "update_drive_revision", + description=( + "Mark a revision keep-forever (pin) or set publish state for " + "Google-native files." + ), + parallelizable=False, + tags=("google_drive_revisions",), + unwrap_envelope=True, + fail_message="Failed to update revision.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "revision_id": { + "type": "string", + "description": "Revision ID.", + "example": "", + }, + "keep_forever": { + "type": "boolean", + "description": ( + "Pin this revision (otherwise Drive auto-prunes after " + "100 or 30 days, whichever first)." + ), + "example": True, + }, + "published": { + "type": "boolean", + "description": "Publish state (Google-native files only).", + "example": False, + }, + "publish_auto": { + "type": "boolean", + "description": "Auto-publish subsequent revisions.", + "example": False, + }, + }, + arg_map=lambda d: { + "file_id": d["file_id"], + "revision_id": d["revision_id"], + "keep_forever": d["keep_forever"] if "keep_forever" in d else None, + "published": d["published"] if "published" in d else None, + "publish_auto": d["publish_auto"] if "publish_auto" in d else None, + }, + ), + client_op( + "delete_drive_revision", + "delete_drive_revision", + description="Delete a revision.", + destructive=True, + parallelizable=False, + tags=("google_drive_revisions",), + unwrap_envelope=True, + fail_message="Failed to delete revision.", + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + "revision_id": { + "type": "string", + "description": "Revision ID.", + "example": "", + }, + }, + ), + # ── Shared drives (formerly Team Drives) ───────────────────────── + client_op( + "list_shared_drives", + "list_shared_drives", + description="List shared drives the user has access to.", + tags=("google_drive_shared_drives",), + unwrap_envelope=True, + fail_message="Failed to list shared drives.", + input_schema={ + "page_size": { + "type": "integer", + "description": "Max results.", + "example": 50, + }, + "q": { + "type": "string", + "description": "Drive search query (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "page_size": d.get("page_size", 50), + "q": d.get("q") or None, + }, + ), + client_op( + "get_shared_drive", + "get_shared_drive", + description="Get metadata for a shared drive.", + tags=("google_drive_shared_drives",), + unwrap_envelope=True, + fail_message="Failed to get shared drive.", + input_schema={ + "drive_id": { + "type": "string", + "description": "Shared drive ID.", + "example": "", + }, + }, + ), + client_op( + "create_shared_drive", + "create_shared_drive", + description=( + "Create a new shared drive. The user must have permission to " + "create shared drives in their org." + ), + parallelizable=False, + tags=("google_drive_shared_drives",), + unwrap_envelope=True, + fail_message="Failed to create shared drive.", + input_schema={ + "name": { + "type": "string", + "description": "Shared drive name.", + "example": "Team project", + }, + }, + ), + client_op( + "update_shared_drive", + "update_shared_drive", + description="Rename or hide/unhide a shared drive.", + parallelizable=False, + tags=("google_drive_shared_drives",), + unwrap_envelope=True, + fail_message="Failed to update shared drive.", + input_schema={ + "drive_id": { + "type": "string", + "description": "Shared drive ID.", + "example": "", + }, + "name": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "hidden": { + "type": "boolean", + "description": "Hide from UI (optional).", + "example": False, + }, + }, + arg_map=lambda d: { + "drive_id": d["drive_id"], + "name": d.get("name") or None, + "hidden": d["hidden"] if "hidden" in d else None, + }, + ), + client_op( + "delete_shared_drive", + "delete_shared_drive", + description="Delete a shared drive. The drive must be empty.", + destructive=True, + parallelizable=False, + tags=("google_drive_shared_drives",), + unwrap_envelope=True, + fail_message="Failed to delete shared drive.", + input_schema={ + "drive_id": { + "type": "string", + "description": "Shared drive ID.", + "example": "", + }, + }, + ), + ] + + +# ================================================================== +# Intentionally NOT exposed as operations (carried over from legacy) +# ================================================================== +# - Changes / watch endpoints (changes.list, changes.watch, channels.stop) +# Push notifications / incremental sync — server-side webhook plumbing, +# not per-interaction actions. +# - generateIds, resumable upload, multipart upload, DriveAccess proposals +# Same reasoning as the legacy actions file: niche or org-admin-level. diff --git a/craftos_integrations/providers/google_drive/provider.py b/craftos_integrations/providers/google_drive/provider.py new file mode 100644 index 00000000..2ddc7329 --- /dev/null +++ b/craftos_integrations/providers/google_drive/provider.py @@ -0,0 +1,33 @@ +"""Google Drive provider — multi-account port of the legacy google_drive integration. + +API surface comes from the legacy ``GoogleDriveClient`` (all Drive REST +methods live there and are unchanged); this class only rebinds its +credential plumbing to the injected per-account credential. +""" + +from __future__ import annotations + +from typing import List + +from ...contracts import Operation +from ...integrations._google_common import DRIVE_SCOPES +from ...integrations.google_drive import GoogleDriveClient +from .._google import GoogleProviderBase, GoogleClientBinding, read_guidance +from .operations import build_operations + + +class BoundGoogleDriveClient(GoogleClientBinding, GoogleDriveClient): + """GoogleDriveClient with per-account credential binding (see GoogleClientBinding).""" + + +class GoogleDriveProvider(GoogleProviderBase): + id = "google_drive" + display_name = "Google Drive" + scopes = DRIVE_SCOPES + client_cls = BoundGoogleDriveClient + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) diff --git a/craftos_integrations/providers/google_youtube/GUIDANCE.md b/craftos_integrations/providers/google_youtube/GUIDANCE.md new file mode 100644 index 00000000..9c964814 --- /dev/null +++ b/craftos_integrations/providers/google_youtube/GUIDANCE.md @@ -0,0 +1,37 @@ +# YouTube + +Search YouTube, manage the user's subscriptions and playlists, post +comments, and rate videos. + +## Multi-account +- Every YouTube action accepts an optional `account` (email, nickname, or a + unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my creator account", "the + work Google account"), pass it as `account` — never silently default to + primary. +- Subscription and playlist ids are **account-scoped**: a subscription id + returned by `list_my_youtube_subscriptions` with `account="work"` must be + used with `account="work"` on the follow-up `unsubscribe_from_youtube_channel`. +- For public-facing actions (posting comments, subscribing) with multiple + accounts connected and no account named: ask the user which account + before acting. + +## Essentials +- **No event listening.** YouTube will never push new-video / new-comment + notifications — purely request-response. +- **ID formats are fixed and distinct — don't mix:** + - video IDs are 11-char strings (e.g. `dQw4w9WgXcQ`) + - channel IDs are 24-char strings starting with `UC...` + - playlist IDs start with `PL...` and are usually 34+ chars + - **subscription IDs ≠ channel IDs** +- **`unsubscribe_from_youtube_channel` takes the SUBSCRIPTION ID,** not the + channel ID. Get it from `list_my_youtube_subscriptions` (with + `include_metadata` for the raw resource). 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. +- 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/providers/google_youtube/__init__.py b/craftos_integrations/providers/google_youtube/__init__.py new file mode 100644 index 00000000..a3b47d59 --- /dev/null +++ b/craftos_integrations/providers/google_youtube/__init__.py @@ -0,0 +1,3 @@ +from .provider import GoogleYoutubeProvider + +__all__ = ["GoogleYoutubeProvider"] diff --git a/craftos_integrations/providers/google_youtube/operations.py b/craftos_integrations/providers/google_youtube/operations.py new file mode 100644 index 00000000..2bc8bb9b --- /dev/null +++ b/craftos_integrations/providers/google_youtube/operations.py @@ -0,0 +1,413 @@ +"""YouTube operations — ported from the legacy google_youtube_actions.py. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). + +Several legacy actions shape raw API resources into lean results unless +``include_metadata`` is set; ``_lean_op`` reproduces that post-processing +on top of ``client_op`` so ported operations return identical dicts. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Callable, Dict, List + +from ...contracts import Operation +from .._shared import client_op + +_INCLUDE_METADATA_SCHEMA = { + "type": "boolean", + "description": "Return raw search results (default false = lean).", + "example": False, +} + + +def _lean_op(op: Operation, lean: Callable[[List[Any]], List[Any]]) -> Operation: + """Wrap an Operation so a successful list result is reduced to its lean + shape unless the caller sets ``include_metadata`` (legacy behavior).""" + inner = op.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + res = await inner(client, input_data) + if not input_data.get("include_metadata") and res.get("status") == "success": + items = res.get("result") + if isinstance(items, list): + res = {**res, "result": lean(items)} + return res + + return replace(op, fn=fn) + + +def _lean_search(items: List[Any]) -> List[Any]: + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + rid = it.get("id") or {} + entry: Dict[str, Any] = {} + for key in ("videoId", "channelId", "playlistId"): + if isinstance(rid, dict) and rid.get(key): + entry[key] = rid[key] + entry.update( + { + "title": snippet.get("title"), + "channelTitle": snippet.get("channelTitle"), + "publishedAt": snippet.get("publishedAt"), + "description": snippet.get("description"), + } + ) + lean.append(entry) + return lean + + +def _lean_subscriptions(items: List[Any]) -> List[Any]: + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + entry = { + "channelId": (snippet.get("resourceId") or {}).get("channelId"), + "title": snippet.get("title"), + } + if snippet.get("description"): + entry["description"] = snippet["description"] + lean.append(entry) + return lean + + +def _lean_playlists(items: List[Any]) -> List[Any]: + return [ + { + "id": it.get("id"), + "title": (it.get("snippet") or {}).get("title"), + "itemCount": (it.get("contentDetails") or {}).get("itemCount"), + } + for it in items + if isinstance(it, dict) + ] + + +def _lean_playlist_items(items: List[Any]) -> List[Any]: + lean = [] + for it in items: + if not isinstance(it, dict): + continue + snippet = it.get("snippet") or {} + lean.append( + { + "videoId": (snippet.get("resourceId") or {}).get("videoId"), + "title": snippet.get("title"), + "position": snippet.get("position"), + "publishedAt": snippet.get("publishedAt"), + } + ) + return lean + + +def _lean_comments(items: List[Any]) -> List[Any]: + lean = [] + for it in items: + if not isinstance(it, dict): + continue + thread = it.get("snippet") or {} + comment = (thread.get("topLevelComment") or {}).get("snippet") or {} + lean.append( + { + "author": comment.get("authorDisplayName"), + "text": comment.get("textOriginal") or comment.get("textDisplay"), + "likeCount": comment.get("likeCount"), + "publishedAt": comment.get("publishedAt"), + "totalReplyCount": thread.get("totalReplyCount"), + } + ) + return lean + + +def build_operations() -> List[Operation]: + return [ + client_op( + "get_my_youtube_channel", + "get_my_channel", + description=( + "Return the authenticated user's YouTube channel info " + "(id, title, subscriber/view counts)." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to fetch channel.", + input_schema={}, + ), + _lean_op( + client_op( + "search_youtube", + "search", + description=( + "Search YouTube for videos, channels, or playlists. Lean " + "results by default ({videoId/channelId/playlistId, title, " + "channelTitle, publishedAt, description}); set " + "include_metadata for raw results." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="YouTube search failed.", + input_schema={ + "query": { + "type": "string", + "description": "Search terms.", + "example": "claude code tutorial", + }, + "type": { + "type": "string", + "description": "What to search for: video, channel, or playlist.", + "example": "video", + }, + "max_results": { + "type": "integer", + "description": "Max number of results.", + "example": 25, + }, + "include_metadata": dict(_INCLUDE_METADATA_SCHEMA), + }, + arg_map=lambda d: { + "query": d["query"], + "type_filter": d.get("type", "video"), + "max_results": d.get("max_results", 25), + }, + ), + _lean_search, + ), + client_op( + "get_youtube_video", + "get_video", + description=( + "Get full metadata for a YouTube video (snippet, statistics, " + "content details)." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to fetch video.", + input_schema={ + "video_id": { + "type": "string", + "description": "The YouTube video ID.", + "example": "dQw4w9WgXcQ", + }, + }, + ), + _lean_op( + client_op( + "list_my_youtube_subscriptions", + "list_my_subscriptions", + description=( + "List the channels the authenticated user is subscribed to. " + "Lean results by default ({channelId, title, description}); " + "set include_metadata for raw results (needed for the " + "subscription ID used by unsubscribe)." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to list subscriptions.", + input_schema={ + "max_results": { + "type": "integer", + "description": "Max number of subscriptions to return.", + "example": 50, + }, + "include_metadata": { + **_INCLUDE_METADATA_SCHEMA, + "description": ( + "Return raw subscription resources (default false = lean)." + ), + }, + }, + arg_map=lambda d: {"max_results": d.get("max_results", 50)}, + ), + _lean_subscriptions, + ), + _lean_op( + client_op( + "list_my_youtube_playlists", + "list_my_playlists", + description=( + "List playlists owned by the authenticated user. Lean " + "results by default ({id, title, itemCount}); set " + "include_metadata for raw results." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to list playlists.", + input_schema={ + "max_results": { + "type": "integer", + "description": "Max number of playlists to return.", + "example": 50, + }, + "include_metadata": { + **_INCLUDE_METADATA_SCHEMA, + "description": ( + "Return raw playlist resources (default false = lean)." + ), + }, + }, + arg_map=lambda d: {"max_results": d.get("max_results", 50)}, + ), + _lean_playlists, + ), + _lean_op( + client_op( + "list_youtube_playlist_items", + "list_playlist_items", + description=( + "List videos in a YouTube playlist. Lean results by default " + "({videoId, title, position, publishedAt}); set " + "include_metadata for raw results." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to list playlist items.", + input_schema={ + "playlist_id": { + "type": "string", + "description": "The playlist ID.", + "example": "PLrAXt...", + }, + "max_results": { + "type": "integer", + "description": "Max number of items to return.", + "example": 50, + }, + "include_metadata": { + **_INCLUDE_METADATA_SCHEMA, + "description": ( + "Return raw playlistItem resources (default false = lean)." + ), + }, + }, + arg_map=lambda d: { + "playlist_id": d["playlist_id"], + "max_results": d.get("max_results", 50), + }, + ), + _lean_playlist_items, + ), + client_op( + "subscribe_to_youtube_channel", + "subscribe", + description="Subscribe the authenticated user to a YouTube channel.", + tags=("google_youtube",), + unwrap_envelope=True, + success_message="Subscribed.", + fail_message="Failed to subscribe.", + input_schema={ + "channel_id": { + "type": "string", + "description": "The channel ID to subscribe to.", + "example": "UC...", + }, + }, + ), + client_op( + "unsubscribe_from_youtube_channel", + "unsubscribe", + description=( + "Remove a YouTube subscription. Takes the subscription ID " + "(from list_my_youtube_subscriptions), not the channel ID." + ), + tags=("google_youtube",), + unwrap_envelope=True, + success_message="Unsubscribed.", + fail_message="Failed to unsubscribe.", + input_schema={ + "subscription_id": { + "type": "string", + "description": "The subscription record ID.", + "example": "abc123...", + }, + }, + ), + client_op( + "rate_youtube_video", + "rate_video", + description="Like, dislike, or clear your rating on a YouTube video.", + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to rate video.", + input_schema={ + "video_id": { + "type": "string", + "description": "The YouTube video ID.", + "example": "dQw4w9WgXcQ", + }, + "rating": { + "type": "string", + "description": "One of: like, dislike, none.", + "example": "like", + }, + }, + ), + client_op( + "post_youtube_comment", + "post_comment", + description="Post a top-level comment on a YouTube video.", + destructive=True, # legacy irreversible=True — public, can't unsay + parallelizable=False, + tags=("google_youtube",), + unwrap_envelope=True, + success_message="Comment posted.", + fail_message="Failed to post comment.", + input_schema={ + "video_id": { + "type": "string", + "description": "The YouTube video ID.", + "example": "dQw4w9WgXcQ", + }, + "text": { + "type": "string", + "description": "Comment text.", + "example": "Great video!", + }, + }, + ), + _lean_op( + client_op( + "get_youtube_video_comments", + "get_video_comments", + description=( + "Get top-level comments on a YouTube video, most recent " + "first. Lean results by default ({author, text, likeCount, " + "publishedAt, totalReplyCount}); set include_metadata for " + "raw commentThread resources." + ), + tags=("google_youtube",), + unwrap_envelope=True, + fail_message="Failed to fetch comments.", + input_schema={ + "video_id": { + "type": "string", + "description": "The YouTube video ID.", + "example": "dQw4w9WgXcQ", + }, + "max_results": { + "type": "integer", + "description": "Max number of comments to return.", + "example": 50, + }, + "include_metadata": { + **_INCLUDE_METADATA_SCHEMA, + "description": ( + "Return raw commentThread resources (default false = lean)." + ), + }, + }, + arg_map=lambda d: { + "video_id": d["video_id"], + "max_results": d.get("max_results", 50), + }, + ), + _lean_comments, + ), + ] diff --git a/craftos_integrations/providers/google_youtube/provider.py b/craftos_integrations/providers/google_youtube/provider.py new file mode 100644 index 00000000..b560ae46 --- /dev/null +++ b/craftos_integrations/providers/google_youtube/provider.py @@ -0,0 +1,33 @@ +"""YouTube provider — multi-account port of the legacy google_youtube integration. + +API surface comes from the legacy ``YouTubeClient`` (all YouTube Data API +v3 methods live there and are unchanged); this class only rebinds its +credential plumbing to the injected per-account credential. +""" + +from __future__ import annotations + +from typing import List + +from ...contracts import Operation +from ...integrations._google_common import YOUTUBE_SCOPES +from ...integrations.google_youtube import YouTubeClient +from .._google import GoogleProviderBase, GoogleClientBinding, read_guidance +from .operations import build_operations + + +class BoundGoogleYoutubeClient(GoogleClientBinding, YouTubeClient): + """YouTubeClient with per-account credential binding (see GoogleClientBinding).""" + + +class GoogleYoutubeProvider(GoogleProviderBase): + id = "google_youtube" # matches legacy platform_id / run_client name + display_name = "YouTube" + scopes = YOUTUBE_SCOPES + client_cls = BoundGoogleYoutubeClient + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) diff --git a/craftos_integrations/providers/hubspot/GUIDANCE.md b/craftos_integrations/providers/hubspot/GUIDANCE.md new file mode 100644 index 00000000..e7d2a4c9 --- /dev/null +++ b/craftos_integrations/providers/hubspot/GUIDANCE.md @@ -0,0 +1,87 @@ +# HubSpot + +Per-portal CRM — contacts/companies/deals/tickets, engagements +(tasks/notes/calls/emails/meetings), lists, pipelines, properties, owners, +associations, forms, marketing email, files, conversations, webhooks. +Talks to `api.hubapi.com`. + +## Multi-account +- One connected account = one HubSpot **hub** (portal). Every HubSpot + action accepts an optional `account` (hub id, nickname, or a unique + fragment like "acme"). Omit it to use the primary hub. +- When the user names a portal in any form ("the client's HubSpot", + "our sandbox portal"), pass it as `account` — never silently default + to primary. +- Object IDs (contacts, companies, deals, tickets, engagement IDs, list + IDs, pipeline/stage IDs, owner IDs, form GUIDs, file IDs, thread IDs) + are **hub-scoped**: an id returned by `list_hubspot_contacts` with + `account="acme"` must be used with `account="acme"` on every follow-up + action (get/update/delete/associate/etc.). +- HubSpot's OAuth authorize page shows its own account/hub chooser, so + adding a *different* portal works from the normal add-account flow — + the user picks the portal to grant on HubSpot's side. +- For destructive actions (deletes, sends) with multiple hubs connected + and no hub named: ask the user which portal before acting. + +## Essentials +- **Object IDs are numeric strings, NOT integers.** HubSpot returns IDs + like `"123456789"`. Pass them through as strings; don't `int()`-cast — + some IDs overflow JS number range. +- **Object types use plural names.** API paths take `contacts`, + `companies`, `deals`, `tickets`, `tasks`, `notes`, `calls`, `emails`, + `meetings`. Custom objects use their schema name (e.g. + `p12345_project`). +- **Property names are flat snake_case strings.** `firstname`, `email`, + `dealstage`, `hs_pipeline_stage`. To create a contact you pass + `{"properties": {"email": "...", "firstname": "..."}}`. There is no + nesting. +- **Pagination is cursor-based.** Every list returns + `{results: [...], paging: {next: {after: ""}}}`. Pass `after` + to get the next page. `limit` defaults to 30, capped at 100 for most + endpoints (500 for owners + lists). +- **Search uses `filterGroups`, not query strings.** The body shape is + `{filterGroups: [{filters: [{propertyName, operator, value}]}]}`. + Multiple groups OR together; filters within a group AND. Operators: + `EQ`, `NEQ`, `GT`, `GTE`, `LT`, `LTE`, `BETWEEN`, `IN`, `NOT_IN`, + `CONTAINS_TOKEN`, `HAS_PROPERTY`, `NOT_HAS_PROPERTY`. +- **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. +- **Token refresh is automatic for OAuth credentials.** Access tokens + expire after ~30 minutes; the client checks `token_expiry` on every + request 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 stale token is used and the next API call + surfaces HubSpot's 401 — the user should reconnect the account. + 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. 429 + responses include `Retry-After` — respect it. +- **Webhooks require an App ID, not a portal ID.** The webhooks API is + for HubSpot Apps (the same kind registered for OAuth), not Private + Apps. The `app_id` arg on the webhook actions is HubSpot's app ID + from the developer console — distinct from the portal/hub ID of the + authenticated account. Skip these actions entirely when authenticated + via a Private App token. +- **Form submissions don't take auth.** `submit_hubspot_form` posts to + `api.hsforms.com`, not `api.hubapi.com`, and the form GUID + portal + ID alone are the authentication. Anyone can submit; the credential is + only used so the action wrapper has a way to look up the portal_id — + make sure the `portal_id` you pass matches the hub the form lives in. +- **The Lists API is v3 only.** The legacy `/contacts/v1/lists` + endpoints are deprecated — don't add them back. `list_hubspot_lists` + uses `POST /crm/v3/lists/search`, which is correct. diff --git a/craftos_integrations/providers/hubspot/__init__.py b/craftos_integrations/providers/hubspot/__init__.py new file mode 100644 index 00000000..06b86125 --- /dev/null +++ b/craftos_integrations/providers/hubspot/__init__.py @@ -0,0 +1,3 @@ +from .provider import HubSpotProvider + +__all__ = ["HubSpotProvider"] diff --git a/craftos_integrations/providers/hubspot/operations.py b/craftos_integrations/providers/hubspot/operations.py new file mode 100644 index 00000000..09ffe2f2 --- /dev/null +++ b/craftos_integrations/providers/hubspot/operations.py @@ -0,0 +1,2161 @@ +"""HubSpot operations — ported from the legacy hubspot_actions.py schemas. + +Complete port of app/data/action/integrations/hubspot/hubspot_actions.py — +all 90 actions, same names/descriptions/schemas/arg mapping. No operation +declares an ``account`` input (conformance-enforced; the host injects it). + +Porting notes: +- Legacy ``irreversible=True`` (send_hubspot_single_send, + send_hubspot_conversation_message) → ``destructive=True``; delete/remove + operations are also flagged destructive per the conformance rule. + Legacy ``parallelizable=False`` (every mutation) carries over 1:1. +- The HubSpot client returns the package's ``{ok: True, result: ...}`` / + ``{error, details}`` envelope from ``helpers.http.arequest`` — exactly + what ``client_op``'s default ``shape_result`` collapses, so envelope + handling matches legacy ``run_client`` behavior with no options. +- Post-processing is reproduced verbatim via fn-wrapping (same pattern as + slack/gmail): ``_pick`` = legacy ``pick_result``; ``_lean_listing`` = + the per-row archived/createdAt/updatedAt strip + paging.next.link drop + applied to every list/search action; ``_batch_ids`` and + ``_created_list_id`` are the two bespoke reducers. +- Comma-separated ``properties``/``associations`` inputs are split into + lists exactly as the legacy actions did (``_csv``). + +The legacy file's "intentionally NOT exposed" list carries over +unchanged: Workflows/Automation authoring, CMS Hub, CTAs, Settings +(users/teams), Quotes/Line Items/Products, Payments, Custom Object +schema authoring, Analytics ingestion, Email Subscription preferences, +legacy v1 single-send, Calling/Video extensions were never actions and +stay out. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Callable, Dict, List, Optional + +from ...contracts import Operation +from .._shared import client_op + +_STATUS = {"status": {"type": "string", "example": "success"}} + + +# ──────────────────────────────────────────────────────────────────────── +# Schema-fragment builders (fresh dicts; descriptions/examples verbatim) +# ──────────────────────────────────────────────────────────────────────── + + +def _s(description: str, example: str = "") -> Dict[str, Any]: + return {"type": "string", "description": description, "example": example} + + +def _i(description: str, example: int) -> Dict[str, Any]: + return {"type": "integer", "description": description, "example": example} + + +def _b(description: str, example: bool = False) -> Dict[str, Any]: + return {"type": "boolean", "description": description, "example": example} + + +def _arr(description: str, example: List[Any]) -> Dict[str, Any]: + return {"type": "array", "description": description, "example": example} + + +def _obj(description: str, example: Dict[str, Any]) -> Dict[str, Any]: + return {"type": "object", "description": description, "example": example} + + +def _limit(example: int = 30, description: str = "Max results.") -> Dict[str, Any]: + return _i(description, example) + + +def _after() -> Dict[str, Any]: + return _s("Pagination cursor.", "") + + +def _only(description: str) -> Dict[str, Any]: + return {**_STATUS, "result": {"type": "object", "description": description}} + + +# ──────────────────────────────────────────────────────────────────────── +# Post-processing helpers (legacy shaping, verbatim) +# ──────────────────────────────────────────────────────────────────────── + + +def _with_post( + base: Operation, + post: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], +) -> Operation: + """Wrap an operation's fn with a (result, input_data) post-processor.""" + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + return post(await inner(client, input_data), input_data) + + return replace(base, fn=fn) + + +def _pick(keys: List[str]): + """Legacy ``pick_result``: reduce a successful result to named keys.""" + + def post(res: Dict[str, Any], _input: Dict[str, Any]) -> Dict[str, Any]: + if res.get("status") == "success" and isinstance(res.get("result"), dict): + r = res["result"] + picked = {k: r.get(k) for k in keys if r.get(k) is not None} + if picked: + res = {**res, "result": picked} + return res + + return post + + +def _lean_listing(res: Dict[str, Any], _input: Dict[str, Any]) -> Dict[str, Any]: + """Legacy list shaping: drop archived/createdAt/updatedAt from each + result row and the paging.next.link URL (agents only need the cursor).""" + r = res.get("result") + if isinstance(r, dict): + for it in r.get("results") or []: + if isinstance(it, dict): + it.pop("archived", None) + it.pop("createdAt", None) + it.pop("updatedAt", None) + nxt = (r.get("paging") or {}).get("next") + if isinstance(nxt, dict): + nxt.pop("link", None) + return res + + +def _batch_ids(res: Dict[str, Any], _input: Dict[str, Any]) -> Dict[str, Any]: + """Legacy batch-create shaping: reduce to {ids, numErrors?, errors?}.""" + r = res.get("result") + if ( + res.get("status") == "success" + and isinstance(r, dict) + and isinstance(r.get("results"), list) + ): + reduced: Dict[str, Any] = { + "ids": [i.get("id") for i in r["results"] if isinstance(i, dict)] + } + if r.get("numErrors"): + reduced["numErrors"] = r.get("numErrors") + reduced["errors"] = r.get("errors") + res = {**res, "result": reduced} + return res + + +def _created_list_id(res: Dict[str, Any], _input: Dict[str, Any]) -> Dict[str, Any]: + """Legacy create_hubspot_list shaping: reduce to {listId}.""" + r = res.get("result") + if res.get("status") == "success" and isinstance(r, dict): + lst = r.get("list") if isinstance(r.get("list"), dict) else r + list_id = lst.get("listId") or lst.get("id") + if list_id is not None: + res = {**res, "result": {"listId": list_id}} + return res + + +def _csv(value: Any) -> Optional[List[str]]: + """Legacy comma-string parsing: 'a, b' → ['a', 'b']; empty → None.""" + return [p.strip() for p in str(value or "").split(",") if p.strip()] or None + + +# ──────────────────────────────────────────────────────────────────────── +# Operations +# ──────────────────────────────────────────────────────────────────────── + + +def build_operations() -> List[Operation]: + return [ + # ── Contacts ───────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_contacts", + "list_contacts", + description=( + "List HubSpot contacts. Paginated; pass 'after' from the " + "previous response's paging.next.after to get more." + ), + tags=("hubspot_contacts", "hubspot"), + input_schema={ + "limit": _limit(30, "Max results (1-100, default 30)."), + "after": _s("Pagination cursor from previous response.", ""), + "properties": _s( + "Comma-separated property names to include.", + "email,firstname,lastname", + ), + "archived": _b("Include archived contacts."), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + "archived": d.get("archived", False), + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_contact", + "get_contact", + description=( + "Get a HubSpot contact by ID. Returns properties and (if " + "requested) associated objects." + ), + tags=("hubspot_contacts", "hubspot"), + input_schema={ + "contact_id": _s("HubSpot contact ID (numeric string).", "123456789"), + "properties": _s( + "Comma-separated property names to include.", + "email,firstname,lastname,phone", + ), + "associations": _s( + "Comma-separated object types to include associations for.", + "companies,deals", + ), + }, + arg_map=lambda d: { + "contact_id": d["contact_id"], + "properties": _csv(d.get("properties", "")), + "associations": _csv(d.get("associations", "")), + }, + ), + _with_post( + client_op( + "create_hubspot_contact", + "create_contact", + description=( + "Create a HubSpot contact. 'properties' is a flat dict like " + "{email, firstname, lastname, phone, company}. Returns only " + "{id}." + ), + parallelizable=False, + tags=("hubspot_contacts", "hubspot"), + input_schema={ + "properties": _obj( + "Flat property dict.", + { + "email": "jane@example.com", + "firstname": "Jane", + "lastname": "Doe", + }, + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: {"properties": d["properties"]}, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "update_hubspot_contact", + "update_contact", + description="Update a HubSpot contact's properties. Returns only {id}.", + parallelizable=False, + tags=("hubspot_contacts", "hubspot"), + input_schema={ + "contact_id": _s("Contact ID.", "123456789"), + "properties": _obj( + "Properties to update (flat dict).", {"phone": "+1-555-0100"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "contact_id": d["contact_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_contact", + "delete_contact", + description=( + "Archive (soft-delete) a HubSpot contact. The record can be " + "restored from the trash UI." + ), + destructive=True, + parallelizable=False, + tags=("hubspot_contacts",), + input_schema={"contact_id": _s("Contact ID.", "123456789")}, + arg_map=lambda d: {"contact_id": d["contact_id"]}, + ), + _with_post( + client_op( + "search_hubspot_contacts", + "search_contacts", + description=( + "Search HubSpot contacts. Use 'query' for free-text or " + "'filter_groups' for precise property filters (operators: " + "EQ, NEQ, GT, GTE, LT, LTE, BETWEEN, IN, NOT_IN, " + "CONTAINS_TOKEN, HAS_PROPERTY)." + ), + tags=("hubspot_contacts", "hubspot"), + input_schema={ + "query": _s( + "Free-text search across default searchable properties.", + "jane@example.com", + ), + "filter_groups": _arr( + "Filter groups: [{filters: [{propertyName, operator, value}]}].", + [ + { + "filters": [ + { + "propertyName": "email", + "operator": "EQ", + "value": "jane@example.com", + } + ] + } + ], + ), + "properties": _s( + "Comma-separated properties to return.", + "email,firstname,lastname", + ), + "limit": _limit(30, "Max results (1-100)."), + "after": _after(), + }, + arg_map=lambda d: { + "query": d.get("query") or None, + "filter_groups": d.get("filter_groups") or None, + "properties": _csv(d.get("properties", "")), + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "batch_get_hubspot_contacts", + "batch_get_contacts", + description="Read up to 100 contacts in a single call. Cheaper than N gets.", + tags=("hubspot_contacts",), + input_schema={ + "ids": _arr("Contact IDs.", ["123", "456", "789"]), + "properties": _s( + "Comma-separated properties to return.", "email,firstname" + ), + }, + arg_map=lambda d: { + "ids": d["ids"], + "properties": _csv(d.get("properties", "")), + }, + ), + _with_post( + client_op( + "batch_create_hubspot_contacts", + "batch_create_contacts", + description=( + "Create up to 100 contacts in a single call. 'records' is a " + "list of flat property dicts. Returns only the created ids " + "(+ errors if any)." + ), + parallelizable=False, + tags=("hubspot_contacts",), + input_schema={ + "records": _arr( + "List of property dicts.", + [{"email": "a@x.com"}, {"email": "b@x.com"}], + ), + }, + output_schema=_only("Only {ids, numErrors?, errors?}."), + arg_map=lambda d: {"records": d["records"]}, + ), + _batch_ids, + ), + _with_post( + client_op( + "merge_hubspot_contacts", + "merge_contacts", + description=( + "Merge two contacts. The primary contact survives; the " + "secondary is archived with associations transferred. " + "Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_contacts",), + input_schema={ + "primary_id": _s("Contact ID that survives the merge.", "123"), + "id_to_merge": _s( + "Contact ID that gets merged INTO the primary.", "456" + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "primary_id": d["primary_id"], + "id_to_merge": d["id_to_merge"], + }, + ), + _pick(["id"]), + ), + # ── Companies ──────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_companies", + "list_companies", + description="List HubSpot companies. Paginated via 'after' cursor.", + tags=("hubspot_companies", "hubspot"), + input_schema={ + "limit": _limit(30, "Max results (1-100)."), + "after": _after(), + "properties": _s( + "Comma-separated property names.", "name,domain,industry" + ), + "archived": _b("Include archived."), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + "archived": d.get("archived", False), + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_company", + "get_company", + description="Get a HubSpot company by ID.", + tags=("hubspot_companies",), + input_schema={ + "company_id": _s("Company ID (numeric string).", "123456789"), + "properties": _s( + "Comma-separated properties.", "name,domain,industry,city" + ), + "associations": _s( + "Comma-separated association types.", "contacts,deals" + ), + }, + arg_map=lambda d: { + "company_id": d["company_id"], + "properties": _csv(d.get("properties", "")), + "associations": _csv(d.get("associations", "")), + }, + ), + _with_post( + client_op( + "create_hubspot_company", + "create_company", + description=( + "Create a HubSpot company. Typical properties: name, domain, " + "industry, city, country. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_companies", "hubspot"), + input_schema={ + "properties": _obj( + "Flat property dict.", {"name": "Acme Co", "domain": "acme.com"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: {"properties": d["properties"]}, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "update_hubspot_company", + "update_company", + description="Update a HubSpot company's properties. Returns only {id}.", + parallelizable=False, + tags=("hubspot_companies",), + input_schema={ + "company_id": _s("Company ID.", "123456789"), + "properties": _obj( + "Properties to update.", {"industry": "Software"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "company_id": d["company_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_company", + "delete_company", + description="Archive (soft-delete) a HubSpot company.", + destructive=True, + parallelizable=False, + tags=("hubspot_companies",), + input_schema={"company_id": _s("Company ID.", "123456789")}, + arg_map=lambda d: {"company_id": d["company_id"]}, + ), + _with_post( + client_op( + "search_hubspot_companies", + "search_companies", + description=( + "Search HubSpot companies using query or filter_groups " + "(same shape as contact search)." + ), + tags=("hubspot_companies", "hubspot"), + input_schema={ + "query": _s("Free-text search.", "acme"), + "filter_groups": _arr( + "Property filter groups.", + [ + { + "filters": [ + { + "propertyName": "domain", + "operator": "EQ", + "value": "acme.com", + } + ] + } + ], + ), + "properties": _s( + "Comma-separated properties to return.", "name,domain" + ), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "query": d.get("query") or None, + "filter_groups": d.get("filter_groups") or None, + "properties": _csv(d.get("properties", "")), + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "batch_get_hubspot_companies", + "batch_get_companies", + description="Read up to 100 companies in a single call.", + tags=("hubspot_companies",), + input_schema={ + "ids": _arr("Company IDs.", ["123", "456"]), + "properties": _s("Comma-separated properties.", "name,domain"), + }, + arg_map=lambda d: { + "ids": d["ids"], + "properties": _csv(d.get("properties", "")), + }, + ), + _with_post( + client_op( + "batch_create_hubspot_companies", + "batch_create_companies", + description=( + "Create up to 100 companies in a single call. Returns only " + "the created ids (+ errors if any)." + ), + parallelizable=False, + tags=("hubspot_companies",), + input_schema={ + "records": _arr( + "List of property dicts.", [{"name": "Acme"}, {"name": "Foo"}] + ), + }, + output_schema=_only("Only {ids, numErrors?, errors?}."), + arg_map=lambda d: {"records": d["records"]}, + ), + _batch_ids, + ), + # ── Deals ──────────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_deals", + "list_deals", + description="List HubSpot deals. Paginated.", + tags=("hubspot_deals", "hubspot"), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "dealname,amount,dealstage,pipeline", + ), + "archived": _b("Include archived."), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + "archived": d.get("archived", False), + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_deal", + "get_deal", + description="Get a HubSpot deal by ID.", + tags=("hubspot_deals",), + input_schema={ + "deal_id": _s("Deal ID.", "123456789"), + "properties": _s( + "Comma-separated properties.", + "dealname,amount,dealstage,pipeline,closedate", + ), + "associations": _s( + "Comma-separated association types.", "contacts,companies" + ), + }, + arg_map=lambda d: { + "deal_id": d["deal_id"], + "properties": _csv(d.get("properties", "")), + "associations": _csv(d.get("associations", "")), + }, + ), + _with_post( + client_op( + "create_hubspot_deal", + "create_deal", + description=( + "Create a HubSpot deal. Typical properties: dealname, " + "amount, dealstage, pipeline, closedate, hubspot_owner_id. " + "Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_deals", "hubspot"), + input_schema={ + "properties": _obj( + "Flat property dict.", + { + "dealname": "Q3 renewal", + "amount": "50000", + "dealstage": "qualifiedtobuy", + }, + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: {"properties": d["properties"]}, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "update_hubspot_deal", + "update_deal", + description="Update a HubSpot deal's properties. Returns only {id}.", + parallelizable=False, + tags=("hubspot_deals", "hubspot"), + input_schema={ + "deal_id": _s("Deal ID.", "123456789"), + "properties": _obj("Properties to update.", {"amount": "75000"}), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "deal_id": d["deal_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_deal", + "delete_deal", + description="Archive (soft-delete) a HubSpot deal.", + destructive=True, + parallelizable=False, + tags=("hubspot_deals",), + input_schema={"deal_id": _s("Deal ID.", "123456789")}, + arg_map=lambda d: {"deal_id": d["deal_id"]}, + ), + _with_post( + client_op( + "search_hubspot_deals", + "search_deals", + description="Search HubSpot deals via query or filter_groups.", + tags=("hubspot_deals",), + input_schema={ + "query": _s("Free-text search.", "renewal"), + "filter_groups": _arr( + "Property filter groups.", + [ + { + "filters": [ + { + "propertyName": "dealstage", + "operator": "EQ", + "value": "closedwon", + } + ] + } + ], + ), + "properties": _s("Comma-separated properties.", "dealname,amount"), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "query": d.get("query") or None, + "filter_groups": d.get("filter_groups") or None, + "properties": _csv(d.get("properties", "")), + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "batch_create_hubspot_deals", + "batch_create_deals", + description=( + "Create up to 100 deals in a single call. Returns only the " + "created ids (+ errors if any)." + ), + parallelizable=False, + tags=("hubspot_deals",), + input_schema={ + "records": _arr( + "List of property dicts.", + [{"dealname": "A"}, {"dealname": "B"}], + ), + }, + output_schema=_only("Only {ids, numErrors?, errors?}."), + arg_map=lambda d: {"records": d["records"]}, + ), + _batch_ids, + ), + _with_post( + client_op( + "move_hubspot_deal_stage", + "move_deal_stage", + description=( + "Move a deal to a different pipeline stage. Helper around " + "updating the 'dealstage' property. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_deals", "hubspot"), + input_schema={ + "deal_id": _s("Deal ID.", "123456789"), + "stage_id": _s( + "Target stage ID (use list_hubspot_pipeline_stages to find).", + "closedwon", + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "deal_id": d["deal_id"], + "stage_id": d["stage_id"], + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "list_hubspot_deals_by_pipeline", + "list_deals_by_pipeline", + description=( + "List deals in a specific pipeline. Helper that wraps " + "search with a pipeline filter." + ), + tags=("hubspot_deals",), + input_schema={ + "pipeline_id": _s("Pipeline ID.", "default"), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "pipeline_id": d["pipeline_id"], + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + # ── Tickets ────────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_tickets", + "list_tickets", + description="List HubSpot support tickets. Paginated.", + tags=("hubspot_tickets", "hubspot"), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "subject,content,hs_pipeline_stage,hs_ticket_priority", + ), + "archived": _b("Include archived."), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + "archived": d.get("archived", False), + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_ticket", + "get_ticket", + description="Get a HubSpot ticket by ID.", + tags=("hubspot_tickets",), + input_schema={ + "ticket_id": _s("Ticket ID.", "123456789"), + "properties": _s( + "Comma-separated properties.", "subject,content,hs_pipeline_stage" + ), + "associations": _s( + "Comma-separated association types.", "contacts,companies" + ), + }, + arg_map=lambda d: { + "ticket_id": d["ticket_id"], + "properties": _csv(d.get("properties", "")), + "associations": _csv(d.get("associations", "")), + }, + ), + _with_post( + client_op( + "create_hubspot_ticket", + "create_ticket", + description=( + "Create a HubSpot support ticket. Typical properties: " + "subject, content, hs_pipeline, hs_pipeline_stage, " + "hs_ticket_priority (LOW/MEDIUM/HIGH/URGENT). Returns only " + "{id}." + ), + parallelizable=False, + tags=("hubspot_tickets", "hubspot"), + input_schema={ + "properties": _obj( + "Flat property dict.", + { + "subject": "Login fails", + "content": "User can't log in", + "hs_ticket_priority": "HIGH", + }, + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: {"properties": d["properties"]}, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "update_hubspot_ticket", + "update_ticket", + description="Update a HubSpot ticket's properties. Returns only {id}.", + parallelizable=False, + tags=("hubspot_tickets",), + input_schema={ + "ticket_id": _s("Ticket ID.", "123456789"), + "properties": _obj( + "Properties to update.", {"hs_ticket_priority": "URGENT"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "ticket_id": d["ticket_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_ticket", + "delete_ticket", + description="Archive (soft-delete) a HubSpot ticket.", + destructive=True, + parallelizable=False, + tags=("hubspot_tickets",), + input_schema={"ticket_id": _s("Ticket ID.", "123456789")}, + arg_map=lambda d: {"ticket_id": d["ticket_id"]}, + ), + _with_post( + client_op( + "search_hubspot_tickets", + "search_tickets", + description="Search HubSpot tickets via query or filter_groups.", + tags=("hubspot_tickets",), + input_schema={ + "query": _s("Free-text search.", "login"), + "filter_groups": _arr( + "Filter groups.", + [ + { + "filters": [ + { + "propertyName": "hs_ticket_priority", + "operator": "EQ", + "value": "HIGH", + } + ] + } + ], + ), + "properties": _s("Comma-separated properties.", "subject,content"), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "query": d.get("query") or None, + "filter_groups": d.get("filter_groups") or None, + "properties": _csv(d.get("properties", "")), + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "close_hubspot_ticket", + "close_ticket", + description=( + "Move a ticket to its closed stage. Helper around updating " + "'hs_pipeline_stage'. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_tickets", "hubspot"), + input_schema={ + "ticket_id": _s("Ticket ID.", "123456789"), + "closed_stage_id": _s( + "Closed-stage ID for this pipeline (use " + "list_hubspot_pipeline_stages).", + "4", + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "ticket_id": d["ticket_id"], + "closed_stage_id": d["closed_stage_id"], + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "list_hubspot_tickets_by_pipeline", + "list_tickets_by_pipeline", + description="List tickets in a specific pipeline. Helper that wraps search.", + tags=("hubspot_tickets",), + input_schema={ + "pipeline_id": _s("Pipeline ID.", "0"), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "pipeline_id": d["pipeline_id"], + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + # ── Engagements: tasks ─────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_tasks", + "list_tasks", + description="List HubSpot tasks (engagements).", + tags=("hubspot_engagements",), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "hs_task_subject,hs_task_status,hs_timestamp", + ), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "create_hubspot_task", + "create_task", + description=( + "Create a HubSpot task. Optionally associate it with a " + "contact/company/deal/ticket. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_engagements", "hubspot"), + input_schema={ + "subject": _s("Task title.", "Follow up on demo"), + "body": _s("Task description.", "Ask about pricing tier"), + "due_timestamp_ms": _i("Due date in ms since epoch.", 1735689600000), + "owner_id": _s("Owner (user) ID to assign.", "12345"), + "priority": _s("NONE | LOW | MEDIUM | HIGH.", "MEDIUM"), + "status": _s( + "NOT_STARTED | IN_PROGRESS | WAITING | COMPLETED | DEFERRED.", + "NOT_STARTED", + ), + "associated_object_type": _s( + "Type of object to associate " + "(contacts/companies/deals/tickets).", + "contacts", + ), + "associated_object_id": _s( + "ID of the associated object.", "123456789" + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "subject": d["subject"], + "body": d.get("body", ""), + "due_timestamp_ms": d.get("due_timestamp_ms"), + "owner_id": d.get("owner_id") or None, + "priority": d.get("priority", "NONE"), + "status": d.get("status", "NOT_STARTED"), + "associated_object_type": d.get("associated_object_type") or None, + "associated_object_id": d.get("associated_object_id") or None, + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "update_hubspot_task", + "update_task", + description=( + "Update a HubSpot task. Common updates: hs_task_status, " + "hs_task_priority, hs_task_subject. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={ + "task_id": _s("Task ID.", "123456789"), + "properties": _obj( + "Properties to update.", {"hs_task_status": "COMPLETED"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "task_id": d["task_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_task", + "delete_task", + description="Archive a HubSpot task.", + destructive=True, + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={"task_id": _s("Task ID.", "123456789")}, + arg_map=lambda d: {"task_id": d["task_id"]}, + ), + # ── Engagements: notes ─────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_notes", + "list_notes", + description="List HubSpot notes (engagements).", + tags=("hubspot_engagements",), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", "hs_note_body,hs_timestamp" + ), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "create_hubspot_note", + "create_note", + description=( + "Create a HubSpot note (typically attached to a " + "contact/company/deal/ticket). Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_engagements", "hubspot"), + input_schema={ + "body": _s( + "Note content (HTML supported).", + "Customer mentioned interest in Enterprise tier", + ), + "owner_id": _s("Owner ID.", "12345"), + "associated_object_type": _s( + "contacts/companies/deals/tickets.", "contacts" + ), + "associated_object_id": _s("ID of associated object.", "123456789"), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "body": d["body"], + "owner_id": d.get("owner_id") or None, + "associated_object_type": d.get("associated_object_type") or None, + "associated_object_id": d.get("associated_object_id") or None, + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_note", + "delete_note", + description="Archive a HubSpot note.", + destructive=True, + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={"note_id": _s("Note ID.", "123456789")}, + arg_map=lambda d: {"note_id": d["note_id"]}, + ), + # ── Engagements: calls ─────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_calls", + "list_calls", + description="List HubSpot call engagements (logged calls).", + tags=("hubspot_engagements",), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "hs_call_title,hs_call_duration,hs_call_direction", + ), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "log_hubspot_call", + "log_call", + description="Log a phone call as a HubSpot engagement. Returns only {id}.", + parallelizable=False, + tags=("hubspot_engagements", "hubspot"), + input_schema={ + "title": _s("Call title.", "Discovery call"), + "body": _s("Call notes.", "Discussed pricing"), + "timestamp_ms": _i( + "When the call happened (ms epoch). Defaults to now.", + 1735689600000, + ), + "duration_ms": _i("Call duration in ms.", 600000), + "from_number": _s("Caller phone.", "+1-555-0100"), + "to_number": _s("Callee phone.", "+1-555-0200"), + "direction": _s("INBOUND | OUTBOUND.", "OUTBOUND"), + "disposition": _s("Outcome ID (configured per portal).", ""), + "owner_id": _s("Owner ID.", "12345"), + "associated_object_type": _s( + "contacts/companies/deals/tickets.", "contacts" + ), + "associated_object_id": _s("Associated object ID.", "123456789"), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "title": d["title"], + "body": d.get("body", ""), + "timestamp_ms": d.get("timestamp_ms"), + "duration_ms": d.get("duration_ms"), + "from_number": d.get("from_number") or None, + "to_number": d.get("to_number") or None, + "direction": d.get("direction", "OUTBOUND"), + "disposition": d.get("disposition") or None, + "owner_id": d.get("owner_id") or None, + "associated_object_type": d.get("associated_object_type") or None, + "associated_object_id": d.get("associated_object_id") or None, + }, + ), + _pick(["id"]), + ), + # ── Engagements: emails ────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_emails", + "list_emails", + description=( + "List HubSpot email engagements (logged emails — not " + "marketing email sends)." + ), + tags=("hubspot_engagements",), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "hs_email_subject,hs_email_direction", + ), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "log_hubspot_email", + "log_email", + description=( + "Log an email as a HubSpot engagement (for record-keeping; " + "doesn't actually send). Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={ + "subject": _s("Email subject.", "Re: Pricing"), + "text_body": _s("Plain-text body.", "Here's the proposal"), + "html_body": _s("HTML body (optional).", ""), + "timestamp_ms": _i("When sent (ms epoch).", 1735689600000), + "direction": _s( + "EMAIL (incoming) | INCOMING_EMAIL | FORWARDED_EMAIL.", + "EMAIL", + ), + "from_email": _s("Sender.", "you@yourdomain.com"), + "to_email": _s("Recipient.", "customer@example.com"), + "owner_id": _s("Owner ID.", "12345"), + "associated_object_type": _s( + "contacts/companies/deals/tickets.", "contacts" + ), + "associated_object_id": _s("Associated object ID.", "123456789"), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "subject": d["subject"], + "text_body": d.get("text_body", ""), + "html_body": d.get("html_body", ""), + "timestamp_ms": d.get("timestamp_ms"), + "direction": d.get("direction", "EMAIL"), + "from_email": d.get("from_email") or None, + "to_email": d.get("to_email") or None, + "owner_id": d.get("owner_id") or None, + "associated_object_type": d.get("associated_object_type") or None, + "associated_object_id": d.get("associated_object_id") or None, + }, + ), + _pick(["id"]), + ), + # ── Engagements: meetings ──────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_meetings", + "list_meetings", + description="List HubSpot meeting engagements.", + tags=("hubspot_engagements",), + input_schema={ + "limit": _limit(), + "after": _after(), + "properties": _s( + "Comma-separated properties.", + "hs_meeting_title,hs_meeting_start_time", + ), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + "properties": _csv(d.get("properties", "")), + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "create_hubspot_meeting", + "create_meeting", + description="Create a HubSpot meeting engagement record. Returns only {id}.", + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={ + "title": _s("Meeting title.", "Quarterly review"), + "body": _s("Description / agenda.", "Review Q3 numbers"), + "start_timestamp_ms": _i("Start time (ms epoch).", 1735689600000), + "end_timestamp_ms": _i("End time (ms epoch).", 1735693200000), + "location": _s("Where (URL or address).", "https://zoom.us/j/123"), + "meeting_outcome": _s("Outcome ID (configured per portal).", ""), + "owner_id": _s("Owner ID.", "12345"), + "associated_object_type": _s( + "contacts/companies/deals/tickets.", "deals" + ), + "associated_object_id": _s("Associated object ID.", "123456789"), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "title": d["title"], + "body": d.get("body", ""), + "start_timestamp_ms": d["start_timestamp_ms"], + "end_timestamp_ms": d["end_timestamp_ms"], + "location": d.get("location") or None, + "meeting_outcome": d.get("meeting_outcome") or None, + "owner_id": d.get("owner_id") or None, + "associated_object_type": d.get("associated_object_type") or None, + "associated_object_id": d.get("associated_object_id") or None, + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_meeting", + "delete_meeting", + description="Archive a HubSpot meeting engagement.", + destructive=True, + parallelizable=False, + tags=("hubspot_engagements",), + input_schema={"meeting_id": _s("Meeting ID.", "123456789")}, + arg_map=lambda d: {"meeting_id": d["meeting_id"]}, + ), + # ── Lists ──────────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_lists", + "list_lists", + description="List/search HubSpot lists. Optionally filter to specific list IDs.", + tags=("hubspot_lists",), + input_schema={ + "limit": _limit(30, "Max results (1-500)."), + "list_ids": _arr("Optional: specific list IDs to fetch.", []), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "list_ids": d.get("list_ids") or None, + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_list", + "get_list", + description="Get a HubSpot list by ID.", + tags=("hubspot_lists",), + input_schema={"list_id": _s("List ID.", "1")}, + arg_map=lambda d: {"list_id": d["list_id"]}, + ), + _with_post( + client_op( + "create_hubspot_list", + "create_list", + description=( + "Create a HubSpot list. processing_type=MANUAL for static " + "(you add contacts yourself); DYNAMIC for filter-based. " + "Returns only {listId}." + ), + parallelizable=False, + tags=("hubspot_lists",), + input_schema={ + "name": _s("List name.", "Q3 prospects"), + "object_type_id": _s( + "Object type ID (0-1=contact, 0-2=company, 0-3=deal, " + "0-5=ticket).", + "0-1", + ), + "processing_type": _s("MANUAL or DYNAMIC.", "MANUAL"), + "filter_branch": _obj("Filter tree for DYNAMIC lists.", {}), + }, + output_schema=_only("Only {listId}."), + arg_map=lambda d: { + "name": d["name"], + "object_type_id": d.get("object_type_id", "0-1"), + "processing_type": d.get("processing_type", "MANUAL"), + "filter_branch": d.get("filter_branch") or None, + }, + ), + _created_list_id, + ), + client_op( + "delete_hubspot_list", + "delete_list", + description="Delete a HubSpot list.", + destructive=True, + parallelizable=False, + tags=("hubspot_lists",), + input_schema={"list_id": _s("List ID.", "1")}, + arg_map=lambda d: {"list_id": d["list_id"]}, + ), + client_op( + "add_contacts_to_hubspot_list", + "add_contacts_to_list", + description="Add contact IDs to a static (MANUAL) list. No-op on DYNAMIC lists.", + parallelizable=False, + tags=("hubspot_lists",), + input_schema={ + "list_id": _s("List ID.", "1"), + "contact_ids": _arr("Contact IDs to add.", ["123", "456"]), + }, + arg_map=lambda d: { + "list_id": d["list_id"], + "contact_ids": d["contact_ids"], + }, + ), + client_op( + "remove_contacts_from_hubspot_list", + "remove_contacts_from_list", + description="Remove contact IDs from a static (MANUAL) list.", + destructive=True, + parallelizable=False, + tags=("hubspot_lists",), + input_schema={ + "list_id": _s("List ID.", "1"), + "contact_ids": _arr("Contact IDs to remove.", ["123", "456"]), + }, + arg_map=lambda d: { + "list_id": d["list_id"], + "contact_ids": d["contact_ids"], + }, + ), + # ── Pipelines ──────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_pipelines", + "list_pipelines", + description=( + "List all pipelines for an object type (typically 'deals' " + "or 'tickets')." + ), + tags=("hubspot_pipelines",), + input_schema={ + "object_type": _s("Object type: deals or tickets.", "deals"), + }, + arg_map=lambda d: {"object_type": d["object_type"]}, + ), + _lean_listing, + ), + client_op( + "get_hubspot_pipeline", + "get_pipeline", + description="Get a pipeline definition (including stages).", + tags=("hubspot_pipelines",), + input_schema={ + "object_type": _s("deals or tickets.", "deals"), + "pipeline_id": _s("Pipeline ID.", "default"), + }, + arg_map=lambda d: { + "object_type": d["object_type"], + "pipeline_id": d["pipeline_id"], + }, + ), + _with_post( + client_op( + "create_hubspot_pipeline", + "create_pipeline", + description=( + "Create a new pipeline. 'stages' is a list of {label, " + "displayOrder, metadata:{probability,...}} dicts. Returns " + "only {id}." + ), + parallelizable=False, + tags=("hubspot_pipelines",), + input_schema={ + "object_type": _s("deals or tickets.", "deals"), + "label": _s("Pipeline name.", "Renewals"), + "stages": _arr( + "Stage definitions.", + [ + { + "label": "New", + "displayOrder": 0, + "metadata": {"probability": "0.1"}, + } + ], + ), + "display_order": _i("Display order among pipelines.", 0), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "object_type": d["object_type"], + "label": d["label"], + "stages": d["stages"], + "display_order": d.get("display_order", 0), + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "list_hubspot_pipeline_stages", + "list_pipeline_stages", + description=( + "List the stages of a pipeline. Returns stage IDs needed " + "for move_hubspot_deal_stage / close_hubspot_ticket." + ), + tags=("hubspot_pipelines",), + input_schema={ + "object_type": _s("deals or tickets.", "deals"), + "pipeline_id": _s("Pipeline ID.", "default"), + }, + arg_map=lambda d: { + "object_type": d["object_type"], + "pipeline_id": d["pipeline_id"], + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "update_hubspot_pipeline_stage", + "update_pipeline_stage", + description=( + "Update a pipeline stage's properties (label, displayOrder, " + "metadata). Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_pipelines",), + input_schema={ + "object_type": _s("deals or tickets.", "deals"), + "pipeline_id": _s("Pipeline ID.", "default"), + "stage_id": _s("Stage ID.", "qualifiedtobuy"), + "properties": _obj( + "Stage fields to update.", {"label": "Qualified — Buying"} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "object_type": d["object_type"], + "pipeline_id": d["pipeline_id"], + "stage_id": d["stage_id"], + "properties": d["properties"], + }, + ), + _pick(["id"]), + ), + # ── Owners ─────────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_owners", + "list_owners", + description=( + "List HubSpot users (owners). Use this to find owner IDs " + "for assignment." + ), + tags=("hubspot_owners", "hubspot"), + input_schema={ + "email": _s("Optional: filter to one owner by email.", ""), + "limit": _limit(100, "Max results (1-500)."), + }, + arg_map=lambda d: { + "email": d.get("email") or None, + "limit": d.get("limit", 100), + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_owner", + "get_owner", + description="Get a HubSpot owner (user) by ID.", + tags=("hubspot_owners",), + input_schema={"owner_id": _s("Owner ID.", "12345")}, + arg_map=lambda d: {"owner_id": d["owner_id"]}, + ), + # ── Properties ─────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_properties", + "list_properties", + description=( + "List all defined properties for an object type. Use this " + "to discover custom-field names before reading/writing " + "them." + ), + tags=("hubspot_properties",), + input_schema={ + "object_type": _s( + "contacts/companies/deals/tickets or custom schema name.", + "contacts", + ), + }, + arg_map=lambda d: {"object_type": d["object_type"]}, + ), + _lean_listing, + ), + client_op( + "get_hubspot_property", + "get_property", + description="Get a property definition (type, options, group).", + tags=("hubspot_properties",), + input_schema={ + "object_type": _s("Object type.", "contacts"), + "property_name": _s("Property internal name.", "firstname"), + }, + arg_map=lambda d: { + "object_type": d["object_type"], + "property_name": d["property_name"], + }, + ), + _with_post( + client_op( + "create_hubspot_property", + "create_property", + description=( + "Create a new custom property. 'definition' must include " + "name, label, type, fieldType, groupName. Returns only " + "{id, name, type}." + ), + parallelizable=False, + tags=("hubspot_properties",), + input_schema={ + "object_type": _s("Object type.", "contacts"), + "definition": _obj( + "Property definition.", + { + "name": "favorite_color", + "label": "Favorite color", + "type": "string", + "fieldType": "text", + "groupName": "contactinformation", + }, + ), + }, + output_schema=_only("Only {id, name, type}."), + arg_map=lambda d: { + "object_type": d["object_type"], + "definition": d["definition"], + }, + ), + _pick(["id", "name", "type"]), + ), + _with_post( + client_op( + "update_hubspot_property", + "update_property", + description=( + "Update an existing property's definition (label, " + "description, options). Returns only {id, name, type}." + ), + parallelizable=False, + tags=("hubspot_properties",), + input_schema={ + "object_type": _s("Object type.", "contacts"), + "property_name": _s("Property internal name.", "favorite_color"), + "definition": _obj( + "Fields to update.", {"label": "Color preference"} + ), + }, + output_schema=_only("Only {id, name, type}."), + arg_map=lambda d: { + "object_type": d["object_type"], + "property_name": d["property_name"], + "definition": d["definition"], + }, + ), + _pick(["id", "name", "type"]), + ), + client_op( + "delete_hubspot_property", + "delete_property", + description=( + "Delete a custom property. Built-in HubSpot properties cannot " + "be deleted." + ), + destructive=True, + parallelizable=False, + tags=("hubspot_properties",), + input_schema={ + "object_type": _s("Object type.", "contacts"), + "property_name": _s("Property internal name.", "favorite_color"), + }, + arg_map=lambda d: { + "object_type": d["object_type"], + "property_name": d["property_name"], + }, + ), + _with_post( + client_op( + "list_hubspot_property_groups", + "list_property_groups", + description=( + "List property groups for an object type (the visual " + "sections grouping properties in HubSpot UI)." + ), + tags=("hubspot_properties",), + input_schema={ + "object_type": _s("Object type.", "contacts"), + }, + arg_map=lambda d: {"object_type": d["object_type"]}, + ), + _lean_listing, + ), + # ── Associations ───────────────────────────────────────────────── + _with_post( + client_op( + "create_hubspot_association", + "create_association", + description=( + "Link two objects (e.g. attach a contact to a deal). " + "Leaves association_type_id empty for the default " + "association between the pair. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_associations", "hubspot"), + input_schema={ + "from_object_type": _s("Source object type.", "deals"), + "from_object_id": _s("Source object ID.", "123"), + "to_object_type": _s("Target object type.", "contacts"), + "to_object_id": _s("Target object ID.", "456"), + "association_type_id": _i( + "Optional: specific association type ID (use " + "list_hubspot_association_types).", + 0, + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "from_object_type": d["from_object_type"], + "from_object_id": d["from_object_id"], + "to_object_type": d["to_object_type"], + "to_object_id": d["to_object_id"], + "association_type_id": d.get("association_type_id") or None, + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "list_hubspot_associations", + "list_associations", + description=( + "List all objects of a given type associated with a source " + "object." + ), + tags=("hubspot_associations",), + input_schema={ + "from_object_type": _s("Source object type.", "deals"), + "from_object_id": _s("Source object ID.", "123"), + "to_object_type": _s("Target object type to look up.", "contacts"), + "limit": _limit(100, "Max results (1-500)."), + "after": _after(), + }, + arg_map=lambda d: { + "from_object_type": d["from_object_type"], + "from_object_id": d["from_object_id"], + "to_object_type": d["to_object_type"], + "limit": d.get("limit", 100), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "delete_hubspot_association", + "delete_association", + description="Remove an association between two objects.", + destructive=True, + parallelizable=False, + tags=("hubspot_associations",), + input_schema={ + "from_object_type": _s("Source type.", "deals"), + "from_object_id": _s("Source ID.", "123"), + "to_object_type": _s("Target type.", "contacts"), + "to_object_id": _s("Target ID.", "456"), + }, + arg_map=lambda d: { + "from_object_type": d["from_object_type"], + "from_object_id": d["from_object_id"], + "to_object_type": d["to_object_type"], + "to_object_id": d["to_object_id"], + }, + ), + _with_post( + client_op( + "list_hubspot_association_types", + "list_association_types", + description=( + "List the available association types between two object " + "types (used when you need a specific labeled association)." + ), + tags=("hubspot_associations",), + input_schema={ + "from_object_type": _s("Source type.", "deals"), + "to_object_type": _s("Target type.", "contacts"), + }, + arg_map=lambda d: { + "from_object_type": d["from_object_type"], + "to_object_type": d["to_object_type"], + }, + ), + _lean_listing, + ), + # ── Forms ──────────────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_forms", + "list_forms", + description="List HubSpot forms (marketing v3).", + tags=("hubspot_forms",), + input_schema={ + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_form", + "get_form", + description="Get a HubSpot form definition by ID.", + tags=("hubspot_forms",), + input_schema={ + "form_id": _s("Form GUID.", "abc12345-6789-0abc-def0-123456789abc"), + }, + arg_map=lambda d: {"form_id": d["form_id"]}, + ), + _with_post( + client_op( + "submit_hubspot_form", + "submit_form", + description=( + "Programmatically submit a HubSpot form. 'fields' is a " + "list of {name, value} dicts. Returns only {id}." + ), + parallelizable=False, + tags=("hubspot_forms",), + input_schema={ + "portal_id": _s("Portal/hub ID.", "12345678"), + "form_guid": _s( + "Form GUID.", "abc12345-6789-0abc-def0-123456789abc" + ), + "fields": _arr( + "Form fields to submit.", + [ + {"name": "email", "value": "jane@example.com"}, + {"name": "firstname", "value": "Jane"}, + ], + ), + "context": _obj( + "Optional context (hutk, pageUrl, pageName, ipAddress).", + {"pageName": "Demo Request"}, + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "portal_id": d["portal_id"], + "form_guid": d["form_guid"], + "fields": d["fields"], + "context": d.get("context") or None, + }, + ), + _pick(["id"]), + ), + _with_post( + client_op( + "list_hubspot_form_submissions", + "list_form_submissions", + description="List submissions for a HubSpot form.", + tags=("hubspot_forms",), + input_schema={ + "form_guid": _s( + "Form GUID.", "abc12345-6789-0abc-def0-123456789abc" + ), + "limit": _limit(30, "Max results (1-50)."), + "after": _after(), + }, + arg_map=lambda d: { + "form_guid": d["form_guid"], + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + # ── Marketing email ────────────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_marketing_emails", + "list_marketing_emails", + description="List marketing email campaigns.", + tags=("hubspot_marketing_email",), + input_schema={ + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_marketing_email", + "get_marketing_email", + description="Get a marketing email campaign by ID.", + tags=("hubspot_marketing_email",), + input_schema={"email_id": _s("Marketing email ID.", "123456789")}, + arg_map=lambda d: {"email_id": d["email_id"]}, + ), + _with_post( + client_op( + "send_hubspot_single_send", + "send_single_email", + description=( + "Send a one-off transactional email based on a pre-built " + "marketing email template. Returns only {id}." + ), + destructive=True, # legacy irreversible — outward-facing send + parallelizable=False, + tags=("hubspot_marketing_email", "hubspot"), + input_schema={ + "email_id": _s("Marketing email template ID.", "123456789"), + "to_email": _s("Recipient email.", "jane@example.com"), + "custom_properties": _obj( + "Optional template variables.", {"first_name": "Jane"} + ), + "contact_properties": _obj( + "Optional contact-property overrides.", {} + ), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "email_id": d["email_id"], + "to_email": d["to_email"], + "custom_properties": d.get("custom_properties") or None, + "contact_properties": d.get("contact_properties") or None, + }, + ), + _pick(["id"]), + ), + client_op( + "get_hubspot_marketing_email_statistics", + "get_marketing_email_statistics", + description="Get aggregated send/open/click statistics for a marketing email.", + tags=("hubspot_marketing_email",), + input_schema={"email_id": _s("Marketing email ID.", "123456789")}, + arg_map=lambda d: {"email_id": d["email_id"]}, + ), + # ── Files ──────────────────────────────────────────────────────── + _with_post( + client_op( + "upload_hubspot_file", + "upload_file", + description=( + "Upload a local file to the HubSpot file manager. 'access' " + "controls visibility: PUBLIC_INDEXABLE / " + "PUBLIC_NOT_INDEXABLE / HIDDEN / PRIVATE. Returns only " + "{id, url}." + ), + parallelizable=False, + tags=("hubspot_files",), + input_schema={ + "file_path": _s("Local path to the file.", "/tmp/contract.pdf"), + "folder_path": _s("HubSpot folder path.", "/"), + "access": _s( + "PUBLIC_INDEXABLE | PUBLIC_NOT_INDEXABLE | HIDDEN | " + "PRIVATE.", + "PRIVATE", + ), + "overwrite": _b("Overwrite existing file with the same name."), + }, + output_schema=_only("Only {id, url}."), + arg_map=lambda d: { + "file_path": d["file_path"], + "folder_path": d.get("folder_path", "/"), + "access": d.get("access", "PRIVATE"), + "overwrite": d.get("overwrite", False), + }, + ), + _pick(["id", "url"]), + ), + client_op( + "get_hubspot_file", + "get_file", + description="Get a file's metadata (including URL).", + tags=("hubspot_files",), + input_schema={"file_id": _s("File ID.", "123456789")}, + arg_map=lambda d: {"file_id": d["file_id"]}, + ), + client_op( + "delete_hubspot_file", + "delete_file", + description="Delete a file from the HubSpot file manager.", + destructive=True, + parallelizable=False, + tags=("hubspot_files",), + input_schema={"file_id": _s("File ID.", "123456789")}, + arg_map=lambda d: {"file_id": d["file_id"]}, + ), + _with_post( + client_op( + "list_hubspot_folders", + "list_folders", + description="List folders in the HubSpot file manager.", + tags=("hubspot_files",), + input_schema={ + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + # ── Conversations (Inbox) ──────────────────────────────────────── + _with_post( + client_op( + "list_hubspot_conversations", + "list_conversations", + description="List conversation threads in the HubSpot Inbox.", + tags=("hubspot_conversations",), + input_schema={ + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + client_op( + "get_hubspot_conversation", + "get_conversation", + description="Get a conversation thread by ID.", + tags=("hubspot_conversations",), + input_schema={"thread_id": _s("Thread ID.", "123456789")}, + arg_map=lambda d: {"thread_id": d["thread_id"]}, + ), + _with_post( + client_op( + "list_hubspot_conversation_messages", + "list_conversation_messages", + description="List messages in a conversation thread.", + tags=("hubspot_conversations",), + input_schema={ + "thread_id": _s("Thread ID.", "123456789"), + "limit": _limit(), + "after": _after(), + }, + arg_map=lambda d: { + "thread_id": d["thread_id"], + "limit": d.get("limit", 30), + "after": d.get("after") or None, + }, + ), + _lean_listing, + ), + _with_post( + client_op( + "send_hubspot_conversation_message", + "send_conversation_message", + description=( + "Send a message into a conversation thread. Requires the " + "channel + channel-account IDs from the thread metadata. " + "Returns only {id}." + ), + destructive=True, # legacy irreversible — outward-facing send + parallelizable=False, + tags=("hubspot_conversations",), + input_schema={ + "thread_id": _s("Thread ID.", "123456789"), + "text": _s("Message body.", "Thanks for reaching out!"), + "channel_id": _s("Channel ID (from thread metadata).", "1000"), + "channel_account_id": _s( + "Channel account ID (from thread metadata).", "12345" + ), + "recipients": _arr( + "Recipient list [{actorId, " + "deliveryIdentifier:{type,value}}].", + [ + { + "actorId": "V-123", + "deliveryIdentifier": { + "type": "HS_EMAIL_ADDRESS", + "value": "jane@example.com", + }, + } + ], + ), + "sender_actor_id": _s("Optional sender actor ID.", ""), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "thread_id": d["thread_id"], + "text": d["text"], + "channel_id": d["channel_id"], + "channel_account_id": d["channel_account_id"], + "recipients": d["recipients"], + "sender_actor_id": d.get("sender_actor_id") or None, + }, + ), + _pick(["id"]), + ), + # ── Webhooks (App-level — requires HubSpot App ID) ─────────────── + _with_post( + client_op( + "list_hubspot_webhook_subscriptions", + "list_webhook_subscriptions", + description=( + "List webhook subscriptions for a HubSpot App. Requires " + "the App ID from the developer console." + ), + tags=("hubspot_webhooks",), + input_schema={ + "app_id": _s("HubSpot App ID (developer console).", "1234567"), + }, + arg_map=lambda d: {"app_id": d["app_id"]}, + ), + _lean_listing, + ), + _with_post( + client_op( + "create_hubspot_webhook_subscription", + "create_webhook_subscription", + description=( + "Subscribe a HubSpot App to an event type (e.g. " + "contact.creation, contact.propertyChange). Returns only " + "{id}." + ), + parallelizable=False, + tags=("hubspot_webhooks",), + input_schema={ + "app_id": _s("HubSpot App ID.", "1234567"), + "event_type": _s( + "Event type to subscribe to.", "contact.creation" + ), + "property_name": _s( + "Property name (only for *.propertyChange event types).", + "", + ), + "active": _b("Whether the subscription is active.", True), + }, + output_schema=_only("Only {id}."), + arg_map=lambda d: { + "app_id": d["app_id"], + "event_type": d["event_type"], + "property_name": d.get("property_name") or None, + "active": d.get("active", True), + }, + ), + _pick(["id"]), + ), + client_op( + "delete_hubspot_webhook_subscription", + "delete_webhook_subscription", + description="Delete a webhook subscription.", + destructive=True, + parallelizable=False, + tags=("hubspot_webhooks",), + input_schema={ + "app_id": _s("HubSpot App ID.", "1234567"), + "subscription_id": _s("Subscription ID.", "abc123"), + }, + arg_map=lambda d: { + "app_id": d["app_id"], + "subscription_id": d["subscription_id"], + }, + ), + ] diff --git a/craftos_integrations/providers/hubspot/provider.py b/craftos_integrations/providers/hubspot/provider.py new file mode 100644 index 00000000..38e066a7 --- /dev/null +++ b/craftos_integrations/providers/hubspot/provider.py @@ -0,0 +1,270 @@ +"""HubSpot provider — first non-Google provider with rotating tokens. + +Follows the Slack non-Google binding pattern (reuse the battle-tested +legacy ``HubSpotClient`` API surface, override only its credential +plumbing) plus the Google refresh pattern: HubSpot OAuth access tokens +expire (~30 min), so the binding reimplements the legacy client's +``_refresh_access_token`` but persists the rotated credential through +the core via ``self._persist(...)`` — never to ``spec.cred_file``, +which is single-account and would cross-wire secondaries. + +The legacy ``_get_valid_access_token`` (lazy expiry check on every +request) is inherited unchanged: it calls ``self._load()`` and +``self._refresh_access_token()``, both of which the binding overrides, +so per-request refresh flows through the account plumbing automatically. +Private App tokens (``auth_kind == "token"``) never expire and skip the +refresh path entirely, exactly as in the legacy client. + +One account = one HubSpot **hub** (portal); identity is the hub id from +the credential (stringified, lowercased). OAuth parameters are +referenced from the legacy handler's ``OAuthFlow`` so the provider spec can +never drift from it. + +multi-account plan decision — dropped legacy quirk: the old handler's ``logout`` +also called ``manager.stop_platform(...)``, so the LAST logout stopped +the whole integration platform. That special case is deliberately NOT +ported; the last disconnect is now a plain disconnect, uniform across +providers (the core handles disconnect centrally). +""" + +from __future__ import annotations + +import copy +import time +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...config import ConfigStore +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.hubspot import ( + HUBSPOT_API, + HUBSPOT_SCOPES, + HubSpotClient, + HubSpotCredential, + HubSpotHandler, +) +from ...logger import get_logger +from .._shared import read_guidance +from .operations import build_operations + +logger = get_logger(__name__) + +_CRED_FIELDS = {f.name for f in fields(HubSpotCredential)} + + +class HubSpotClientBinding: + """Overrides HubSpotClient's disk plumbing: credential is injected per + account, token refresh persists through the core. MRO puts this before + the legacy client: + + class BoundHubSpotClient(HubSpotClientBinding, HubSpotClient): pass + """ + + _cred: Optional[HubSpotCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = HubSpotCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> HubSpotCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + def _refresh_access_token(self) -> Optional[str]: + """Swap the refresh_token for a fresh access_token + expiry. + + Legacy logic verbatim (same endpoint, same params, same rotate-or- + keep refresh_token handling, same 60s early-refresh margin) except + for persistence: the mutated credential goes through + ``self._persist(...)`` so the core routes it to the right account + entry, instead of ``save_credential(spec.cred_file, ...)``. + + Returns the new access_token, or ``None`` on failure (the inherited + ``_get_valid_access_token`` then falls back to the stale token, + which produces a clean 401 from HubSpot rather than a crash). + """ + cred = self._load() + if cred.auth_kind != "oauth" or not cred.refresh_token: + return None + + client_id = ConfigStore.get_oauth("HUBSPOT_SHARED_CLIENT_ID") + client_secret = ConfigStore.get_oauth("HUBSPOT_SHARED_CLIENT_SECRET") + if not client_id or not client_secret: + logger.warning( + "[HUBSPOT] Cannot refresh token: HUBSPOT_SHARED_CLIENT_ID/SECRET " + "not configured. Reconnect the account to continue." + ) + return None + + result = http_request( + "POST", + f"{HUBSPOT_API}/oauth/v1/token", + data={ + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": cred.refresh_token, + }, + expected=(200,), + ) + if "error" in result: + logger.warning( + f"[HUBSPOT] Token refresh failed: {result.get('error')}. " + "Reconnect the account to continue." + ) + return None + + data = result.get("result") or {} + new_token = data.get("access_token") + if not new_token: + logger.warning("[HUBSPOT] Token refresh returned no access_token.") + return None + + cred.access_token = new_token + # HubSpot sometimes rotates the refresh_token, sometimes doesn't — + # keep the old one if a new one isn't returned. + 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 + self._persist(asdict(cred)) + logger.info("[HUBSPOT] Access token refreshed.") + return new_token + + +class BoundHubSpotClient(HubSpotClientBinding, HubSpotClient): + """HubSpotClient with per-account credential binding (see HubSpotClientBinding).""" + + +class HubSpotProvider: + id = "hubspot" + display_name = "HubSpot" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundHubSpotClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Hub (portal) id as a lowercase string. None for credentials saved + before the hub id was captured (pre-multi-account Private App token logins).""" + hub_id = credential.get("hub_id") + if hub_id is None or isinstance(hub_id, (dict, list)): + return None + text = str(hub_id).strip() + return text.lower() if text else None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=HubSpotHandler.oauth.auth_url, + token_url=HubSpotHandler.oauth.token_url, + scopes=tuple(s for s in HUBSPOT_SCOPES.split() if s), + # HubSpot's authorize page always shows its own account/hub + # chooser (pick which portal to grant access to) — no extra + # params needed to add a *different* hub. + has_chooser=True, + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Out-of-band refresh (listener wake-up etc.); operations normally + refresh inline via the binding's ``_get_valid_access_token``. + Returns None for non-expiring Private App tokens.""" + holder: Dict[str, Any] = {} + client = self.build_client(credential, holder.update) + token = client._refresh_access_token() + return holder or None if token else None + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the legacy handler's OAuthFlow — the + machinery behind the legacy ``invite()`` subcommand, including the + access-token introspection call that captures hub_id/hub_domain/ + user email (HubSpot has no OAuthFlow userinfo endpoint). The + Private-App-token ``login()`` path is host UI territory and is + not ported here. + + A *copy* of the shared flow gets the provider spec's + ``extra_authorize_params`` applied (empty — HubSpot's authorize + page always shows its own hub chooser); the shared handler + instance is never mutated. + + Returns (identity, credential, message). Identity is computed by + ``identity_of`` (hub id). One deliberate deviation from the + legacy ``invite()``: a failed introspection no longer fails the + whole login — the token itself is valid, so the credential is + returned with identity None and the core stores it under + LEGACY_IDENTITY, upgrading in place on the next re-auth. + """ + oauth = copy.copy(HubSpotHandler.oauth) + oauth.extra_auth_params = dict(self.oauth_spec().extra_authorize_params) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"HubSpot OAuth failed: {result['error']}" + + access_token = result.get("access_token", "") + expires_in = result.get("expires_in", 0) or 0 + + # Hub metadata from the introspection endpoint (same call as the + # legacy invite()). + info = http_request( + "GET", + f"{HUBSPOT_API}/oauth/v1/access-tokens/{access_token}", + expected=(200,), + ) + if "error" in info: + logger.warning( + f"[HUBSPOT] token introspection failed: {info['error']} — " + "storing the credential without a hub id." + ) + meta: Dict[str, Any] = {} + else: + meta = info.get("result") or {} + + credential = asdict( + HubSpotCredential( + access_token=access_token, + refresh_token=result.get("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", + ) + ) + identity = self.identity_of(credential) + label = meta.get("hub_domain") or meta.get("hub_id") or "HubSpot" + message = f"HubSpot connected via OAuth: {label}" + if not identity: + message += ( + " (no hub id captured — stored as the legacy account until " + "the next re-auth)" + ) + return identity, credential, message + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ): + return None # HubSpot is request-response only (no event listening) diff --git a/craftos_integrations/providers/linkedin/GUIDANCE.md b/craftos_integrations/providers/linkedin/GUIDANCE.md new file mode 100644 index 00000000..62a5d54d --- /dev/null +++ b/craftos_integrations/providers/linkedin/GUIDANCE.md @@ -0,0 +1,46 @@ +# LinkedIn + +Official LinkedIn API integration. Profile, posts, search, organisation +analytics, and (with elevated perms) DMs. + +## Multi-account +- One connected account = one LinkedIn member profile. Every LinkedIn + action accepts an optional `account` (email, nickname, or a unique + fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my consulting LinkedIn", + "the company profile"), pass it as `account` — never silently default + to primary. +- Post URNs, comment URNs, invitation URNs, and the auto-constructed + `urn:li:person:...` author are **account-scoped**: a post created with + `account="work"` must be liked/commented/deleted with + `account="work"` on every follow-up action. +- For destructive actions (create/delete post, comment, like, DM, + connection request) with multiple accounts connected and no account + named: ask the user which account before acting. +- **Adding another account:** LinkedIn's OAuth page has no account + chooser — it reuses your current browser session. To add a different + LinkedIn account, log out of linkedin.com in the browser first, then + click Add account. + +## 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) — per connected account. NEVER + ask the user for it; the integration auto-constructs + `urn:li:person:` for self-references on the resolved + account. +- **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. +- **URN identity zoo:** `urn:li:person:...` for users, + `urn:li:organization:...` for companies, `urn:li:share:...` for posts. + They're not interchangeable — read each action's schema for which it + expects. diff --git a/craftos_integrations/providers/linkedin/__init__.py b/craftos_integrations/providers/linkedin/__init__.py new file mode 100644 index 00000000..8063539d --- /dev/null +++ b/craftos_integrations/providers/linkedin/__init__.py @@ -0,0 +1,3 @@ +from .provider import LinkedInProvider + +__all__ = ["LinkedInProvider"] diff --git a/craftos_integrations/providers/linkedin/operations.py b/craftos_integrations/providers/linkedin/operations.py new file mode 100644 index 00000000..298787c1 --- /dev/null +++ b/craftos_integrations/providers/linkedin/operations.py @@ -0,0 +1,680 @@ +"""LinkedIn operations — ported from the legacy linkedin_actions.py. + +Complete port of app/data/action/integrations/linkedin/linkedin_actions.py +— all 31 actions, same names/descriptions/schemas/arg mapping. No +operation declares an ``account`` input (conformance-enforced; the host +injects it). + +Porting notes: +- Legacy ``irreversible=True`` (send_linkedin_message, + send_linkedin_connection_request) → ``destructive=True``. Per the same + rule, every outward-facing social send (create/reshare post, like, + comment, follow, respond to invitation) and every permanent delete + (post, comment) is ``destructive=True`` + ``parallelizable=False``. + Reversible mutations (unlike, unfollow) stay non-destructive but are + serialized (``parallelizable=False``). +- The author/actor URN (``urn:li:person:``) is derived from the + *bound account's* credential — legacy ``_person_urn`` verbatim, now per + account via the injected credential instead of the shared linkedin.json. +- Envelope handling matches legacy ``run_client_sync``/``with_client`` + defaults exactly: the client's ``{"ok": ..., "result": ...}`` transport + envelope is collapsed by ``shape_result`` with no ``unwrap_envelope`` + opt-in, so restricted-API responses carrying a ``"note"`` field surface + the same way they always did. +- The lean ugcPosts shaping of get_my_linkedin_posts / + get_linkedin_organization_posts is reproduced verbatim (the port has no + double transport envelope, so the legacy's inner-envelope collapse + step is unnecessary here). +""" + +from __future__ import annotations + +import asyncio +from dataclasses import replace +from typing import Any, Callable, Dict, List, Optional, Tuple + +from ...contracts import Operation +from .._shared import client_op, shape_result + +_STATUS = {"status": {"type": "string", "example": "success"}} + + +def _person_urn(client: Any) -> str: + """LinkedIn URN of the bound account — author/actor for posts, likes, + comments, messages, follows. Legacy helper, now per-account.""" + cred = client._load() + return ( + f"urn:li:person:{cred.linkedin_id}" + if cred.linkedin_id + else f"urn:li:person:{cred.user_id}" + ) + + +def _urn_op( + name: str, + method: str, + *, + description: str, + input_schema: Dict[str, Any], + args: Callable[[str, Dict[str, Any]], Dict[str, Any]], + destructive: bool = False, + parallelizable: bool = True, + output_schema: Optional[Dict[str, Any]] = None, + tags: Tuple[str, ...] = ("linkedin",), +) -> Operation: + """Like ``client_op`` but for methods needing the bound account's + person URN: ``args(person_urn, input_data)`` builds the kwargs.""" + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + try: + kwargs = args(_person_urn(client), input_data) + raw = await asyncio.to_thread(getattr(client, method), **kwargs) + return shape_result(raw) + except Exception as e: + return {"status": "error", "message": str(e)} + + return Operation( + name=name, + description=description, + input_schema=input_schema, + output_schema=output_schema or dict(_STATUS), + fn=fn, + destructive=destructive, + parallelizable=parallelizable, + tags=tags, + ) + + +# ──────────────────────────────────────────────────────────────────────── +# Post-processing (legacy lean ugcPosts shaping, verbatim) +# ──────────────────────────────────────────────────────────────────────── + + +def _with_post( + base: Operation, + post: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], +) -> Operation: + """Wrap an operation's fn with a (result, input_data) post-processor.""" + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + return post(await inner(client, input_data), input_data) + + return replace(base, fn=fn) + + +def _lean_ugc_posts(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + """Legacy lean shaping: {id, text, created, lifecycleState, media} per + post unless include_metadata=true asked for the full raw ugcPosts.""" + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict) or "error" in body: + return res + + posts = [] + for el in body.get("elements", []) or []: + if not isinstance(el, dict): + continue + share = (el.get("specificContent") or {}).get( + "com.linkedin.ugc.ShareContent" + ) or {} + p = { + "id": el.get("id"), + "text": (share.get("shareCommentary") or {}).get("text"), + "created": (el.get("created") or {}).get("time"), + "lifecycleState": el.get("lifecycleState"), + } + media = share.get("media") + if media: + p["media"] = [ + {k: v for k, v in m.items() if k in ("media", "originalUrl", "status")} + for m in media + if isinstance(m, dict) + ] + posts.append(p) + lean: Dict[str, Any] = {"posts": posts} + if isinstance(body.get("paging"), dict): + pg = body["paging"] + lean["paging"] = { + "start": pg.get("start"), + "count": pg.get("count"), + "total": pg.get("total"), + } + return {**res, "result": lean} + + +# ──────────────────────────────────────────────────────────────────────── +# Operations +# ──────────────────────────────────────────────────────────────────────── + + +def build_operations() -> List[Operation]: + return [ + # ── Profile ────────────────────────────────────────────────────── + client_op( + "get_linkedin_profile", + "get_user_profile", + description="Get the authenticated user's LinkedIn profile.", + tags=("linkedin",), + input_schema={}, + ), + # ── Posts (create / delete / get / list / org posts / reshare) ─── + _urn_op( + "create_linkedin_post", + "create_text_post", + description="Create a text post on LinkedIn.", + destructive=True, # outward-facing send — visible to the network + parallelizable=False, + input_schema={ + "text": { + "type": "string", + "description": "Post text (max 3000 chars).", + "example": "Excited to share...", + }, + "visibility": { + "type": "string", + "description": "Visibility: PUBLIC, CONNECTIONS, or LOGGED_IN.", + "example": "PUBLIC", + }, + }, + args=lambda urn, d: { + "author_urn": urn, + "text": d["text"], + "visibility": d.get("visibility", "PUBLIC"), + }, + ), + client_op( + "delete_linkedin_post", + "delete_post", + description="Delete a LinkedIn post.", + destructive=True, # permanent delete + parallelizable=False, + tags=("linkedin",), + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + ), + client_op( + "get_linkedin_post", + "get_post", + description="Get a post.", + tags=("linkedin",), + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + ), + _with_post( + _urn_op( + "get_my_linkedin_posts", + "get_posts_by_author", + description=( + "Get my posts. Lean posts ({id, text, created, " + "lifecycleState, media}) by default; include_metadata=true " + "returns the full raw ugcPosts." + ), + input_schema={ + "count": { + "type": "integer", + "description": "Count.", + "example": 50, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean posts. True: full raw ugcPosts.", + "example": False, + }, + }, + args=lambda urn, d: { + "author_urn": urn, + "count": d.get("count", 50), + }, + ), + _lean_ugc_posts, + ), + _with_post( + client_op( + "get_linkedin_organization_posts", + "get_posts_by_author", + description=( + "Get organization posts. Lean posts ({id, text, created, " + "lifecycleState, media}) by default; include_metadata=true " + "returns the full raw ugcPosts." + ), + tags=("linkedin",), + input_schema={ + "organization_urn": { + "type": "string", + "description": "Org URN.", + "example": "urn:li:organization:123", + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean posts. True: full raw ugcPosts.", + "example": False, + }, + }, + arg_map=lambda d: {"author_urn": d["organization_urn"]}, + ), + _lean_ugc_posts, + ), + _urn_op( + "reshare_linkedin_post", + "reshare_post", + description="Reshare a post.", + destructive=True, # outward-facing send + parallelizable=False, + input_schema={ + "original_post_urn": { + "type": "string", + "description": "Original Post URN.", + "example": "urn:li:share:123", + }, + "commentary": { + "type": "string", + "description": "Commentary.", + "example": "Interesting!", + }, + }, + args=lambda urn, d: { + "author_urn": urn, + "original_post_urn": d["original_post_urn"], + "commentary": d.get("commentary", ""), + }, + ), + # ── Reactions / Comments ───────────────────────────────────────── + _urn_op( + "like_linkedin_post", + "like_post", + description="Like a post.", + destructive=True, # outward-facing send — visible to the author + parallelizable=False, + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + args=lambda urn, d: {"actor_urn": urn, "post_urn": d["post_urn"]}, + ), + _urn_op( + "unlike_linkedin_post", + "unlike_post", + description="Unlike a post.", + parallelizable=False, # reversible mutation — serialized, not flagged + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + args=lambda urn, d: {"actor_urn": urn, "post_urn": d["post_urn"]}, + ), + client_op( + "get_linkedin_post_likes", + "get_post_reactions", + description="Get post likes.", + tags=("linkedin",), + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + ), + _urn_op( + "comment_on_linkedin_post", + "comment_on_post", + description="Comment on a post.", + destructive=True, # outward-facing send + parallelizable=False, + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + }, + "text": { + "type": "string", + "description": "Comment text.", + "example": "Great post!", + }, + }, + args=lambda urn, d: { + "actor_urn": urn, + "post_urn": d["post_urn"], + "text": d["text"], + }, + ), + client_op( + "get_linkedin_post_comments", + "get_post_comments", + description="Get post comments.", + tags=("linkedin",), + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + ), + _urn_op( + "delete_linkedin_comment", + "delete_comment", + description="Delete a comment.", + destructive=True, # permanent delete + parallelizable=False, + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + }, + "comment_urn": { + "type": "string", + "description": "Comment URN.", + "example": "urn:li:comment:123", + }, + }, + args=lambda urn, d: { + "actor_urn": urn, + "post_urn": d["post_urn"], + "comment_urn": d["comment_urn"], + }, + ), + # ── Connections / Invitations / Messages ───────────────────────── + client_op( + "get_linkedin_connections", + "get_connections", + description="Get the authenticated user's LinkedIn connections.", + tags=("linkedin",), + input_schema={ + "count": { + "type": "integer", + "description": "Number of connections to return.", + "example": 50, + }, + }, + arg_map=lambda d: {"count": d.get("count", 50)}, + ), + _urn_op( + "send_linkedin_message", + "send_message_to_recipients", + description="Send a message to LinkedIn users.", + destructive=True, # legacy irreversible — outward-facing DM + parallelizable=False, + input_schema={ + "recipient_urns": { + "type": "array", + "description": "List of recipient URNs (urn:li:person:xxx).", + "example": [], + }, + "subject": { + "type": "string", + "description": "Message subject.", + "example": "Hello", + }, + "body": { + "type": "string", + "description": "Message body.", + "example": "Hi, I wanted to connect...", + }, + }, + args=lambda urn, d: { + "sender_urn": urn, + "recipient_urns": d["recipient_urns"], + "subject": d["subject"], + "body": d["body"], + }, + ), + client_op( + "send_linkedin_connection_request", + "send_connection_request", + description="Send connection request.", + destructive=True, # legacy irreversible — outward-facing invite + parallelizable=False, + tags=("linkedin",), + input_schema={ + "invitee_profile_urn": { + "type": "string", + "description": "Profile URN.", + "example": "urn:li:person:123", + }, + "message": { + "type": "string", + "description": "Message.", + "example": "Hi", + }, + }, + arg_map=lambda d: { + "invitee_profile_urn": d["invitee_profile_urn"], + "message": d.get("message"), + }, + ), + client_op( + "get_linkedin_sent_invitations", + "get_sent_invitations", + description="Get sent invitations.", + tags=("linkedin",), + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50} + }, + arg_map=lambda d: {"count": d.get("count", 50)}, + ), + client_op( + "get_linkedin_received_invitations", + "get_received_invitations", + description="Get received invitations.", + tags=("linkedin",), + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 50} + }, + arg_map=lambda d: {"count": d.get("count", 50)}, + ), + client_op( + "respond_to_linkedin_invitation", + "respond_to_invitation", + description="Respond to invitation.", + destructive=True, # accept/ignore cannot be taken back + parallelizable=False, + tags=("linkedin",), + input_schema={ + "invitation_urn": { + "type": "string", + "description": "Invitation URN.", + "example": "urn:li:invitation:123", + }, + "action": { + "type": "string", + "description": "accept/ignore.", + "example": "accept", + }, + }, + arg_map=lambda d: { + "invitation_urn": d["invitation_urn"], + "action": d["action"], + }, + ), + client_op( + "get_linkedin_conversations", + "get_conversations", + description="Get conversations.", + tags=("linkedin",), + input_schema={ + "count": {"type": "integer", "description": "Count.", "example": 20} + }, + arg_map=lambda d: {"count": d.get("count", 20)}, + ), + # ── Search / Lookups ───────────────────────────────────────────── + client_op( + "search_linkedin_jobs", + "search_jobs", + description="Search for job postings on LinkedIn.", + tags=("linkedin",), + input_schema={ + "keywords": { + "type": "string", + "description": "Job search keywords.", + "example": "software engineer", + }, + "location": { + "type": "string", + "description": "Optional location filter.", + "example": "", + }, + "count": { + "type": "integer", + "description": "Number of results.", + "example": 25, + }, + }, + arg_map=lambda d: { + "keywords": d["keywords"], + "location": d.get("location"), + "count": d.get("count", 25), + }, + ), + client_op( + "get_linkedin_job_details", + "get_job_details", + description="Get job details.", + tags=("linkedin",), + input_schema={ + "job_id": {"type": "string", "description": "Job ID.", "example": "123"} + }, + ), + client_op( + "search_linkedin_companies", + "search_companies", + description="Search companies.", + tags=("linkedin",), + input_schema={ + "keywords": { + "type": "string", + "description": "Keywords.", + "example": "tech", + } + }, + ), + client_op( + "lookup_linkedin_company", + "get_company_by_vanity_name", + description="Lookup company by vanity name.", + tags=("linkedin",), + input_schema={ + "vanity_name": { + "type": "string", + "description": "Vanity name.", + "example": "microsoft", + } + }, + ), + client_op( + "get_linkedin_person", + "get_person", + description="Get person profile by ID.", + tags=("linkedin",), + input_schema={ + "person_id": { + "type": "string", + "description": "Person ID.", + "example": "123", + } + }, + ), + # ── Organizations / Analytics / Follow ─────────────────────────── + client_op( + "get_linkedin_organizations", + "get_my_organizations", + description="Get user's organizations.", + tags=("linkedin",), + input_schema={}, + ), + client_op( + "get_linkedin_organization_info", + "get_organization", + description="Get organization info.", + tags=("linkedin",), + input_schema={ + "organization_id": { + "type": "string", + "description": "Org ID.", + "example": "123", + } + }, + ), + client_op( + "get_linkedin_organization_analytics", + "get_organization_analytics", + description="Get organization analytics.", + tags=("linkedin",), + input_schema={ + "organization_urn": { + "type": "string", + "description": "Org URN.", + "example": "urn:li:organization:123", + } + }, + ), + client_op( + "get_linkedin_post_analytics", + "get_post_analytics", + description="Get post analytics.", + tags=("linkedin",), + input_schema={ + "post_urn": { + "type": "string", + "description": "Post URN.", + "example": "urn:li:share:123", + } + }, + arg_map=lambda d: {"share_urns": [d["post_urn"]]}, + ), + _urn_op( + "follow_linkedin_organization", + "follow_organization", + description="Follow organization.", + destructive=True, # outward-facing send — visible to the org + parallelizable=False, + input_schema={ + "organization_urn": { + "type": "string", + "description": "Org URN.", + "example": "urn:li:organization:123", + } + }, + args=lambda urn, d: { + "follower_urn": urn, + "organization_urn": d["organization_urn"], + }, + ), + _urn_op( + "unfollow_linkedin_organization", + "unfollow_organization", + description="Unfollow organization.", + parallelizable=False, # reversible mutation — serialized, not flagged + input_schema={ + "organization_urn": { + "type": "string", + "description": "Org URN.", + "example": "urn:li:organization:123", + } + }, + args=lambda urn, d: { + "follower_urn": urn, + "organization_urn": d["organization_urn"], + }, + ), + ] diff --git a/craftos_integrations/providers/linkedin/provider.py b/craftos_integrations/providers/linkedin/provider.py new file mode 100644 index 00000000..3405ef89 --- /dev/null +++ b/craftos_integrations/providers/linkedin/provider.py @@ -0,0 +1,234 @@ +"""LinkedIn provider — multi-account wrapper over the legacy ``LinkedInClient``. + +Follows the Slack binding pattern: the battle-tested API surface of the +legacy client is reused unchanged, and only its credential plumbing is +overridden — the credential is injected per account by ``build_client`` +and never read from ``spec.cred_file`` (single-account; would cross-wire +secondaries). + +Unlike Slack, LinkedIn tokens expire (~60 days), so the binding also +reimplements the legacy ``refresh_access_token`` with one change: the +refreshed credential is persisted through ``self._persist`` (routed by +the core to the right account entry), mirroring +``GoogleClientBinding.refresh_access_token`` — never written to disk +by the client itself. + +Identity is the account's email (lowercased) captured at OAuth time, +falling back to the OpenID ``sub`` claim when LinkedIn returns no email. +Old ``linkedin.json`` shapes carry neither key — ``identity_of`` returns +None and the core stores them under LEGACY_IDENTITY, upgrading in place +on the next re-auth. + +CRITICAL — no account chooser: LinkedIn's OAuth documents NO +prompt/account-chooser parameter (an undocumented ``prompt=login`` was +shipped by the abandoned PR and does nothing). ``has_chooser=False`` +declares that explicitly; the conformance suite then requires +GUIDANCE.md to document the add-account browser-session workaround. +""" + +from __future__ import annotations + +import copy +import time +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.linkedin import ( + LINKEDIN_OAUTH_BASE, + LinkedInClient, + LinkedInCredential, + LinkedInHandler, +) +from ...logger import get_logger +from .._shared import read_guidance +from .operations import build_operations + +logger = get_logger(__name__) + +_CRED_FIELDS = {f.name for f in fields(LinkedInCredential)} + + +class LinkedInClientBinding: + """Overrides LinkedInClient's disk plumbing: credential is injected + per account, refresh persists through the core. MRO puts this before + the legacy client: + + class BoundLinkedInClient(LinkedInClientBinding, LinkedInClient): pass + + Stored credentials carry identity keys (``email``/``sub``) that are not + LinkedInCredential dataclass fields — they are kept aside and merged + back into every persisted refresh so identity is never dropped. + """ + + _cred: Optional[LinkedInCredential] + _extra: Dict[str, Any] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = LinkedInCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._extra = {k: v for k, v in credential.items() if k not in _CRED_FIELDS} + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> LinkedInCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + def refresh_access_token(self) -> Optional[str]: + """Legacy LinkedIn refresh, persisted via the core (never to + spec.cred_file). Same request/expiry math as the legacy client: + LinkedIn access tokens last ~60 days (5184000s), renewed a day + early.""" + cred = self._load() + if not all([cred.client_id, cred.client_secret, cred.refresh_token]): + return None + result = http_request( + "POST", + f"{LINKEDIN_OAUTH_BASE}/accessToken", + data={ + "grant_type": "refresh_token", + "refresh_token": cred.refresh_token, + "client_id": cred.client_id, + "client_secret": cred.client_secret, + }, + expected=(200,), + ) + if "error" in result: + logger.warning(f"[LINKEDIN] token refresh failed: {result['error']}") + return None + data = result["result"] + cred.access_token = data["access_token"] + cred.token_expiry = time.time() + data.get("expires_in", 5184000) - 86400 + self._persist({**self._extra, **asdict(cred)}) + return cred.access_token + + +class BoundLinkedInClient(LinkedInClientBinding, LinkedInClient): + """LinkedInClient with per-account credential binding (see LinkedInClientBinding).""" + + +class LinkedInProvider: + id = "linkedin" + display_name = "LinkedIn" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundLinkedInClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Email (lowercased) captured at OAuth time; falls back to the + OpenID ``sub`` claim when LinkedIn returned no email. Legacy + ``linkedin.json`` shapes carry neither — None → LEGACY_IDENTITY, + upgraded in place on the next re-auth.""" + email = credential.get("email") + if isinstance(email, str) and email.strip(): + return email.strip().lower() + sub = credential.get("sub") + if isinstance(sub, str) and sub.strip(): + return sub.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=LinkedInHandler.oauth.auth_url, + token_url=LinkedInHandler.oauth.token_url, + scopes=tuple(LinkedInHandler.oauth.scopes.split()), + # LinkedIn's OAuth documents NO prompt/account-chooser param — + # do NOT add one (the abandoned PR's ``prompt=login`` is + # fictitious and does nothing). has_chooser=False makes the + # conformance suite require the GUIDANCE.md workaround: log + # out of linkedin.com in the browser, then Add account. + extra_authorize_params={}, + has_chooser=False, + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Out-of-band refresh (listener wake-up etc.); operations normally + refresh inline via the binding's ``_ensure_token``.""" + holder: Dict[str, Any] = {} + client = self.build_client(credential, holder.update) + token = client.refresh_access_token() + return (holder or None) if token else None + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the legacy handler's OAuthFlow (same + endpoints/scopes, localhost callback or host-injected oauth_runner). + A *copy* of the shared flow gets the provider spec's + ``extra_authorize_params`` applied — for LinkedIn that is ``{}`` + (no chooser param exists; the abandoned PR's ``prompt=login`` was + fictitious), but routing through ``oauth_spec()`` keeps the spec + the single source of truth and never mutates the shared handler + instance. + + Returns (identity, credential, message). Identity is computed by + ``identity_of`` from the credential (email, falling back to the + OpenID ``sub`` claim). When LinkedIn returns neither, the + credential is returned with identity None — the core stores it + under LEGACY_IDENTITY and upgrades it in place on the next + re-auth; a working token beats a failed login here (unlike + Google/Outlook, where a missing identity implies the userinfo + call itself failed). + """ + from ...config import ConfigStore + + oauth = copy.copy(LinkedInHandler.oauth) + oauth.extra_auth_params = dict(self.oauth_spec().extra_authorize_params) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"LinkedIn OAuth failed: {result['error']}" + info = result.get("userinfo") or {} + credential = asdict( + 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", ""), + ) + ) + # Identity keys ride alongside the dataclass fields — the client + # binding keeps them aside and re-merges them on every refresh. + if info.get("email"): + credential["email"] = info["email"] + if info.get("sub"): + credential["sub"] = info["sub"] + identity = self.identity_of(credential) + if identity: + name = info.get("name") or identity + return identity, credential, f"LinkedIn connected as {name} ({identity})" + return None, credential, ( + "LinkedIn connected, but no email or member id was returned — " + "stored as the legacy account until the next re-auth." + ) + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ): + return None # LinkedIn is request-response only (no event listening) diff --git a/craftos_integrations/providers/notion/GUIDANCE.md b/craftos_integrations/providers/notion/GUIDANCE.md new file mode 100644 index 00000000..e30276c2 --- /dev/null +++ b/craftos_integrations/providers/notion/GUIDANCE.md @@ -0,0 +1,48 @@ +# Notion + +Notes and databases — search, pages, databases, blocks, comments, users, +file uploads. + +## Multi-account +- One connected account = one Notion **workspace**. Each OAuth grant is + issued per workspace (Notion shows a native workspace picker on the + authorize page), and its token never expires. +- Every Notion action accepts an optional `account` (workspace name, + nickname, or a unique fragment). Omit it to use the primary workspace. +- When the user names a workspace in any form ("the company Notion", "my + personal workspace"), pass it as `account` — never silently default to + primary. +- Page/database/block IDs are **workspace-scoped**: an id returned by + `search_notion` under one account must be used with the same `account` + on every follow-up action (get/update/archive/append/etc.). +- With multiple workspaces connected and no workspace named, ask the user + which workspace before creating or archiving content. + +## Essentials +- **No event listening.** Notion is request-response only — it will never + push incoming events. Don't promise the user "you'll be notified when X + changes." +- **IDs are 36-char UUIDs with hyphens, not human-readable names.** Always + `search_notion` first to resolve a name like "Roadmap" to its page or + database ID. +- **`create_notion_page` requires `parent_type` AND matching `parent_id`.** + `parent_type` is either `"page_id"` or `"database_id"`. Mismatched type → + server-side failure. The parent must already exist. +- **Page content is Notion block JSON, not markdown.** + `append_notion_page_content` expects rich Notion block objects + (paragraph, heading_1, bulleted_list_item, ...) — passing markdown + silently fails. If the user gives markdown, convert it first. +- **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. + +## Behavior +- Archive/trash is reversible: `restore_notion_page` / + `restore_notion_database` undo the archive actions, and + `delete_notion_block` soft-deletes to trash (restorable in the Notion + UI). diff --git a/craftos_integrations/providers/notion/__init__.py b/craftos_integrations/providers/notion/__init__.py new file mode 100644 index 00000000..1a11d10e --- /dev/null +++ b/craftos_integrations/providers/notion/__init__.py @@ -0,0 +1,3 @@ +from .provider import NotionProvider + +__all__ = ["NotionProvider"] diff --git a/craftos_integrations/providers/notion/operations.py b/craftos_integrations/providers/notion/operations.py new file mode 100644 index 00000000..f1508b97 --- /dev/null +++ b/craftos_integrations/providers/notion/operations.py @@ -0,0 +1,1149 @@ +"""Notion operations — ported from the legacy notion_actions.py schemas. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). + +Complete port of app/data/action/integrations/notion/notion_actions.py. +The lean/include_metadata shaping (search results, page properties, +database schema/rows, block content) is reproduced verbatim so agents +see identical result dicts. + +Destructive flags: Notion archive/trash is reversible (restore_* / +un-trash), so ported operations stay destructive=False — except +delete_notion_block, whose name trips the conformance destructive-verb +gate; it is flagged so hosts confirm before trashing blocks on an +ambiguous multi-account request. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Callable, Dict, List, Optional + +from ...contracts import Operation +from .._shared import client_op + +STATUS_OUTPUT = {"status": {"type": "string", "example": "success"}} + + +# ------------------------------------------------------------------ +# Shared shaping helpers (verbatim from the legacy action bodies) +# ------------------------------------------------------------------ + + +def _plain(rt) -> str: + return "".join(x.get("plain_text", "") for x in (rt or []) if isinstance(x, dict)) + + +def _prop_value(p): + if not isinstance(p, dict): + return p + t = p.get("type") + v = p.get(t) + if t in ("title", "rich_text"): + return _plain(v) + if t in ("select", "status"): + return (v or {}).get("name") + if t == "multi_select": + return [o.get("name") for o in (v or []) if isinstance(o, dict)] + if t == "date": + return ( + {"start": v.get("start"), "end": v.get("end")} + if isinstance(v, dict) + else None + ) + if t == "people": + return [u.get("name") or u.get("id") for u in (v or []) if isinstance(u, dict)] + if t == "relation": + return [r.get("id") for r in (v or []) if isinstance(r, dict)] + if t in ("formula", "rollup"): + inner = (v or {}).get("type") + return (v or {}).get(inner) + if t in ("created_by", "last_edited_by"): + return (v or {}).get("name") or (v or {}).get("id") + if t == "files": + return [f.get("name") for f in (v or []) if isinstance(f, dict)] + return v + + +def _pick(res: Dict[str, Any], keys) -> Dict[str, Any]: + """Port of the legacy ``pick_result`` helper.""" + if res.get("status") == "success" and isinstance(res.get("result"), dict): + r = res["result"] + picked = {k: r.get(k) for k in keys if r.get(k) is not None} + if picked: + res = {**res, "result": picked} + return res + + +def _shaped( + base: Operation, + shaper: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], +) -> Operation: + """Wrap an operation's fn with a post-shaper (mirrors the legacy + action bodies that post-processed run_client_sync results).""" + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + return shaper(await inner(client, input_data), input_data) + + return replace(base, fn=fn) + + +def _picked(base: Operation, keys) -> Operation: + return _shaped(base, lambda res, _d: _pick(res, keys)) + + +# ------------------------------------------------------------------ +# Search (workspace-wide) +# ------------------------------------------------------------------ + + +def _search_notion_op() -> Operation: + base = client_op( + "search_notion", + "search", + description=( + "Search Notion workspace for pages and databases. Lean results " + "({id, object, title, url}) by default; include_metadata=true " + "returns the full raw objects (properties, timestamps, parents, ...)." + ), + tags=("notion",), + input_schema={ + "query": { + "type": "string", + "description": "Search query.", + "example": "meeting notes", + }, + "filter_type": { + "type": "string", + "description": "Optional: 'page' or 'database'.", + "example": "page", + }, + "include_metadata": { + "type": "boolean", + "description": ( + "False (default): lean {id, object, title, url} per result. " + "True: full raw." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "query": d["query"], + "filter_type": d.get("filter_type"), + }, + ) + + def shaper(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + items = res.get("result") + if not isinstance(items, list): + return res + lean = [] + for it in items: + if not isinstance(it, dict) or "error" in it: + lean.append(it) + continue + if isinstance(it.get("title"), list): # database object + title = _plain(it["title"]) + else: # page object — title lives in the title-type property + title = "" + for p in (it.get("properties") or {}).values(): + if isinstance(p, dict) and p.get("type") == "title": + title = _plain(p.get("title")) + break + lean.append( + { + "id": it.get("id"), + "object": it.get("object"), + "title": title, + "url": it.get("url"), + } + ) + return {**res, "result": lean} + + return _shaped(base, shaper) + + +# ------------------------------------------------------------------ +# Pages +# ------------------------------------------------------------------ + + +def _get_notion_page_op() -> Operation: + base = client_op( + "get_notion_page", + "get_page", + description=( + "Get a Notion page by ID (returns metadata + properties, not block " + "content). Lean {id, url, archived, properties: {name: plain value}} " + "by default; include_metadata=true returns the full raw page object." + ), + tags=("notion_pages", "notion"), + input_schema={ + "page_id": { + "type": "string", + "description": "Notion page ID.", + "example": "abc123", + }, + "include_metadata": { + "type": "boolean", + "description": ( + "False (default): lean page with plain property values. " + "True: full raw." + ), + "example": False, + }, + }, + arg_map=lambda d: {"page_id": d["page_id"]}, + ) + + def shaper(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + lean = { + "id": body.get("id"), + "url": body.get("url"), + "archived": body.get("archived"), + "properties": { + name: _prop_value(p) + for name, p in (body.get("properties") or {}).items() + }, + } + return {**res, "result": lean} + + return _shaped(base, shaper) + + +def _page_ops() -> List[Operation]: + return [ + _get_notion_page_op(), + _picked( + client_op( + "create_notion_page", + "create_page", + description="Create a new page in Notion.", + tags=("notion_pages", "notion"), + parallelizable=False, + input_schema={ + "parent_id": { + "type": "string", + "description": "Parent page or database ID.", + "example": "abc123", + }, + "parent_type": { + "type": "string", + "description": "'page_id' or 'database_id'.", + "example": "page_id", + }, + "properties": { + "type": "object", + "description": "Page properties.", + "example": {"title": [{"text": {"content": "New Page"}}]}, + }, + "children": { + "type": "array", + "description": "Optional content blocks.", + "example": [], + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": { + "type": "object", + "description": "{id, url} of the new page.", + }, + }, + arg_map=lambda d: { + "parent_id": d["parent_id"], + "parent_type": d["parent_type"], + "properties": d["properties"], + "children": d.get("children"), + }, + ), + ["id", "url"], + ), + _picked( + client_op( + "update_notion_page", + "update_page", + description="Update a Notion page's properties (and/or archive state).", + tags=("notion_pages", "notion"), + parallelizable=False, + input_schema={ + "page_id": { + "type": "string", + "description": "Page ID to update.", + "example": "abc123", + }, + "properties": { + "type": "object", + "description": "Properties to update.", + "example": {}, + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": { + "type": "object", + "description": "{id, url} of the updated page.", + }, + }, + ), + ["id", "url"], + ), + client_op( + "archive_notion_page", + "archive_page", + description=( + "Archive a Notion page (send to trash). Reversible via " + "restore_notion_page." + ), + tags=("notion_pages", "notion"), + parallelizable=False, + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + }, + ), + client_op( + "restore_notion_page", + "restore_page", + description="Restore a previously-archived Notion page.", + tags=("notion_pages",), + parallelizable=False, + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + }, + ), + client_op( + "get_notion_page_property", + "get_page_property", + description=( + "Get a single page property's value. For rollup/relation/people " + "properties that paginate, this returns the full list." + ), + tags=("notion_pages",), + input_schema={ + "page_id": {"type": "string", "description": "Page ID.", "example": ""}, + "property_id": { + "type": "string", + "description": "Property ID (from page schema).", + "example": "", + }, + "page_size": { + "type": "integer", + "description": "Pagination size.", + "example": 100, + }, + }, + arg_map=lambda d: { + "page_id": d["page_id"], + "property_id": d["property_id"], + "page_size": d.get("page_size", 100), + }, + ), + ] + + +# ------------------------------------------------------------------ +# Databases +# ------------------------------------------------------------------ + + +def _get_notion_database_schema_op() -> Operation: + base = client_op( + "get_notion_database_schema", + "get_database", + description=( + "Get a Notion database schema by ID. Lean {id, title, url, " + "properties: {name: type (+options for select/multi_select/status)}} " + "by default; include_metadata=true returns the full raw database object." + ), + tags=("notion_databases", "notion"), + input_schema={ + "database_id": { + "type": "string", + "description": "Database ID.", + "example": "abc123", + }, + "include_metadata": { + "type": "boolean", + "description": ( + "False (default): lean schema (property name -> type). " + "True: full raw." + ), + "example": False, + }, + }, + output_schema={**STATUS_OUTPUT, "database": {"type": "object"}}, + arg_map=lambda d: {"database_id": d["database_id"]}, + ) + + def shaper(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + props: Dict[str, Any] = {} + for name, p in (body.get("properties") or {}).items(): + if not isinstance(p, dict): + continue + t = p.get("type") + if t in ("select", "multi_select", "status"): + options = (p.get(t) or {}).get("options") or [] + props[name] = { + "type": t, + "options": [o.get("name") for o in options if isinstance(o, dict)], + } + else: + props[name] = t + lean = { + "id": body.get("id"), + "title": _plain(body.get("title")), + "url": body.get("url"), + "properties": props, + } + return {**res, "result": lean} + + return _shaped(base, shaper) + + +def _query_notion_database_op() -> Operation: + base = client_op( + "query_notion_database", + "query_database", + description=( + "Query a Notion database with optional filters and sorts. Lean rows " + "({id, url, properties: {name: plain value}}) by default; " + "include_metadata=true returns the full raw page objects." + ), + tags=("notion_databases", "notion"), + input_schema={ + "database_id": { + "type": "string", + "description": "Database ID.", + "example": "abc123", + }, + "filter": { + "type": "object", + "description": "Optional Notion filter object.", + "example": {}, + }, + "sorts": { + "type": "array", + "description": "Optional sort array.", + "example": [], + }, + "include_metadata": { + "type": "boolean", + "description": ( + "False (default): lean rows with plain property values. " + "True: full raw." + ), + "example": False, + }, + }, + arg_map=lambda d: { + "database_id": d["database_id"], + "filter_obj": d.get("filter"), + "sorts": d.get("sorts"), + }, + ) + + def shaper(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + lean = { + "results": [ + { + "id": row.get("id"), + "url": row.get("url"), + "properties": { + name: _prop_value(p) + for name, p in (row.get("properties") or {}).items() + }, + } + for row in body.get("results", []) or [] + if isinstance(row, dict) + ], + "has_more": body.get("has_more"), + "next_cursor": body.get("next_cursor"), + } + return {**res, "result": lean} + + return _shaped(base, shaper) + + +def _database_ops() -> List[Operation]: + return [ + _get_notion_database_schema_op(), + _query_notion_database_op(), + _picked( + client_op( + "create_notion_database", + "create_database", + description=( + "Create a new database under a parent page. Schema goes in " + "'properties' (each value is a property type config like " + "{'title': {}} / {'rich_text': {}} / {'select': {'options': " + "[...]}})." + ), + tags=("notion_databases", "notion"), + parallelizable=False, + input_schema={ + "parent_page_id": { + "type": "string", + "description": "Parent page ID.", + "example": "", + }, + "title": { + "type": "array", + "description": "Title rich_text array.", + "example": [{"text": {"content": "Tasks"}}], + }, + "description": { + "type": "array", + "description": "Description rich_text array (optional).", + "example": [], + }, + "properties": { + "type": "object", + "description": "Property schema (column definitions). Required.", + "example": {"Name": {"title": {}}}, + }, + "is_inline": { + "type": "boolean", + "description": "Render inline.", + "example": False, + }, + "icon": { + "type": "object", + "description": "Icon (optional). e.g. {'type':'emoji','emoji':'📋'}.", + "example": {}, + }, + "cover": { + "type": "object", + "description": "Cover (optional).", + "example": {}, + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": { + "type": "object", + "description": "{id, url} of the new database.", + }, + }, + arg_map=lambda d: { + "parent_page_id": d["parent_page_id"], + "title": d.get("title"), + "description": d.get("description"), + "properties": d.get("properties"), + "is_inline": bool(d.get("is_inline", False)), + "icon": d.get("icon") or None, + "cover": d.get("cover") or None, + }, + ), + ["id", "url"], + ), + _picked( + client_op( + "update_notion_database", + "update_database", + description=( + "Update a Notion database (title, description, schema, " + "inline state)." + ), + tags=("notion_databases", "notion"), + parallelizable=False, + input_schema={ + "database_id": { + "type": "string", + "description": "Database ID.", + "example": "", + }, + "title": { + "type": "array", + "description": "New title rich_text (optional).", + "example": [], + }, + "description": { + "type": "array", + "description": "New description rich_text (optional).", + "example": [], + }, + "properties": { + "type": "object", + "description": ( + "Property updates (rename / change type / remove " + "with null) (optional)." + ), + "example": {}, + }, + "is_inline": { + "type": "boolean", + "description": "Set inline (optional).", + "example": False, + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": { + "type": "object", + "description": "{id, url} of the updated database.", + }, + }, + arg_map=lambda d: { + "database_id": d["database_id"], + "title": d.get("title"), + "description": d.get("description"), + "properties": d.get("properties"), + "is_inline": d["is_inline"] if "is_inline" in d else None, + }, + ), + ["id", "url"], + ), + client_op( + "archive_notion_database", + "archive_database", + description="Archive a Notion database.", + tags=("notion_databases",), + parallelizable=False, + input_schema={ + "database_id": { + "type": "string", + "description": "Database ID.", + "example": "", + }, + }, + ), + client_op( + "restore_notion_database", + "restore_database", + description="Restore an archived Notion database.", + tags=("notion_databases",), + parallelizable=False, + input_schema={ + "database_id": { + "type": "string", + "description": "Database ID.", + "example": "", + }, + }, + ), + ] + + +# ------------------------------------------------------------------ +# Blocks +# ------------------------------------------------------------------ + + +def _get_notion_page_content_op() -> Operation: + base = client_op( + "get_notion_page_content", + "get_block_children", + description=( + "Get the content blocks of a Notion page (or any block that has " + "children). By default returns SIMPLIFIED content (each block's " + "type + plain text) to keep the output small and readable. Set " + "include_metadata=true to get the FULL raw blocks including block " + "IDs, timestamps and other metadata — do this when you need block " + "IDs to update or delete specific blocks." + ), + tags=("notion_blocks", "notion"), + input_schema={ + "page_id": { + "type": "string", + "description": "Page ID (or block ID for nested children).", + "example": "abc123", + }, + "include_metadata": { + "type": "boolean", + "description": ( + "False (default): return only {type, text} per block — " + "lean, for reading. True: return the full raw blocks with " + "block IDs/timestamps/etc. — needed to edit or delete " + "specific blocks." + ), + "example": False, + }, + }, + output_schema={ + **STATUS_OUTPUT, + "content": { + "type": "array", + "description": ( + "Simplified blocks [{type, text, ...}] when " + "include_metadata is false; full raw blocks when true." + ), + }, + }, + arg_map=lambda d: {"block_id": d["page_id"]}, + ) + + def shaper(result: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if bool(input_data.get("include_metadata", False)) or ( + result.get("status") == "error" + ): + return result + raw = result.get("result", {}) + blocks = raw.get("results", []) if isinstance(raw, dict) else [] + + def _simplify(b: dict) -> dict: + t = b.get("type") + data = b.get(t) if isinstance(b.get(t), dict) else {} + text = "".join( + rt.get("plain_text", "") + for rt in data.get("rich_text", []) + if isinstance(rt, dict) + ) + out = {"type": t, "text": text} + if t == "to_do": + out["checked"] = bool(data.get("checked")) + if b.get("has_children"): + out["has_children"] = True + return out + + content = [_simplify(b) for b in blocks if isinstance(b, dict)] + out: Dict[str, Any] = {"status": "success", "content": content} + if isinstance(raw, dict) and raw.get("has_more"): + out["has_more"] = True + out["next_cursor"] = raw.get("next_cursor") + return out + + return _shaped(base, shaper) + + +def _append_notion_page_content_op() -> Operation: + base = client_op( + "append_notion_page_content", + "append_block_children", + description=( + "Append content blocks to a Notion page (or any block). Returns " + "{appended: count, ids: [block ids]}." + ), + tags=("notion_blocks", "notion"), + parallelizable=False, + input_schema={ + "page_id": { + "type": "string", + "description": "Page ID (or block ID).", + "example": "abc123", + }, + "children": { + "type": "array", + "description": "List of block objects.", + "example": [], + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": {"type": "object", "description": "{appended, ids}."}, + }, + arg_map=lambda d: {"block_id": d["page_id"], "children": d["children"]}, + ) + + def shaper(res: Dict[str, Any], _input_data: Dict[str, Any]) -> Dict[str, Any]: + if res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict) or not isinstance(body.get("results"), list): + return res + ids = [b.get("id") for b in body["results"] if isinstance(b, dict)] + return {**res, "result": {"appended": len(ids), "ids": ids}} + + return _shaped(base, shaper) + + +def _block_ops() -> List[Operation]: + return [ + _get_notion_page_content_op(), + _append_notion_page_content_op(), + client_op( + "get_notion_block", + "get_block", + description="Get a single block (not its children) by block ID.", + tags=("notion_blocks", "notion"), + input_schema={ + "block_id": { + "type": "string", + "description": "Block ID.", + "example": "", + }, + }, + ), + _picked( + client_op( + "update_notion_block", + "update_block", + description=( + "Update a block's content. block_update has the " + "per-block-type key as the top-level field, e.g. {'to_do': " + "{'rich_text': [...], 'checked': true}} for a to-do, " + "{'paragraph': {'rich_text': [...]}} for a paragraph. Pass " + "{'in_trash': true} to soft-delete." + ), + tags=("notion_blocks", "notion"), + parallelizable=False, + input_schema={ + "block_id": { + "type": "string", + "description": "Block ID.", + "example": "", + }, + "block_update": { + "type": "object", + "description": "Per-block-type update object.", + "example": { + "paragraph": { + "rich_text": [{"text": {"content": "Updated"}}] + } + }, + }, + }, + output_schema={ + **STATUS_OUTPUT, + "result": { + "type": "object", + "description": "{id} of the updated block.", + }, + }, + ), + ["id"], + ), + client_op( + "delete_notion_block", + "delete_block", + description="Delete (soft delete, send to trash) a Notion block.", + tags=("notion_blocks", "notion"), + # Reversible (trash), but the "delete" verb trips the conformance + # destructive-name gate — flagged so hosts confirm-or-clarify. + destructive=True, + parallelizable=False, + input_schema={ + "block_id": { + "type": "string", + "description": "Block ID.", + "example": "", + }, + }, + ), + ] + + +# ------------------------------------------------------------------ +# Comments / Users +# ------------------------------------------------------------------ + + +def _comment_and_user_ops() -> List[Operation]: + return [ + client_op( + "list_notion_comments", + "list_comments", + description="List comments on a page or block.", + tags=("notion_comments", "notion"), + input_schema={ + "block_id": { + "type": "string", + "description": "Block or page ID.", + "example": "", + }, + "page_size": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + "start_cursor": { + "type": "string", + "description": "Pagination cursor (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "block_id": d["block_id"], + "page_size": d.get("page_size", 100), + "start_cursor": d.get("start_cursor") or None, + }, + ), + client_op( + "create_notion_comment", + "create_comment", + description=( + "Post a comment on a page/block, or reply in a discussion. " + "Provide exactly one of parent_page_id, parent_block_id, or " + "discussion_id." + ), + tags=("notion_comments", "notion"), + parallelizable=False, + input_schema={ + "rich_text": { + "type": "array", + "description": "Comment content as rich_text array.", + "example": [{"text": {"content": "Looks good!"}}], + }, + "parent_page_id": { + "type": "string", + "description": "Page ID for a new top-level discussion (optional).", + "example": "", + }, + "parent_block_id": { + "type": "string", + "description": "Block ID for a new top-level discussion (optional).", + "example": "", + }, + "discussion_id": { + "type": "string", + "description": "Discussion ID to reply to (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "rich_text": d["rich_text"], + "parent_page_id": d.get("parent_page_id") or None, + "parent_block_id": d.get("parent_block_id") or None, + "discussion_id": d.get("discussion_id") or None, + }, + ), + client_op( + "list_notion_users", + "list_users", + description="List workspace members visible to the integration.", + tags=("notion_users", "notion"), + input_schema={ + "page_size": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + "start_cursor": { + "type": "string", + "description": "Pagination cursor (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "page_size": d.get("page_size", 100), + "start_cursor": d.get("start_cursor") or None, + }, + ), + client_op( + "get_notion_user", + "get_user", + description="Get a single Notion user by ID.", + tags=("notion_users", "notion"), + input_schema={ + "user_id": {"type": "string", "description": "User ID.", "example": ""}, + }, + ), + client_op( + "get_notion_bot_info", + "get_bot_info", + description=( + "Get info about the authenticated Notion bot (workspace_name, " + "owner, capabilities)." + ), + tags=("notion_users", "notion"), + input_schema={}, + ), + ] + + +# ------------------------------------------------------------------ +# File uploads +# ------------------------------------------------------------------ + + +def _file_upload_ops() -> List[Operation]: + return [ + client_op( + "upload_notion_file", + "upload_local_file", + description=( + "High-level: upload a local file in one call (single-part). " + "Returns the file_upload object with id+status='uploaded'. " + "Attach to a block via {'type':'file_upload','file_upload':" + "{'id': }}. Use multi-part flow for files >20 MB." + ), + tags=("notion_files", "notion"), + parallelizable=False, + input_schema={ + "file_path": { + "type": "string", + "description": "Absolute path to local file.", + "example": "C:/Users/me/report.pdf", + }, + "content_type": { + "type": "string", + "description": "MIME type (autodetect if omitted).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_path": d["file_path"], + "content_type": d.get("content_type") or None, + }, + ), + client_op( + "create_notion_file_upload", + "create_file_upload", + description=( + "Step 1 of file upload: initialise a file_upload resource. " + "Returns id + upload_url. Use mode=single_part for <20 MB, " + "multi_part for larger, or external_url to import from a URL." + ), + tags=("notion_files",), + parallelizable=False, + input_schema={ + "mode": { + "type": "string", + "description": "single_part | multi_part | external_url.", + "example": "single_part", + }, + "filename": { + "type": "string", + "description": "Required for multi_part.", + "example": "", + }, + "content_type": { + "type": "string", + "description": "MIME type (recommended).", + "example": "", + }, + "number_of_parts": { + "type": "integer", + "description": "Required for multi_part.", + "example": 0, + }, + "external_url": { + "type": "string", + "description": "Required for external_url mode.", + "example": "", + }, + }, + arg_map=lambda d: { + "mode": d.get("mode", "single_part"), + "filename": d.get("filename") or None, + "content_type": d.get("content_type") or None, + "number_of_parts": d.get("number_of_parts") or None, + "external_url": d.get("external_url") or None, + }, + ), + client_op( + "send_notion_file_upload", + "send_file_upload", + description=( + "Step 2: send file bytes to a pending file_upload. For " + "multi_part uploads, repeat with each part_number." + ), + tags=("notion_files",), + parallelizable=False, + input_schema={ + "file_upload_id": { + "type": "string", + "description": "ID from create_notion_file_upload.", + "example": "", + }, + "file_path": { + "type": "string", + "description": ( + "Absolute path to local file (or one part for multi_part)." + ), + "example": "", + }, + "part_number": { + "type": "integer", + "description": "1..1000, only for multi_part.", + "example": 0, + }, + }, + arg_map=lambda d: { + "file_upload_id": d["file_upload_id"], + "file_path": d["file_path"], + "part_number": d.get("part_number") or None, + }, + ), + client_op( + "complete_notion_file_upload", + "complete_file_upload", + description=( + "Step 3 (multi_part only): finalize a multi-part upload after " + "all parts sent." + ), + tags=("notion_files",), + parallelizable=False, + input_schema={ + "file_upload_id": { + "type": "string", + "description": "File upload ID.", + "example": "", + }, + }, + ), + client_op( + "get_notion_file_upload", + "get_file_upload", + description="Get the current status of a file upload.", + tags=("notion_files",), + input_schema={ + "file_upload_id": { + "type": "string", + "description": "File upload ID.", + "example": "", + }, + }, + ), + client_op( + "list_notion_file_uploads", + "list_file_uploads", + description=( + "List file uploads created by this integration. Filter by " + "status (pending|uploaded|expired|failed)." + ), + tags=("notion_files",), + input_schema={ + "status": { + "type": "string", + "description": "Filter (optional).", + "example": "", + }, + "page_size": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + "start_cursor": { + "type": "string", + "description": "Pagination cursor (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "status": d.get("status") or None, + "page_size": d.get("page_size", 100), + "start_cursor": d.get("start_cursor") or None, + }, + ), + ] + + +def build_operations() -> List[Operation]: + return [ + _search_notion_op(), + *_page_ops(), + *_database_ops(), + *_block_ops(), + *_comment_and_user_ops(), + *_file_upload_ops(), + ] diff --git a/craftos_integrations/providers/notion/provider.py b/craftos_integrations/providers/notion/provider.py new file mode 100644 index 00000000..627f9a70 --- /dev/null +++ b/craftos_integrations/providers/notion/provider.py @@ -0,0 +1,155 @@ +"""Notion provider — multi-account wrapper over the legacy ``NotionClient``. + +API surface comes from the legacy client (all Notion REST methods live +there, unchanged); the binding below only replaces its disk credential +plumbing with the injected per-account credential. + +One connected account = one Notion workspace: the OAuth grant is issued +per workspace via Notion's native workspace picker, and the access token +never expires (``refresh()`` returns None). +""" + +from __future__ import annotations + +import copy +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...integrations.notion import NotionClient, NotionCredential, NotionHandler +from .._google import read_guidance +from .operations import build_operations + +# Real endpoints from the legacy NotionHandler.oauth flow. +NOTION_AUTH_URL = "https://api.notion.com/v1/oauth/authorize" +NOTION_TOKEN_URL = "https://api.notion.com/v1/oauth/token" + +# Notion's authorize page includes a native workspace picker, so +# has_chooser=True; ``owner=user`` mirrors the legacy OAuthFlow params. +NOTION_AUTH_PARAMS = {"owner": "user"} + + +class NotionClientBinding: + """Overrides NotionClient's disk plumbing: credential is injected per + account; there is no token refresh (Notion tokens don't expire). MRO + puts this before the legacy client: + + class BoundNotionClient(NotionClientBinding, NotionClient): pass + """ + + _cred: Optional[NotionCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + # OAuth invites store "access_token"; manual token entry (and the + # old notion.json) store "token" — accept both. + token = credential.get("token") or credential.get("access_token") or "" + self._cred = NotionCredential(token=token) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> NotionCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundNotionClient(NotionClientBinding, NotionClient): + """NotionClient with per-account credential binding (see NotionClientBinding).""" + + +class NotionProvider: + id = "notion" + family = None + display_name = "Notion" + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Workspace id (falling back to bot id) from the OAuth response. + + Old token-only shapes ({"token": "secret_..."}) carry neither — + return None so the core stores them under LEGACY_IDENTITY and + upgrades in place on the next re-auth. + """ + for key in ("workspace_id", "bot_id"): + value = credential.get(key) + if isinstance(value, str) and value.strip(): + return value.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=NOTION_AUTH_URL, + token_url=NOTION_TOKEN_URL, + scopes=(), # Notion OAuth has no scope parameter + extra_authorize_params=dict(NOTION_AUTH_PARAMS), + has_chooser=True, # native workspace picker on the authorize page + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = BoundNotionClient() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # Notion integration tokens do not expire + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the legacy handler's OAuthFlow — the + machinery behind the legacy ``invite()`` subcommand (Basic-auth + JSON token exchange, no userinfo endpoint; workspace metadata + arrives in the token response itself). The manual token-entry + ``login()`` path is host UI territory and is not ported here. + + A *copy* of the shared flow gets the provider spec's + ``extra_authorize_params`` (``owner=user``, same as legacy) + applied — the shared handler instance is never mutated. + + Returns (identity, credential, message). Identity is computed by + ``identity_of`` (workspace id, falling back to bot id). When the + token response carries neither, the credential is returned with + identity None — the core stores it under LEGACY_IDENTITY and + upgrades it in place on the next re-auth. + """ + oauth = copy.copy(NotionHandler.oauth) + oauth.extra_auth_params = dict(self.oauth_spec().extra_authorize_params) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"Notion OAuth failed: {result['error']}" + raw = result.get("raw") or {} + credential = { + # build_client accepts "token" (the legacy key) or "access_token". + "token": result.get("access_token", ""), + "workspace_id": raw.get("workspace_id") or "", + "bot_id": raw.get("bot_id") or "", + "workspace_name": raw.get("workspace_name") or "", + } + identity = self.identity_of(credential) + ws_name = raw.get("workspace_name") or "default" + message = f"Notion connected via CraftOS integration: {ws_name}" + if not identity: + message += ( + " (no workspace id returned — stored as the legacy account " + "until the next re-auth)" + ) + return identity, credential, message + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ): + return None # Notion is request-response only (no event listening) diff --git a/craftos_integrations/providers/outlook/GUIDANCE.md b/craftos_integrations/providers/outlook/GUIDANCE.md new file mode 100644 index 00000000..31ce516a --- /dev/null +++ b/craftos_integrations/providers/outlook/GUIDANCE.md @@ -0,0 +1,39 @@ +# Outlook + +Microsoft 365 / Outlook.com mail via Microsoft Graph — read, search, send, +reply/forward, drafts, attachments, folders, inbox rules, categories, +mailbox settings. + +## Multi-account +- Every Outlook action accepts an optional `account` (email, nickname, or + a unique fragment like "work"). Omit it to use the primary account. +- When the user names an account in any form ("my work mailbox", "the + contoso address"), pass it as `account` — never silently default to + primary. +- Message, folder, attachment, rule, and category ids are + **account-scoped**: an id returned by `search_outlook_emails` with + `account="work"` must be used with `account="work"` on every follow-up + action (get/reply/move/delete/etc.). +- For destructive actions (send, delete, folder delete) with multiple + accounts connected and no account named: ask the user which account + before acting. + +## Essentials +- **The integration knows the user's own email address** — read it from + the connected account; never ask the user for it. +- **`From` is always the connected account.** It cannot be spoofed on + send. +- **Message IDs are Microsoft Graph opaque IDs** (`AAMk...`). Pull them + from list/search results; never construct them. Conversation IDs group + related messages — useful for finding threads. +- **`delete_outlook_email` is permanent.** Prefer `move_outlook_email` to + `deleteditems` for a soft delete. +- **Well-known folder names** work anywhere a folder id is accepted: + `inbox`, `drafts`, `sentitems`, `deleteditems`, `archive`, `junkemail` + (and `msgfolderroot` as the top-level parent). +- **`add_outlook_attachment` only works on drafts** and only for files + under 3 MB. +- **Token refresh is automatic** (60-second buffer before the ~2-hour + TTL). A 401 means the access token expired and the client is + refreshing — wait and retry; only direct the user to reconnect if 401s + persist across retries. diff --git a/craftos_integrations/providers/outlook/__init__.py b/craftos_integrations/providers/outlook/__init__.py new file mode 100644 index 00000000..274290a4 --- /dev/null +++ b/craftos_integrations/providers/outlook/__init__.py @@ -0,0 +1,3 @@ +from .provider import OutlookProvider + +__all__ = ["OutlookProvider"] diff --git a/craftos_integrations/providers/outlook/listener.py b/craftos_integrations/providers/outlook/listener.py new file mode 100644 index 00000000..930ecd66 --- /dev/null +++ b/craftos_integrations/providers/outlook/listener.py @@ -0,0 +1,97 @@ +"""Outlook listener — the legacy Graph poll loop re-homed onto a bound client. + +The loop machinery is NOT rewritten: ``BoundOutlookClient`` inherits the +legacy ``OutlookClient``'s ``_poll_loop`` / ``_check_new_messages`` / +``_dispatch_message`` (``/me/messages`` filtered by ``receivedDateTime`` +every POLL_INTERVAL, 401-triggered refresh, seen-id dedup, self-message +filtering) unchanged. This class replaces only: + +* callback plumbing — ``_message_callback`` becomes a shim converting each + ``PlatformMessage`` into the host event payload and awaiting the + account-bound ``emit``; +* startup state — instead of always starting the ``receivedDateTime`` + watermark at "now", a persisted cursor seeds ``_last_poll_time`` + + ``_seen_message_ids`` so a restart picks up mail received while the host + was down without re-emitting what was already delivered. + +Mid-poll 401s resolve through ``OutlookClientBinding.refresh_access_token`` +(inherited via MRO by the loop's refresh calls), so Microsoft's rotating +refresh tokens persist through the core automatically. +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from ...integrations.outlook import POLL_INTERVAL +from ...logger import get_logger +from .._shared import EmitFn, emit_callback + +logger = get_logger(__name__) + +# How many recently-seen message ids survive into the cursor. Matches the +# legacy in-memory trim floor (sets over 500 were cut back to 200). +CURSOR_SEEN_IDS = 200 + + +class OutlookListener: + """One Outlook mailbox poll loop for one bound account.""" + + def __init__( + self, client: Any, cursor: Optional[Dict[str, Any]], emit: EmitFn + ) -> None: + self._client = client + self._initial_cursor = dict(cursor) if cursor else None + self._emit = emit + self.poll_interval: float = POLL_INTERVAL # legacy cadence (5s) + + async def start(self) -> None: + client = self._client + if client._listening: + return + client._message_callback = emit_callback(self._emit) + + # Same connectivity/token sanity check the legacy start_listening + # performed (also warms the access token via the credential binding). + try: + profile = await client._async_get_profile() + email_addr = profile.get("mail") or profile.get("userPrincipalName", "") + logger.info(f"[OUTLOOK] listener connected as: {email_addr}") + except Exception as e: + raise RuntimeError(f"Failed to connect to Outlook: {e}") + + saved = self._initial_cursor or {} + last_poll_time = saved.get("last_poll_time") + if last_poll_time: + # Resume: keep the persisted receivedDateTime watermark so mail + # that arrived while we were down is still delivered; seen ids + # stop the overlapping window from double-emitting. + client._last_poll_time = str(last_poll_time) + client._seen_message_ids = set(saved.get("seen_ids") or []) + else: + # Fresh start: watermark at "now", exactly like the legacy + # start_listening — no historical backfill. + client._last_poll_time = datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + client._seen_message_ids = set() + + client._listening = True + client._poll_task = asyncio.create_task(client._poll_loop()) + + async def stop(self) -> None: + # Legacy stop_listening already does exactly what we need. + await self._client.stop_listening() + + def cursor(self) -> Optional[Dict[str, Any]]: + client = self._client + if not client._last_poll_time: + # Never started: hand back what we were given so a persisted + # cursor is never destroyed. + return self._initial_cursor + return { + "last_poll_time": client._last_poll_time, + "seen_ids": sorted(client._seen_message_ids)[-CURSOR_SEEN_IDS:], + } diff --git a/craftos_integrations/providers/outlook/operations.py b/craftos_integrations/providers/outlook/operations.py new file mode 100644 index 00000000..412c0aac --- /dev/null +++ b/craftos_integrations/providers/outlook/operations.py @@ -0,0 +1,1179 @@ +"""Outlook operations — ported from the legacy outlook_actions.py schemas. + +NOTE: no operation declares an ``account`` input — the host adapter +injects it on every generated action and the core resolves it centrally +(conformance-enforced). + +Complete port of app/data/action/integrations/outlook/outlook_actions.py +(all 40 actions). Names, descriptions, schemas, arg maps, envelope +options, and the lean/include_metadata result shaping are reproduced +verbatim; legacy ``irreversible`` sends plus permanent deletes map to +``destructive=True``. The legacy file's intentionally-unexposed Graph +surfaces (webhooks, >3 MB upload sessions, extensions, calendar, +delta sync, delegation) stay unexposed here for the same reasons. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Dict, List, Optional + +from ...contracts import Operation +from .._shared import client_op + +_UNSET = object() + + +def _csv_list(text: Optional[str], default: Any = _UNSET) -> Any: + """Local copy of app.utils.text.csv_list (providers are host-blind).""" + if not text: + return [] if default is _UNSET else default + return [v.strip() for v in text.split(",") if v.strip()] + + +def _forward_outlook_email_op() -> Operation: + """forward_outlook_email with the legacy empty-recipient guard.""" + base = client_op( + "forward_outlook_email", + "forward_message", + description="Forward an email to other recipients.", + destructive=True, # outward-facing send (legacy irreversible) + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to forward.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "AAMk...", + }, + "to_recipients": { + "type": "string", + "description": "Comma-separated recipient emails.", + "example": "bob@example.com", + }, + "comment": { + "type": "string", + "description": "Optional intro comment.", + "example": "", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "to_recipients": _csv_list(d["to_recipients"]), + "comment": d.get("comment", ""), + }, + ) + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + if not _csv_list(input_data.get("to_recipients", "")): + return {"status": "error", "message": "No recipients provided."} + return await inner(client, input_data) + + return replace(base, fn=fn) + + +def _get_outlook_mailbox_settings_op() -> Operation: + """get_outlook_mailbox_settings with the legacy lean shaping.""" + base = client_op( + "get_outlook_mailbox_settings", + "get_mailbox_settings", + description=( + "Get the user's mailbox settings. Default returns {timeZone, " + "language, workingHours, automaticRepliesSetting.status}; set " + "include_metadata for the raw settings." + ), + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to get settings.", + input_schema={ + "include_metadata": { + "type": "boolean", + "description": "Return the raw mailboxSettings resource (default false = lean).", + "example": False, + }, + }, + arg_map=lambda d: {}, + ) + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + res = await inner(client, input_data) + if not input_data.get("include_metadata") and res.get("status") == "success": + settings = res.get("result") + if isinstance(settings, dict): + lean: Dict[str, Any] = {"timeZone": settings.get("timeZone")} + language = settings.get("language") or {} + if language.get("displayName"): + lean["language"] = {"displayName": language["displayName"]} + wh = settings.get("workingHours") or {} + if wh: + lean["workingHours"] = { + k: wh.get(k) + for k in ("daysOfWeek", "startTime", "endTime") + if wh.get(k) is not None + } + ars = settings.get("automaticRepliesSetting") or {} + if ars.get("status"): + lean["automaticRepliesSetting"] = {"status": ars["status"]} + res = {**res, "result": lean} + return res + + return replace(base, fn=fn) + + +def _get_outlook_automatic_replies_op() -> Operation: + """get_outlook_automatic_replies with the legacy lean/HTML-strip shaping.""" + base = client_op( + "get_outlook_automatic_replies", + "get_automatic_replies", + description=( + "Get the current out-of-office / automatic reply settings. " + "Default returns {status, schedule, reply messages as plain " + "text}; set include_metadata for the raw setting." + ), + tags=("outlook_settings", "outlook"), + unwrap_envelope=True, + fail_message="Failed to get auto-replies.", + input_schema={ + "include_metadata": { + "type": "boolean", + "description": "Return the raw automaticRepliesSetting (default false = lean, HTML stripped).", + "example": False, + }, + }, + arg_map=lambda d: {}, + ) + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + res = await inner(client, input_data) + if not input_data.get("include_metadata") and res.get("status") == "success": + setting = res.get("result") + if isinstance(setting, dict): + import html + import re + + def _strip_html(value): + if not isinstance(value, str): + return value + return html.unescape(re.sub(r"<[^>]+>", "", value)).strip() + + res = { + **res, + "result": { + k: v + for k, v in { + "status": setting.get("status"), + "scheduledStartDateTime": setting.get( + "scheduledStartDateTime" + ), + "scheduledEndDateTime": setting.get( + "scheduledEndDateTime" + ), + "internalReplyMessage": _strip_html( + setting.get("internalReplyMessage") + ), + "externalReplyMessage": _strip_html( + setting.get("externalReplyMessage") + ), + }.items() + if v is not None + }, + } + return res + + return replace(base, fn=fn) + + +def _update_draft_args(d: Dict[str, Any]) -> Dict[str, Any]: + """Legacy presence-based semantics: only keys present in the request + replace draft fields; absent keys pass None (client skips them).""" + return { + "message_id": d["message_id"], + "subject": d.get("subject") if "subject" in d else None, + "body": d.get("body") if "body" in d else None, + "html": bool(d.get("html", False)), + "to": _csv_list(d["to"], default=None) if "to" in d else None, + "cc": _csv_list(d["cc"], default=None) if "cc" in d else None, + "bcc": _csv_list(d["bcc"], default=None) if "bcc" in d else None, + } + + +def _update_automatic_replies_args(d: Dict[str, Any]) -> Dict[str, Any]: + return { + "status": d["status"], + "internal_reply": d.get("internal_reply") + if "internal_reply" in d + else None, + "external_reply": d.get("external_reply") + if "external_reply" in d + else None, + "external_audience": d.get("external_audience", "all"), + "scheduled_start": d.get("scheduled_start") or None, + "scheduled_end": d.get("scheduled_end") or None, + } + + +def build_operations() -> List[Operation]: + return [ + # ── Mail — read / send / reply / forward / draft / lifecycle ───── + client_op( + "send_outlook_email", + "send_email", + description="Send an email via Outlook (Microsoft 365).", + destructive=True, # outward-facing send (legacy irreversible) + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + success_message="Email sent.", + fail_message="Failed to send email.", + input_schema={ + "to": { + "type": "string", + "description": "Recipient email address.", + "example": "user@example.com", + }, + "subject": { + "type": "string", + "description": "Email subject.", + "example": "Meeting Follow-up", + }, + "body": { + "type": "string", + "description": "Email body text.", + "example": "Hi, here are the notes...", + }, + "cc": { + "type": "string", + "description": "Optional CC recipients (comma-separated).", + "example": "", + }, + }, + arg_map=lambda d: { + "to": d["to"], + "subject": d["subject"], + "body": d["body"], + "cc": d.get("cc"), + }, + ), + client_op( + "list_outlook_emails", + "list_emails", + description="List recent emails from Outlook inbox.", + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to list emails.", + input_schema={ + "count": { + "type": "integer", + "description": "Number of recent emails to list.", + "example": 10, + }, + "unread_only": { + "type": "boolean", + "description": "Only show unread emails.", + "example": False, + }, + }, + arg_map=lambda d: { + "n": d.get("count", 10), + "unread_only": d.get("unread_only", False), + }, + ), + client_op( + "get_outlook_email", + "get_email", + description=( + "Get full details of a specific Outlook email by message ID. " + "Body is plain text by default; set include_metadata for the " + "HTML body." + ), + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to get email.", + input_schema={ + "message_id": { + "type": "string", + "description": "Outlook message ID.", + "example": "AAMk...", + }, + "include_metadata": { + "type": "boolean", + "description": "Return the HTML body instead of plain text (default false).", + "example": False, + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "include_metadata": bool(d.get("include_metadata", False)), + }, + ), + client_op( + "read_top_outlook_emails", + "read_top_emails", + description=( + "Read the top N recent Outlook emails with details. With " + "full_body=true, bodies are plain text by default; set " + "include_metadata for HTML bodies." + ), + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to read emails.", + input_schema={ + "count": { + "type": "integer", + "description": "Number of emails to read.", + "example": 5, + }, + "full_body": { + "type": "boolean", + "description": "Include full body text.", + "example": False, + }, + "include_metadata": { + "type": "boolean", + "description": "With full_body, return HTML bodies instead of plain text (default false).", + "example": False, + }, + }, + arg_map=lambda d: { + "n": d.get("count", 5), + "full_body": d.get("full_body", False), + "include_metadata": bool(d.get("include_metadata", False)), + }, + ), + client_op( + "search_outlook_emails", + "search_messages", + description=( + "Search Outlook messages by free-text query (matches subject, " + "body, attachments). Sorted by relevance." + ), + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to search.", + input_schema={ + "query": { + "type": "string", + "description": "Search text.", + "example": "invoice contoso", + }, + "top": {"type": "integer", "description": "Max results.", "example": 25}, + "folder": { + "type": "string", + "description": "Optional folder name (inbox/sentitems/etc.) or ID.", + "example": "", + }, + }, + arg_map=lambda d: { + "query": d["query"], + "top": d.get("top", 25), + "folder": d.get("folder") or None, + }, + ), + client_op( + "reply_outlook_email", + "reply_to_message", + description="Reply to the sender of an email. Sent immediately.", + destructive=True, # outward-facing send (legacy irreversible) + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to reply.", + input_schema={ + "message_id": { + "type": "string", + "description": "Original message ID.", + "example": "AAMk...", + }, + "comment": { + "type": "string", + "description": "Reply body (plain text).", + "example": "Thanks, sounds good.", + }, + "to_recipients": { + "type": "string", + "description": "Optional comma-separated extra recipients.", + "example": "", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "comment": d["comment"], + "to_recipients": _csv_list(d.get("to_recipients", ""), default=None) + if d.get("to_recipients") + else None, + }, + ), + client_op( + "reply_all_outlook_email", + "reply_all_to_message", + description="Reply-all to an email. Sent immediately.", + destructive=True, # outward-facing send (legacy irreversible) + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to reply-all.", + input_schema={ + "message_id": { + "type": "string", + "description": "Original message ID.", + "example": "AAMk...", + }, + "comment": { + "type": "string", + "description": "Reply body.", + "example": "", + }, + }, + ), + _forward_outlook_email_op(), + client_op( + "create_outlook_reply_draft", + "create_reply_draft", + description=( + "Create a draft reply (pre-populated with quoted original). " + "Edit with update_outlook_draft, then send with " + "send_outlook_draft." + ), + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to create reply draft.", + input_schema={ + "message_id": { + "type": "string", + "description": "Original message ID.", + "example": "AAMk...", + }, + "comment": { + "type": "string", + "description": "Optional initial reply text.", + "example": "", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "comment": d.get("comment", ""), + }, + ), + client_op( + "create_outlook_forward_draft", + "create_forward_draft", + description=( + "Create a draft forward (pre-populated with quoted original). " + "Edit and send later." + ), + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to create forward draft.", + input_schema={ + "message_id": { + "type": "string", + "description": "Original message ID.", + "example": "AAMk...", + }, + "to_recipients": { + "type": "string", + "description": "Comma-separated recipient emails.", + "example": "", + }, + "comment": { + "type": "string", + "description": "Optional intro.", + "example": "", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "to_recipients": _csv_list(d.get("to_recipients", "")), + "comment": d.get("comment", ""), + }, + ), + client_op( + "create_outlook_draft", + "create_draft", + description=( + "Create a new email draft (not sent). Returns the draft_id " + "for later editing/sending." + ), + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to create draft.", + input_schema={ + "subject": { + "type": "string", + "description": "Subject.", + "example": "Quick question", + }, + "body": {"type": "string", "description": "Body.", "example": ""}, + "to": { + "type": "string", + "description": "Comma-separated recipients (optional).", + "example": "", + }, + "cc": { + "type": "string", + "description": "Comma-separated CC (optional).", + "example": "", + }, + "bcc": { + "type": "string", + "description": "Comma-separated BCC (optional).", + "example": "", + }, + "html": { + "type": "boolean", + "description": "Body is HTML.", + "example": False, + }, + }, + arg_map=lambda d: { + "subject": d["subject"], + "body": d["body"], + "to": _csv_list(d.get("to", ""), default=None), + "cc": _csv_list(d.get("cc", ""), default=None), + "bcc": _csv_list(d.get("bcc", ""), default=None), + "html": bool(d.get("html", False)), + }, + ), + client_op( + "update_outlook_draft", + "update_draft", + description="Edit a draft's subject/body/recipients before sending.", + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to update draft.", + input_schema={ + "message_id": { + "type": "string", + "description": "Draft ID.", + "example": "", + }, + "subject": { + "type": "string", + "description": "New subject (optional).", + "example": "", + }, + "body": { + "type": "string", + "description": "New body (optional).", + "example": "", + }, + "html": { + "type": "boolean", + "description": "Body is HTML.", + "example": False, + }, + "to": { + "type": "string", + "description": "New comma-separated recipients (optional, replaces).", + "example": "", + }, + "cc": { + "type": "string", + "description": "New CC (optional).", + "example": "", + }, + "bcc": { + "type": "string", + "description": "New BCC (optional).", + "example": "", + }, + }, + arg_map=_update_draft_args, + ), + client_op( + "send_outlook_draft", + "send_draft", + description="Send a previously-created draft.", + destructive=True, # outward-facing send (legacy irreversible) + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to send draft.", + input_schema={ + "message_id": { + "type": "string", + "description": "Draft ID.", + "example": "", + }, + }, + ), + client_op( + "delete_outlook_email", + "delete_message", + description=( + "Permanently delete a message. Use move_outlook_email to " + "deleteditems for a soft delete." + ), + destructive=True, # permanent delete + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to delete.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + }, + ), + client_op( + "move_outlook_email", + "move_message", + description=( + "Move a message to another folder. destination_folder_id can " + "be a well-known name (inbox, drafts, sentitems, " + "deleteditems, archive, junkemail) or a custom folder ID." + ), + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to move.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "destination_folder_id": { + "type": "string", + "description": "Folder ID or well-known name.", + "example": "archive", + }, + }, + ), + client_op( + "copy_outlook_email", + "copy_message", + description="Copy a message to another folder (original stays).", + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to copy.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "destination_folder_id": { + "type": "string", + "description": "Folder ID or well-known name.", + "example": "", + }, + }, + ), + client_op( + "mark_outlook_email_read", + "mark_as_read", + description="Mark an Outlook email as read.", + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + success_message="Email marked as read.", + fail_message="Failed to mark email.", + input_schema={ + "message_id": { + "type": "string", + "description": "Outlook message ID.", + "example": "AAMk...", + }, + }, + ), + client_op( + "mark_outlook_email_unread", + "mark_as_unread", + description="Mark an Outlook email as unread.", + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to mark unread.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + }, + ), + client_op( + "flag_outlook_email", + "flag_message", + description=( + "Set the flag status on an email. flag_status: notFlagged | " + "flagged | complete." + ), + parallelizable=False, + tags=("outlook_mail", "outlook"), + unwrap_envelope=True, + fail_message="Failed to flag.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "flag_status": { + "type": "string", + "description": "notFlagged, flagged, or complete.", + "example": "flagged", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "flag_status": d.get("flag_status", "flagged"), + }, + ), + client_op( + "set_outlook_email_categories", + "set_message_categories", + description=( + "Replace the categories on an Outlook message (use " + "list_outlook_categories to see available ones)." + ), + parallelizable=False, + tags=("outlook_mail",), + unwrap_envelope=True, + fail_message="Failed to set categories.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "categories": { + "type": "string", + "description": "Comma-separated category display names.", + "example": "Personal,Important", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "categories": _csv_list(d.get("categories", "")), + }, + ), + # ── Attachments ────────────────────────────────────────────────── + client_op( + "list_outlook_attachments", + "list_attachments", + description="List attachments on an Outlook message.", + tags=("outlook_attachments", "outlook"), + unwrap_envelope=True, + fail_message="Failed to list attachments.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + }, + ), + client_op( + "download_outlook_attachment", + "download_attachment", + description=( + "Download an attachment to a local path. Only works for " + "fileAttachment type." + ), + parallelizable=False, + tags=("outlook_attachments", "outlook"), + unwrap_envelope=True, + fail_message="Failed to download.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "attachment_id": { + "type": "string", + "description": "Attachment ID.", + "example": "", + }, + "save_to": { + "type": "string", + "description": "Local path to save to.", + "example": "C:/Users/me/downloads/file.pdf", + }, + }, + ), + client_op( + "add_outlook_attachment", + "add_attachment", + description="Attach a local file to a DRAFT message (under 3 MB).", + parallelizable=False, + tags=("outlook_attachments",), + unwrap_envelope=True, + fail_message="Failed to add attachment.", + input_schema={ + "message_id": { + "type": "string", + "description": "Draft message ID.", + "example": "", + }, + "file_path": { + "type": "string", + "description": "Absolute path to the local file.", + "example": "", + }, + "content_type": { + "type": "string", + "description": "MIME type (autodetect if omitted).", + "example": "", + }, + }, + arg_map=lambda d: { + "message_id": d["message_id"], + "file_path": d["file_path"], + "content_type": d.get("content_type") or None, + }, + ), + client_op( + "delete_outlook_attachment", + "delete_attachment", + description="Remove an attachment from a draft.", + destructive=True, # delete_* — flagged for uniform confirm behavior + parallelizable=False, + tags=("outlook_attachments",), + unwrap_envelope=True, + fail_message="Failed to delete attachment.", + input_schema={ + "message_id": { + "type": "string", + "description": "Message ID.", + "example": "", + }, + "attachment_id": { + "type": "string", + "description": "Attachment ID.", + "example": "", + }, + }, + ), + # ── Folders ────────────────────────────────────────────────────── + client_op( + "list_outlook_folders", + "list_folders", + description="List mail folders in Outlook.", + tags=("outlook_folders", "outlook"), + unwrap_envelope=True, + fail_message="Failed to list folders.", + input_schema={}, + ), + client_op( + "get_outlook_folder", + "get_folder", + description="Get metadata for a single mail folder (counts, parent).", + tags=("outlook_folders",), + unwrap_envelope=True, + fail_message="Failed to get folder.", + input_schema={ + "folder_id": { + "type": "string", + "description": "Folder ID or well-known name (inbox, drafts, sentitems, etc.).", + "example": "inbox", + }, + }, + ), + client_op( + "create_outlook_folder", + "create_folder", + description=( + "Create a new mail folder. Defaults to top-level (under " + "msgfolderroot)." + ), + parallelizable=False, + tags=("outlook_folders", "outlook"), + unwrap_envelope=True, + fail_message="Failed to create folder.", + input_schema={ + "display_name": { + "type": "string", + "description": "Folder name.", + "example": "Receipts", + }, + "parent_folder_id": { + "type": "string", + "description": "Parent folder ID or well-known name. Default msgfolderroot.", + "example": "msgfolderroot", + }, + }, + arg_map=lambda d: { + "display_name": d["display_name"], + "parent_folder_id": d.get("parent_folder_id", "msgfolderroot"), + }, + ), + client_op( + "update_outlook_folder", + "update_folder", + description="Rename a mail folder.", + parallelizable=False, + tags=("outlook_folders",), + unwrap_envelope=True, + fail_message="Failed to rename folder.", + input_schema={ + "folder_id": { + "type": "string", + "description": "Folder ID.", + "example": "", + }, + "display_name": { + "type": "string", + "description": "New name.", + "example": "", + }, + }, + ), + client_op( + "delete_outlook_folder", + "delete_folder", + description=( + "Delete a mail folder (and all messages in it). Cannot delete " + "well-known folders." + ), + destructive=True, # deletes the folder and every message in it + parallelizable=False, + tags=("outlook_folders",), + unwrap_envelope=True, + fail_message="Failed to delete folder.", + input_schema={ + "folder_id": { + "type": "string", + "description": "Folder ID.", + "example": "", + }, + }, + ), + client_op( + "list_outlook_child_folders", + "list_child_folders", + description="List child folders of a mail folder.", + tags=("outlook_folders",), + unwrap_envelope=True, + fail_message="Failed to list child folders.", + input_schema={ + "folder_id": { + "type": "string", + "description": "Parent folder ID or well-known name. Default msgfolderroot.", + "example": "msgfolderroot", + }, + }, + arg_map=lambda d: { + "folder_id": d.get("folder_id", "msgfolderroot"), + }, + ), + client_op( + "list_outlook_folder_messages", + "list_folder_messages", + description="List messages in a specific folder.", + tags=("outlook_folders", "outlook"), + unwrap_envelope=True, + fail_message="Failed to list messages.", + input_schema={ + "folder_id": { + "type": "string", + "description": "Folder ID or well-known name.", + "example": "inbox", + }, + "count": {"type": "integer", "description": "Max results.", "example": 25}, + "unread_only": { + "type": "boolean", + "description": "Filter to unread.", + "example": False, + }, + }, + arg_map=lambda d: { + "folder_id": d["folder_id"], + "n": d.get("count", 25), + "unread_only": bool(d.get("unread_only", False)), + }, + ), + # ── Mailbox settings + auto-replies + rules + categories ───────── + _get_outlook_mailbox_settings_op(), + _get_outlook_automatic_replies_op(), + client_op( + "update_outlook_automatic_replies", + "update_automatic_replies", + description=( + "Set out-of-office reply. status: disabled | alwaysEnabled | " + "scheduled. external_audience: none | contactsOnly | all." + ), + parallelizable=False, + tags=("outlook_settings", "outlook"), + unwrap_envelope=True, + fail_message="Failed to set auto-replies.", + input_schema={ + "status": { + "type": "string", + "description": "disabled, alwaysEnabled, or scheduled.", + "example": "alwaysEnabled", + }, + "internal_reply": { + "type": "string", + "description": "Reply text shown to internal senders (optional).", + "example": "Out of office until Friday.", + }, + "external_reply": { + "type": "string", + "description": "Reply text shown to external senders (optional).", + "example": "", + }, + "external_audience": { + "type": "string", + "description": "none, contactsOnly, or all.", + "example": "all", + }, + "scheduled_start": { + "type": "string", + "description": "ISO 8601 start (only for status=scheduled).", + "example": "", + }, + "scheduled_end": { + "type": "string", + "description": "ISO 8601 end (only for status=scheduled).", + "example": "", + }, + }, + arg_map=_update_automatic_replies_args, + ), + client_op( + "list_outlook_inbox_rules", + "list_inbox_rules", + description="List inbox rules (server-side mail rules).", + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to list rules.", + input_schema={}, + ), + client_op( + "create_outlook_inbox_rule", + "create_inbox_rule", + description=( + "Create an inbox rule. conditions and actions are Graph rule " + "objects — e.g. conditions={'fromAddresses': [{'emailAddress':" + " {'address': 'x@y.com'}}]}, actions={'moveToFolder': " + "''}." + ), + parallelizable=False, + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to create rule.", + input_schema={ + "display_name": { + "type": "string", + "description": "Rule name.", + "example": "From boss to Important", + }, + "conditions": { + "type": "object", + "description": "Graph messageRulePredicates object.", + "example": {}, + }, + "actions": { + "type": "object", + "description": "Graph messageRuleActions object.", + "example": {}, + }, + "sequence": { + "type": "integer", + "description": "Run order (lower runs first).", + "example": 1, + }, + "is_enabled": { + "type": "boolean", + "description": "Enable on create.", + "example": True, + }, + }, + arg_map=lambda d: { + "display_name": d["display_name"], + "conditions": d["conditions"], + "actions": d["actions"], + "sequence": d.get("sequence", 1), + "is_enabled": bool(d.get("is_enabled", True)), + }, + ), + client_op( + "delete_outlook_inbox_rule", + "delete_inbox_rule", + description="Delete an inbox rule.", + destructive=True, # permanent delete + parallelizable=False, + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to delete rule.", + input_schema={ + "rule_id": { + "type": "string", + "description": "Rule ID.", + "example": "", + }, + }, + ), + client_op( + "list_outlook_categories", + "list_categories", + description=( + "List the user's master categories (color-coded tags for " + "messages, calendar items, etc.)." + ), + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to list categories.", + input_schema={}, + ), + client_op( + "create_outlook_category", + "create_category", + description=( + "Create a master category. color: preset0..preset24 from " + "Graph categoryColor enum." + ), + parallelizable=False, + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to create category.", + input_schema={ + "display_name": { + "type": "string", + "description": "Category name.", + "example": "Personal", + }, + "color": { + "type": "string", + "description": "preset0..preset24.", + "example": "preset0", + }, + }, + arg_map=lambda d: { + "display_name": d["display_name"], + "color": d.get("color", "preset0"), + }, + ), + client_op( + "delete_outlook_category", + "delete_category", + description="Delete a master category.", + destructive=True, # permanent delete + parallelizable=False, + tags=("outlook_settings",), + unwrap_envelope=True, + fail_message="Failed to delete category.", + input_schema={ + "category_id": { + "type": "string", + "description": "Category ID.", + "example": "", + }, + }, + ), + ] diff --git a/craftos_integrations/providers/outlook/provider.py b/craftos_integrations/providers/outlook/provider.py new file mode 100644 index 00000000..3591c1ff --- /dev/null +++ b/craftos_integrations/providers/outlook/provider.py @@ -0,0 +1,216 @@ +"""Outlook provider — Microsoft Graph mail with rotating tokens. + +Reuses the battle-tested API surface of the legacy ``OutlookClient`` +unchanged and overrides only its credential plumbing with a binding mixin +(mirroring ``GoogleClientBinding``): the credential is injected per +account by ``build_client`` and never read from ``spec.cred_file`` (which +is single-account and would cross-wire secondaries). + +Unlike Slack, Outlook access tokens expire (~2h) and Microsoft *rotates* +refresh tokens, so the binding reimplements the legacy refresh but +persists through ``self._persist`` — the core routes the updated +credential to the right account entry. The legacy client's inline +``_ensure_token`` path picks up the overridden ``refresh_access_token`` +via MRO, so mid-operation refreshes also persist through the core. + +One account = one Microsoft account (email/UPN). OAuth parameters are +referenced from the legacy handler's ``OAuthFlow`` so the provider spec cannot +drift from it — except for the added account-chooser prompt below. +""" + +from __future__ import annotations + +import copy +import time +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.outlook import ( + MS_TOKEN_URL, + OUTLOOK_SCOPES, + OutlookClient, + OutlookCredential, + OutlookHandler, +) +from ...logger import get_logger +from .._shared import read_guidance +from .listener import OutlookListener +from .operations import build_operations + +logger = get_logger(__name__) + +_CRED_FIELDS = {f.name for f in fields(OutlookCredential)} + +# The chooser fix this port exists for: without ``prompt=select_account``, +# "Add account" silently re-auths whichever Microsoft account the browser +# is already signed into — the abandoned PR shipped without it and could +# never actually add a *second* Outlook account. ``response_mode=query`` +# is carried from the legacy handler. If ``select_account`` regresses +# token issuance for some tenant, that's a review conversation, never a +# silent drop. +OUTLOOK_AUTH_PARAMS = { + "response_mode": "query", + "prompt": "select_account", +} + + +class OutlookClientBinding: + """Overrides OutlookClient's disk plumbing: credential is injected per + account, refresh persists through the core. MRO puts this before the + legacy client: + + class BoundOutlookClient(OutlookClientBinding, OutlookClient): pass + """ + + _cred: Optional[OutlookCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = OutlookCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> OutlookCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + def refresh_access_token(self) -> Optional[str]: + """Legacy Outlook refresh, re-homed: same PKCE public-client token + request (no client_secret), but the refreshed credential goes to + ``self._persist`` instead of ``spec.cred_file``.""" + cred = self._load() + if not all([cred.client_id, cred.refresh_token]): + return None + result = http_request( + "POST", + MS_TOKEN_URL, + data={ + "client_id": cred.client_id, + "refresh_token": cred.refresh_token, + "grant_type": "refresh_token", + "scope": OUTLOOK_SCOPES, + }, + expected=(200,), + ) + if "error" in result: + logger.warning(f"[OUTLOOK] token refresh failed: {result['error']}") + return None + data = result["result"] + cred.access_token = data["access_token"] + # Microsoft rotates refresh tokens: persist the new one when + # issued, keep the old one when the response omits it. + cred.refresh_token = data.get("refresh_token", cred.refresh_token) + cred.token_expiry = time.time() + data.get("expires_in", 3600) - 60 + self._persist(asdict(cred)) + return cred.access_token + + +class BoundOutlookClient(OutlookClientBinding, OutlookClient): + """OutlookClient with per-account credential binding (see OutlookClientBinding).""" + + +class OutlookProvider: + id = "outlook" + display_name = "Outlook" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundOutlookClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """The account's email/UPN, lowercased. The legacy login stored + ``mail`` or ``userPrincipalName`` under ``email``; None for + credentials saved before that capture.""" + email = credential.get("email") + if isinstance(email, str) and email.strip(): + return email.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=OutlookHandler.oauth.auth_url, + token_url=OutlookHandler.oauth.token_url, + scopes=tuple(OUTLOOK_SCOPES.split()), + # prompt=select_account is load-bearing (see OUTLOOK_AUTH_PARAMS). + extra_authorize_params=OUTLOOK_AUTH_PARAMS, + has_chooser=True, + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Out-of-band refresh (listener wake-up etc.); operations normally + refresh inline via the binding.""" + holder: Dict[str, Any] = {} + client = self.build_client(credential, holder.update) + token = client.refresh_access_token() + return holder or None if token else None + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the legacy handler's OAuthFlow (same + PKCE public-client dance, localhost callback or host-injected + oauth_runner), with the chooser params applied: a *copy* of the + shared flow gets ``prompt=select_account`` (+ the carried + ``response_mode=query``) so "Add account" can add a *different* + Microsoft account — the shared handler instance is never mutated. + + Returns (identity, credential, message). Google-style refusal on a + missing identity — documented judgment call: Graph's ``/me`` with + the ``User.Read`` scope always returns a ``userPrincipalName`` when + the fetch succeeds, so an empty result means the userinfo call + itself failed; re-prompting beats storing an unaddressable account. + """ + from ...config import ConfigStore + + oauth = copy.copy(OutlookHandler.oauth) + oauth.extra_auth_params = dict(self.oauth_spec().extra_authorize_params) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"Outlook OAuth failed: {result['error']}" + info = result.get("userinfo") or {} + email = (info.get("mail") or info.get("userPrincipalName") or "").strip().lower() + if not email: + return None, None, ( + "Outlook sign-in completed but Microsoft Graph returned no " + "email/UPN — cannot store an unaddressable account. " + "Please try again." + ) + credential = asdict( + OutlookCredential( + 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("OUTLOOK_CLIENT_ID"), + email=email, + ) + ) + return email, credential, f"Outlook connected as {email}" + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + return read_guidance(__file__) + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> OutlookListener: + """Mailbox poll listener (legacy loop re-homed — see listener.py).""" + return OutlookListener(client, cursor, emit) diff --git a/craftos_integrations/providers/slack/GUIDANCE.md b/craftos_integrations/providers/slack/GUIDANCE.md new file mode 100644 index 00000000..e71ee0e4 --- /dev/null +++ b/craftos_integrations/providers/slack/GUIDANCE.md @@ -0,0 +1,43 @@ +# Slack + +Team messaging — send/edit messages, channels, threads, reactions, pins, +files, users, usergroups, bookmarks, reminders. Talks to Slack's Web API. + +## Multi-account +- One connected account = one Slack **workspace** (team). Every Slack + action accepts an optional `account` (team id, nickname, or a unique + fragment like "acme"). Omit it to use the primary workspace. +- When the user names a workspace in any form ("the client's Slack", + "our community workspace"), pass it as `account` — never silently + default to primary. +- Channel IDs, message timestamps (`ts`), user IDs, file IDs, and + usergroup IDs are **workspace-scoped**: an id returned by + `list_slack_channels` with `account="acme"` must be used with + `account="acme"` on every follow-up action (send/history/react/etc.). +- For destructive actions (delete message/file, kick user) with multiple + workspaces connected and no workspace named: ask the user which + workspace before acting. + +## Essentials +- **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. +- **Don't ask the user for workspace facts:** resolve team/channel/user + details with `get_slack_auth_info`, `get_slack_team_info`, + `get_slack_channel_info`, and `list_slack_users`. +- **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 (or `join_slack_channel`); don't + retry. +- **Some actions need a user token (`xoxp-`), not a bot token:** + `search_slack_messages` (search:read), reminders (reminders:write), + and `set_slack_user_presence`. With a bot token these return a Slack + error — report it, don't retry. diff --git a/craftos_integrations/providers/slack/__init__.py b/craftos_integrations/providers/slack/__init__.py new file mode 100644 index 00000000..2c9358a7 --- /dev/null +++ b/craftos_integrations/providers/slack/__init__.py @@ -0,0 +1,3 @@ +from .provider import SlackProvider + +__all__ = ["SlackProvider"] diff --git a/craftos_integrations/providers/slack/listener.py b/craftos_integrations/providers/slack/listener.py new file mode 100644 index 00000000..e52f3d97 --- /dev/null +++ b/craftos_integrations/providers/slack/listener.py @@ -0,0 +1,120 @@ +"""Slack listener — the legacy channel poll loop re-homed onto a bound client. + +The legacy ``SlackClient`` listens by *polling*, not Socket Mode: every +POLL_INTERVAL it walks the joined channels (``conversations.list``) and +fetches ``conversations.history`` newer than each channel's last-seen +``ts`` watermark, dispatching human messages (bot/self/subtype messages +filtered) — see ``integrations/slack/__init__.py``. All of that channel +walking and message filtering (``_get_joined_channels`` / +``_poll_channels`` / ``_process_message``) is inherited by +``BoundSlackClient`` and reused unchanged here. + +What could NOT be reused is the outer loop: the legacy ``_poll_loop`` +unconditionally runs a "catch-up" that stamps every channel's watermark to +*now* — correct for a fresh start (no backlog flood) but it would clobber +a persisted cursor on restart and drop everything received while the host +was down. So this class owns a small outer loop (same retry cadence as +legacy) and chooses at start: cursor present → seed ``_last_timestamps`` +from it; no cursor → run the legacy catch-up. Channels joined later are +picked up by the inherited ``_poll_channels`` (it stamps unknown channels +at now, same as legacy). + +One listener = one workspace (the account identity is the team id); bot +tokens don't expire, so there is no refresh plumbing. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +from ...integrations.slack import POLL_INTERVAL, RETRY_DELAY, _slack_acall +from ...logger import get_logger +from .._shared import EmitFn, emit_callback + +logger = get_logger(__name__) + + +class SlackListener: + """One Slack workspace poll loop for one bound account.""" + + def __init__( + self, client: Any, cursor: Optional[Dict[str, Any]], emit: EmitFn + ) -> None: + self._client = client + self._initial_cursor = dict(cursor) if cursor else None + self._emit = emit + self._task: Optional[asyncio.Task] = None + self.poll_interval: float = POLL_INTERVAL # legacy cadence (3s) + + async def start(self) -> None: + client = self._client + if client._listening: + return + client._message_callback = emit_callback(self._emit) + + # Same auth sanity check as the legacy start_listening: it both + # validates the bot token and captures the bot user id used to + # filter the bot's own messages out of the stream. + cred = client._load() + data = await _slack_acall( + "POST", "auth.test", {"Authorization": f"Bearer {cred.bot_token}"} + ) + if "error" in data: + raise RuntimeError(f"Invalid Slack token: {data['error']}") + client._bot_user_id = data.get("user_id") + logger.info(f"[SLACK] listener bot user ID: {client._bot_user_id}") + + saved = (self._initial_cursor or {}).get("last_timestamps") or {} + if saved: + # Resume: keep the per-channel ts watermarks so messages posted + # while we were down are still delivered (and nothing before + # the watermarks is replayed). + client._last_timestamps = {str(k): str(v) for k, v in saved.items()} + else: + # Fresh start: legacy catch-up — stamp every joined channel at + # "now" so history is not flooded into the agent. Failures are + # tolerated exactly like the legacy loop tolerated them. + try: + await client._refresh_channel_timestamps() + except Exception as e: + logger.error(f"[SLACK] Catchup error: {e}") + client._catchup_done = True + + client._listening = True + self._task = asyncio.create_task(self._loop()) + + async def _loop(self) -> None: + """Legacy ``_poll_loop`` minus the catch-up (handled in start()).""" + client = self._client + while client._listening: + try: + await client._poll_channels() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"[SLACK] Poll error: {e}") + await asyncio.sleep(RETRY_DELAY) + continue + await asyncio.sleep(self.poll_interval) + + async def stop(self) -> None: + client = self._client + if not client._listening: + return + client._listening = False + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + def cursor(self) -> Optional[Dict[str, Any]]: + timestamps = self._client._last_timestamps + if not timestamps: + # Never started (or no channels yet): hand back what we were + # given so a persisted cursor is never destroyed. + return self._initial_cursor + return {"last_timestamps": dict(timestamps)} diff --git a/craftos_integrations/providers/slack/operations.py b/craftos_integrations/providers/slack/operations.py new file mode 100644 index 00000000..a4e2fedc --- /dev/null +++ b/craftos_integrations/providers/slack/operations.py @@ -0,0 +1,1688 @@ +"""Slack operations — ported from the legacy slack_actions.py schemas. + +Complete port of app/data/action/integrations/slack/slack_actions.py — +all 60 actions, same names/descriptions/schemas/arg mapping. No operation +declares an ``account`` input (conformance-enforced; the host injects it). + +Porting notes: +- Legacy ``irreversible=True`` (send_slack_message, send_slack_ephemeral) + → ``destructive=True``; permanent deletes/removes are also flagged + destructive per the conformance rule (delete/remove-named operations). +- Legacy actions used ``run_client``'s default envelope handling; the + Slack client returns either the raw Slack body (with ``ok`` alongside + payload fields — collapsed by ``shape_result``) or ``{error, details}`` + — so ``client_op`` defaults match legacy behavior exactly. +- ``pick_result`` / lean-shaping post-processing is reproduced verbatim + via fn-wrapping (same pattern as gmail's lean operations). + +The legacy file's "intentionally NOT exposed" list carries over +unchanged: Events API/RTM/Socket Mode plumbing, views.*/interactions.*, +canvases/lists, admin.*/scim, dnd.*, deprecated surfaces (stars, +dialog.*, chat.unfurl) were never actions and stay out. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Callable, Dict, List + +from ...contracts import Operation +from .._shared import client_op + +_STATUS = {"status": {"type": "string", "example": "success"}} + + +# ──────────────────────────────────────────────────────────────────────── +# Post-processing helpers (legacy pick_result / lean shaping, verbatim) +# ──────────────────────────────────────────────────────────────────────── + + +def _with_post( + base: Operation, + post: Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]], +) -> Operation: + """Wrap an operation's fn with a (result, input_data) post-processor.""" + inner = base.fn + + async def fn(client: Any, input_data: Dict[str, Any]) -> Dict[str, Any]: + return post(await inner(client, input_data), input_data) + + return replace(base, fn=fn) + + +def _pick(keys: List[str]): + """Legacy ``pick_result``: reduce a successful result to named keys.""" + + def post(res: Dict[str, Any], _input: Dict[str, Any]) -> Dict[str, Any]: + if res.get("status") == "success" and isinstance(res.get("result"), dict): + r = res["result"] + picked = {k: r.get(k) for k in keys if r.get(k) is not None} + if picked: + res = {**res, "result": picked} + return res + + return post + + +def _lean_message(m: dict) -> dict: + out = {"user": m.get("user"), "text": m.get("text"), "ts": m.get("ts")} + if m.get("thread_ts"): + out["thread_ts"] = m["thread_ts"] + if m.get("reply_count") is not None: + out["reply_count"] = m["reply_count"] + if m.get("subtype"): + out["subtype"] = m["subtype"] + if m.get("reactions"): + out["reactions"] = [ + {"name": r.get("name"), "count": r.get("count")} + for r in m["reactions"] + if isinstance(r, dict) + ] + return out + + +def _lean_messages(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + lean = { + "messages": [ + _lean_message(m) + for m in body.get("messages", []) or [] + if isinstance(m, dict) + ] + } + if body.get("has_more"): + lean["has_more"] = True + return {**res, "result": lean} + + +def _lean_channels(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(c: dict) -> dict: + out = { + "id": c.get("id"), + "name": c.get("name"), + "is_private": c.get("is_private"), + "is_archived": c.get("is_archived"), + "num_members": c.get("num_members"), + "topic": (c.get("topic") or {}).get("value"), + "purpose": (c.get("purpose") or {}).get("value"), + } + if "is_member" in c: + out["is_member"] = c.get("is_member") + return out + + lean = { + "channels": [ + _lean(c) for c in body.get("channels", []) or [] if isinstance(c, dict) + ] + } + cursor = (body.get("response_metadata") or {}).get("next_cursor") + if cursor: + lean["next_cursor"] = cursor + return {**res, "result": lean} + + +def _lean_users(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + + def _lean(m: dict) -> dict: + profile = m.get("profile") or {} + out = { + "id": m.get("id"), + "name": m.get("name"), + "real_name": m.get("real_name") or profile.get("real_name"), + "display_name": profile.get("display_name"), + "email": profile.get("email"), + "is_bot": m.get("is_bot"), + "tz": m.get("tz"), + "deleted": m.get("deleted"), + } + if "is_admin" in m: + out["is_admin"] = m.get("is_admin") + return out + + lean = { + "members": [ + _lean(m) for m in body.get("members", []) or [] if isinstance(m, dict) + ] + } + cursor = (body.get("response_metadata") or {}).get("next_cursor") + if cursor: + lean["next_cursor"] = cursor + return {**res, "result": lean} + + +def _lean_files(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict): + return res + lean = { + "files": [ + { + "id": f.get("id"), + "name": f.get("name"), + "title": f.get("title"), + "mimetype": f.get("mimetype"), + "size": f.get("size"), + "created": f.get("created"), + "user": f.get("user"), + "permalink": f.get("permalink"), + } + for f in body.get("files", []) or [] + if isinstance(f, dict) + ] + } + if isinstance(body.get("paging"), dict): + lean["paging"] = body["paging"] + return {**res, "result": lean} + + +def _lean_search(res: Dict[str, Any], input_data: Dict[str, Any]) -> Dict[str, Any]: + if input_data.get("include_metadata") or res.get("status") != "success": + return res + body = res.get("result") + if not isinstance(body, dict) or not isinstance(body.get("messages"), dict): + return res + msgs = body["messages"] + + def _lean(m: dict) -> dict: + ch = m.get("channel") or {} + out = { + "user": m.get("user"), + "text": m.get("text"), + "ts": m.get("ts"), + "channel": {"id": ch.get("id"), "name": ch.get("name")}, + "permalink": m.get("permalink"), + } + if m.get("thread_ts"): + out["thread_ts"] = m["thread_ts"] + return out + + lean = { + "total": msgs.get("total"), + "matches": [ + _lean(m) for m in msgs.get("matches", []) or [] if isinstance(m, dict) + ], + } + return {**res, "result": lean} + + +# ──────────────────────────────────────────────────────────────────────── +# Operations +# ──────────────────────────────────────────────────────────────────────── + + +def build_operations() -> List[Operation]: + return [ + # ── Messages — post / update / delete / ephemeral / schedule / + # permalink / threads ────────────────────────────────────────── + _with_post( + client_op( + "send_slack_message", + "send_message", + description=( + "Send a message to a Slack channel or DM. Pass thread_ts " + "to reply in a thread." + ), + destructive=True, # legacy irreversible — outward-facing send + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID or name.", + "example": "C01234567", + }, + "text": { + "type": "string", + "description": "Message text.", + "example": "Hello team!", + }, + "thread_ts": { + "type": "string", + "description": "Optional thread timestamp for replies.", + "example": "", + }, + }, + output_schema={ + **_STATUS, + "result": { + "type": "object", + "description": "{channel, ts} of the posted message.", + }, + }, + arg_map=lambda d: { + "recipient": d["channel"], + "text": d["text"], + "thread_ts": d.get("thread_ts"), + }, + ), + _pick(["channel", "ts"]), + ), + _with_post( + client_op( + "update_slack_message", + "update_message", + description=( + "Edit a previously-sent Slack message. ts is the " + "timestamp returned when posting." + ), + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "ts": { + "type": "string", + "description": "Timestamp of the message to edit.", + "example": "1234567890.123456", + }, + "text": { + "type": "string", + "description": "New text (optional).", + "example": "", + }, + "blocks": { + "type": "array", + "description": "New Block Kit blocks (optional).", + "example": [], + }, + }, + output_schema={ + **_STATUS, + "result": { + "type": "object", + "description": "{channel, ts} of the edited message.", + }, + }, + arg_map=lambda d: { + "channel": d["channel"], + "ts": d["ts"], + "text": d["text"] if "text" in d else None, + "blocks": d["blocks"] if "blocks" in d else None, + }, + ), + _pick(["channel", "ts"]), + ), + client_op( + "delete_slack_message", + "delete_message", + description="Delete a Slack message.", + destructive=True, # permanent delete + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "ts": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + }, + ), + _with_post( + client_op( + "send_slack_ephemeral", + "post_ephemeral", + description=( + "Send an ephemeral message visible only to one user in a " + "channel." + ), + destructive=True, # legacy irreversible — outward-facing send + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "user": { + "type": "string", + "description": "User ID who will see the message.", + "example": "U12345", + }, + "text": { + "type": "string", + "description": "Message text.", + "example": "", + }, + "blocks": { + "type": "array", + "description": "Block Kit blocks (optional).", + "example": [], + }, + "thread_ts": { + "type": "string", + "description": "Reply in a thread (optional).", + "example": "", + }, + }, + output_schema={ + **_STATUS, + "result": { + "type": "object", + "description": "{message_ts} of the ephemeral message.", + }, + }, + arg_map=lambda d: { + "channel": d["channel"], + "user": d["user"], + "text": d["text"], + "blocks": d["blocks"] if "blocks" in d else None, + "thread_ts": d.get("thread_ts") or None, + }, + ), + _pick(["channel", "message_ts"]), + ), + _with_post( + client_op( + "schedule_slack_message", + "schedule_message", + description=( + "Schedule a Slack message to be sent at a future time. " + "post_at is a Unix timestamp." + ), + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "post_at": { + "type": "integer", + "description": "Unix timestamp when to send.", + "example": 0, + }, + "text": { + "type": "string", + "description": "Message text.", + "example": "", + }, + "blocks": { + "type": "array", + "description": "Block Kit blocks (optional).", + "example": [], + }, + "thread_ts": { + "type": "string", + "description": "Optional thread reply.", + "example": "", + }, + }, + output_schema={ + **_STATUS, + "result": { + "type": "object", + "description": "{scheduled_message_id, channel, post_at}.", + }, + }, + arg_map=lambda d: { + "channel": d["channel"], + "post_at": d["post_at"], + "text": d["text"], + "blocks": d["blocks"] if "blocks" in d else None, + "thread_ts": d.get("thread_ts") or None, + }, + ), + _pick(["scheduled_message_id", "channel", "post_at"]), + ), + client_op( + "delete_scheduled_slack_message", + "delete_scheduled_message", + description="Cancel a previously-scheduled Slack message.", + destructive=True, # cancels a pending send + parallelizable=False, + tags=("slack_messages",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "scheduled_message_id": { + "type": "string", + "description": ( + "Scheduled message ID (from schedule_slack_message " + "response)." + ), + "example": "", + }, + }, + ), + client_op( + "list_scheduled_slack_messages", + "list_scheduled_messages", + description="List the bot's pending scheduled messages.", + tags=("slack_messages",), + input_schema={ + "channel": { + "type": "string", + "description": "Filter to one channel (optional).", + "example": "", + }, + "limit": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + }, + arg_map=lambda d: { + "channel": d.get("channel") or None, + "limit": d.get("limit", 100), + }, + ), + client_op( + "get_slack_message_permalink", + "get_permalink", + description="Get a shareable permalink URL for a Slack message.", + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "message_ts": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + }, + ), + _with_post( + client_op( + "get_slack_thread_replies", + "get_thread_replies", + description=( + "Get all messages in a Slack thread (the parent + all " + "replies). Lean messages (user, text, ts, thread_ts, " + "reply_count, reactions) by default; include_metadata=true " + "returns full raw messages (blocks, team, bot_profile, ...)." + ), + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "ts": { + "type": "string", + "description": "Parent message timestamp (thread_ts).", + "example": "", + }, + "limit": { + "type": "integer", + "description": "Max messages.", + "example": 100, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean messages. True: full raw.", + "example": False, + }, + }, + arg_map=lambda d: { + "channel": d["channel"], + "ts": d["ts"], + "limit": d.get("limit", 100), + }, + ), + _lean_messages, + ), + # ── Reactions ───────────────────────────────────────────────────── + client_op( + "add_slack_reaction", + "add_reaction", + description=( + "Add an emoji reaction to a Slack message. name is the emoji " + "code without colons (e.g. 'thumbsup', 'eyes')." + ), + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "timestamp": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + "name": { + "type": "string", + "description": "Emoji name without colons.", + "example": "thumbsup", + }, + }, + ), + client_op( + "remove_slack_reaction", + "remove_reaction", + description="Remove an emoji reaction from a Slack message.", + destructive=True, # remove-named (conformance rule) + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "timestamp": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + "name": { + "type": "string", + "description": "Emoji name without colons.", + "example": "thumbsup", + }, + }, + ), + client_op( + "get_slack_reactions", + "get_reactions", + description="Get all reactions on a Slack message.", + tags=("slack_messages",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "timestamp": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + }, + ), + client_op( + "list_slack_user_reactions", + "list_user_reactions", + description="List messages a user has reacted to.", + tags=("slack_messages",), + input_schema={ + "user": { + "type": "string", + "description": "User ID (optional, defaults to auth'd user).", + "example": "", + }, + "count": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + }, + arg_map=lambda d: { + "user": d.get("user") or None, + "count": d.get("count", 100), + }, + ), + # ── Pins ────────────────────────────────────────────────────────── + client_op( + "pin_slack_message", + "pin_message", + description="Pin a message to a Slack channel.", + parallelizable=False, + tags=("slack_messages", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "timestamp": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + }, + ), + client_op( + "unpin_slack_message", + "unpin_message", + description="Unpin a message from a Slack channel.", + parallelizable=False, + tags=("slack_messages",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "timestamp": { + "type": "string", + "description": "Message timestamp.", + "example": "", + }, + }, + ), + client_op( + "list_slack_pins", + "list_pins", + description="List pinned items in a Slack channel.", + tags=("slack_messages",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + # ── Conversations — list/info/create/invite/open/archive/rename/ + # topic/members ────────────────────────────────────────────────── + _with_post( + client_op( + "list_slack_channels", + "list_channels", + description=( + "List channels in the Slack workspace. Lean channels (id, " + "name, is_private, is_archived, is_member, num_members, " + "topic, purpose) by default; include_metadata=true returns " + "full raw channel objects." + ), + tags=("slack_conversations", "slack"), + input_schema={ + "limit": { + "type": "integer", + "description": "Max channels to return.", + "example": 100, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean channels. True: full raw.", + "example": False, + }, + }, + output_schema={ + **_STATUS, + "channels": {"type": "array"}, + }, + arg_map=lambda d: {"limit": d.get("limit", 100)}, + ), + _lean_channels, + ), + client_op( + "get_slack_channel_info", + "get_channel_info", + description="Get info about a Slack channel.", + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C1234567", + }, + }, + ), + _with_post( + client_op( + "get_slack_channel_history", + "get_channel_history", + description=( + "Get message history from a Slack channel. Lean messages " + "(user, text, ts, thread_ts, reply_count, reactions) by " + "default; include_metadata=true returns full raw messages " + "(blocks, team, bot_profile, ...)." + ), + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C01234567", + }, + "limit": { + "type": "integer", + "description": "Max messages.", + "example": 50, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean messages. True: full raw.", + "example": False, + }, + }, + output_schema={ + **_STATUS, + "messages": {"type": "array"}, + }, + arg_map=lambda d: { + "channel": d["channel"], + "limit": d.get("limit", 50), + }, + ), + _lean_messages, + ), + client_op( + "list_slack_channel_members", + "list_channel_members", + description="List members of a Slack channel.", + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "limit": { + "type": "integer", + "description": "Max members.", + "example": 100, + }, + "cursor": { + "type": "string", + "description": "Pagination cursor.", + "example": "", + }, + }, + arg_map=lambda d: { + "channel": d["channel"], + "limit": d.get("limit", 100), + "cursor": d.get("cursor") or None, + }, + ), + client_op( + "create_slack_channel", + "create_channel", + description="Create a new Slack channel.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "name": { + "type": "string", + "description": "Channel name.", + "example": "project-alpha", + }, + "is_private": { + "type": "boolean", + "description": "Is private?", + "example": False, + }, + }, + arg_map=lambda d: { + "name": d["name"], + "is_private": d.get("is_private", False), + }, + ), + client_op( + "invite_to_slack_channel", + "invite_to_channel", + description="Invite users to a Slack channel.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "C1234567", + }, + "users": { + "type": "array", + "description": "List of user IDs.", + "example": ["U123"], + }, + }, + ), + client_op( + "open_slack_dm", + "open_dm", + description="Open a DM with Slack users.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "users": { + "type": "array", + "description": "List of user IDs.", + "example": ["U123"], + }, + }, + ), + client_op( + "archive_slack_channel", + "archive_channel", + description="Archive a Slack channel.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + client_op( + "unarchive_slack_channel", + "unarchive_channel", + description="Unarchive a previously-archived Slack channel.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + client_op( + "rename_slack_channel", + "rename_channel", + description="Rename a Slack channel.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "name": { + "type": "string", + "description": "New channel name.", + "example": "", + }, + }, + ), + client_op( + "set_slack_channel_topic", + "set_channel_topic", + description="Set a Slack channel's topic.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "topic": { + "type": "string", + "description": "New topic.", + "example": "", + }, + }, + ), + client_op( + "set_slack_channel_purpose", + "set_channel_purpose", + description="Set a Slack channel's purpose / description.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "purpose": { + "type": "string", + "description": "New purpose.", + "example": "", + }, + }, + ), + client_op( + "join_slack_channel", + "join_channel", + description="Have the bot join a Slack channel.", + parallelizable=False, + tags=("slack_conversations", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + client_op( + "leave_slack_channel", + "leave_channel", + description="Have the bot leave a Slack channel.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + client_op( + "kick_user_from_slack_channel", + "kick_user", + description="Remove a user from a Slack channel.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "user": { + "type": "string", + "description": "User ID.", + "example": "", + }, + }, + ), + client_op( + "close_slack_conversation", + "close_conversation", + description="Close a DM, MPDM, or private channel.", + parallelizable=False, + tags=("slack_conversations",), + input_schema={ + "channel": { + "type": "string", + "description": "Conversation ID.", + "example": "", + }, + }, + ), + # ── Files ───────────────────────────────────────────────────────── + client_op( + "upload_slack_file", + "upload_file_v2", + description=( + "Upload a local file to Slack using the modern 3-step " + "files.getUploadURLExternal flow. Optionally share into a " + "channel + post initial comment." + ), + parallelizable=False, + tags=("slack_files", "slack"), + input_schema={ + "file_path": { + "type": "string", + "description": "Absolute path to local file.", + "example": "C:/Users/me/report.pdf", + }, + "channel_id": { + "type": "string", + "description": "Channel ID to share into (optional).", + "example": "C01234567", + }, + "initial_comment": { + "type": "string", + "description": "Message text with the file (optional).", + "example": "", + }, + "title": { + "type": "string", + "description": "File title (optional).", + "example": "", + }, + "thread_ts": { + "type": "string", + "description": "Reply in a thread (optional).", + "example": "", + }, + "filename": { + "type": "string", + "description": "Override filename (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "file_path": d["file_path"], + "channel_id": d.get("channel_id") or None, + "initial_comment": d.get("initial_comment") or None, + "title": d.get("title") or None, + "thread_ts": d.get("thread_ts") or None, + "filename": d.get("filename") or None, + }, + ), + _with_post( + client_op( + "list_slack_files", + "list_files", + description=( + "List files in the workspace (optionally filter by " + "channel, user, or types like 'images,zips'). Lean files " + "(id, name, title, mimetype, size, created, user, " + "permalink) by default; include_metadata=true returns full " + "raw file objects (thumbnails, share info, ...)." + ), + tags=("slack_files", "slack"), + input_schema={ + "channel": { + "type": "string", + "description": "Filter to channel (optional).", + "example": "", + }, + "user": { + "type": "string", + "description": "Filter to user (optional).", + "example": "", + }, + "types": { + "type": "string", + "description": ( + "Comma-separated types: all, spaces, snippets, " + "images, gdocs, zips, pdfs (optional)." + ), + "example": "", + }, + "count": { + "type": "integer", + "description": "Max results.", + "example": 100, + }, + "page": { + "type": "integer", + "description": "Page number.", + "example": 1, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean files. True: full raw.", + "example": False, + }, + }, + arg_map=lambda d: { + "channel": d.get("channel") or None, + "user": d.get("user") or None, + "types": d.get("types") or None, + "count": d.get("count", 100), + "page": d.get("page", 1), + }, + ), + _lean_files, + ), + client_op( + "get_slack_file_info", + "get_file_info", + description=( + "Get metadata for a Slack file (name, size, URL, channels " + "shared into)." + ), + tags=("slack_files", "slack"), + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "F0123ABC", + }, + }, + ), + client_op( + "delete_slack_file", + "delete_file", + description="Delete a Slack file. Irreversible.", + destructive=True, # permanent delete + parallelizable=False, + tags=("slack_files",), + input_schema={ + "file_id": { + "type": "string", + "description": "File ID.", + "example": "", + }, + }, + ), + # ── Users + usergroups + presence ───────────────────────────────── + _with_post( + client_op( + "list_slack_users", + "list_users", + description=( + "List users in the Slack workspace. Lean members (id, " + "name, real_name, display_name, email, is_bot, is_admin, " + "tz, deleted) by default; include_metadata=true returns " + "full raw user objects (avatar URLs, full profile, ...)." + ), + tags=("slack_users", "slack"), + input_schema={ + "limit": { + "type": "integer", + "description": "Max users to return.", + "example": 100, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean members. True: full raw.", + "example": False, + }, + }, + output_schema={ + **_STATUS, + "users": {"type": "array"}, + }, + arg_map=lambda d: {"limit": d.get("limit", 100)}, + ), + _lean_users, + ), + client_op( + "get_slack_user_info", + "get_user_info", + description="Get info about a Slack user.", + tags=("slack_users", "slack"), + input_schema={ + "slack_user_id": { + "type": "string", + "description": "User ID.", + "example": "U1234567", + }, + }, + arg_map=lambda d: {"user_id": d["slack_user_id"]}, + ), + client_op( + "lookup_slack_user_by_email", + "lookup_user_by_email", + description="Resolve a Slack user by their email address.", + tags=("slack_users", "slack"), + input_schema={ + "email": { + "type": "string", + "description": "Email address.", + "example": "alice@example.com", + }, + }, + ), + client_op( + "get_slack_user_presence", + "get_user_presence", + description=( + "Check whether a Slack user is online (active) or offline " + "(away)." + ), + tags=("slack_users",), + input_schema={ + "user": { + "type": "string", + "description": "User ID.", + "example": "", + }, + }, + ), + client_op( + "set_slack_user_presence", + "set_user_presence", + description=( + "Set the authenticated user's presence (requires user token " + "xoxp-, not bot token)." + ), + parallelizable=False, + tags=("slack_users",), + input_schema={ + "presence": { + "type": "string", + "description": "auto or away.", + "example": "auto", + }, + }, + ), + client_op( + "list_slack_usergroups", + "list_usergroups", + description="List Slack usergroups (@team mentions) in the workspace.", + tags=("slack_users", "slack"), + input_schema={ + "include_disabled": { + "type": "boolean", + "description": "Include disabled groups.", + "example": False, + }, + "include_count": { + "type": "boolean", + "description": "Include member counts.", + "example": False, + }, + "include_users": { + "type": "boolean", + "description": "Include user list per group.", + "example": False, + }, + }, + arg_map=lambda d: { + "include_disabled": bool(d.get("include_disabled", False)), + "include_count": bool(d.get("include_count", False)), + "include_users": bool(d.get("include_users", False)), + }, + ), + client_op( + "create_slack_usergroup", + "create_usergroup", + description="Create a new Slack usergroup.", + parallelizable=False, + tags=("slack_users",), + input_schema={ + "name": { + "type": "string", + "description": "Group name (e.g. 'Marketing').", + "example": "", + }, + "handle": { + "type": "string", + "description": "Handle without @ (optional).", + "example": "", + }, + "description": { + "type": "string", + "description": "Description (optional).", + "example": "", + }, + "channels": { + "type": "array", + "description": "Default channels (optional).", + "example": [], + }, + }, + arg_map=lambda d: { + "name": d["name"], + "handle": d.get("handle") or None, + "description": d.get("description") or None, + "channels": d.get("channels") or None, + }, + ), + client_op( + "update_slack_usergroup", + "update_usergroup", + description="Update a Slack usergroup's name/handle/description/channels.", + parallelizable=False, + tags=("slack_users",), + input_schema={ + "usergroup": { + "type": "string", + "description": "Usergroup ID.", + "example": "", + }, + "name": { + "type": "string", + "description": "New name (optional).", + "example": "", + }, + "handle": { + "type": "string", + "description": "New handle (optional).", + "example": "", + }, + "description": { + "type": "string", + "description": "New description (optional).", + "example": "", + }, + "channels": { + "type": "array", + "description": "New default channels (optional).", + "example": [], + }, + }, + arg_map=lambda d: { + "usergroup": d["usergroup"], + "name": d["name"] if "name" in d else None, + "handle": d["handle"] if "handle" in d else None, + "description": d["description"] if "description" in d else None, + "channels": d["channels"] if "channels" in d else None, + }, + ), + client_op( + "list_slack_usergroup_users", + "list_usergroup_users", + description="List the users in a Slack usergroup.", + tags=("slack_users",), + input_schema={ + "usergroup": { + "type": "string", + "description": "Usergroup ID.", + "example": "", + }, + "include_disabled": { + "type": "boolean", + "description": "Include disabled users.", + "example": False, + }, + }, + arg_map=lambda d: { + "usergroup": d["usergroup"], + "include_disabled": bool(d.get("include_disabled", False)), + }, + ), + client_op( + "set_slack_usergroup_users", + "update_usergroup_users", + description="REPLACE the members of a Slack usergroup.", + parallelizable=False, + tags=("slack_users",), + input_schema={ + "usergroup": { + "type": "string", + "description": "Usergroup ID.", + "example": "", + }, + "users": { + "type": "array", + "description": "List of user IDs to set as members.", + "example": [], + }, + }, + ), + client_op( + "enable_slack_usergroup", + "enable_usergroup", + description="Enable a previously-disabled Slack usergroup.", + parallelizable=False, + tags=("slack_users",), + input_schema={ + "usergroup": { + "type": "string", + "description": "Usergroup ID.", + "example": "", + }, + }, + ), + client_op( + "disable_slack_usergroup", + "disable_usergroup", + description=( + "Disable a Slack usergroup (keeps it but hides from " + "autocomplete)." + ), + parallelizable=False, + tags=("slack_users",), + input_schema={ + "usergroup": { + "type": "string", + "description": "Usergroup ID.", + "example": "", + }, + }, + ), + # ── Workspace: auth / team / search / bookmarks / reminders ─────── + client_op( + "get_slack_auth_info", + "auth_test", + description=( + "Get info about the authenticated Slack bot/user (team, user, " + "bot_id)." + ), + tags=("slack_workspace", "slack"), + input_schema={}, + ), + client_op( + "get_slack_team_info", + "get_team_info", + description=( + "Get info about the Slack workspace (team name, domain, icon)." + ), + tags=("slack_workspace", "slack"), + input_schema={ + "team": { + "type": "string", + "description": "Team ID (optional, defaults to current).", + "example": "", + }, + }, + arg_map=lambda d: {"team": d.get("team") or None}, + ), + _with_post( + client_op( + "search_slack_messages", + "search_messages", + description=( + "Search for messages in the Slack workspace (requires " + "user token / search:read). Lean matches (user, text, ts, " + "channel {id, name}, permalink) by default; " + "include_metadata=true returns full raw matches (blocks, " + "score, pagination, ...)." + ), + tags=("slack_workspace", "slack"), + input_schema={ + "query": { + "type": "string", + "description": "Search query.", + "example": "project update", + }, + "count": { + "type": "integer", + "description": "Max results.", + "example": 20, + }, + "include_metadata": { + "type": "boolean", + "description": "False (default): lean matches. True: full raw.", + "example": False, + }, + }, + arg_map=lambda d: { + "query": d["query"], + "count": d.get("count", 20), + }, + ), + _lean_search, + ), + client_op( + "list_slack_bookmarks", + "list_bookmarks", + description="List bookmarks pinned to a Slack channel.", + tags=("slack_workspace", "slack"), + input_schema={ + "channel_id": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + }, + ), + client_op( + "add_slack_bookmark", + "add_bookmark", + description="Add a bookmark to a Slack channel.", + parallelizable=False, + tags=("slack_workspace", "slack"), + input_schema={ + "channel_id": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "title": { + "type": "string", + "description": "Bookmark title.", + "example": "Project doc", + }, + "type": { + "type": "string", + "description": "Bookmark type (link).", + "example": "link", + }, + "link": { + "type": "string", + "description": "URL (for type=link).", + "example": "", + }, + "emoji": { + "type": "string", + "description": "Emoji shortcode (optional).", + "example": ":bookmark:", + }, + }, + arg_map=lambda d: { + "channel_id": d["channel_id"], + "title": d["title"], + "type": d.get("type", "link"), + "link": d.get("link") or None, + "emoji": d.get("emoji") or None, + }, + ), + client_op( + "edit_slack_bookmark", + "edit_bookmark", + description="Edit an existing channel bookmark.", + parallelizable=False, + tags=("slack_workspace",), + input_schema={ + "channel_id": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "bookmark_id": { + "type": "string", + "description": "Bookmark ID.", + "example": "", + }, + "title": { + "type": "string", + "description": "New title (optional).", + "example": "", + }, + "link": { + "type": "string", + "description": "New URL (optional).", + "example": "", + }, + "emoji": { + "type": "string", + "description": "New emoji (optional).", + "example": "", + }, + }, + arg_map=lambda d: { + "channel_id": d["channel_id"], + "bookmark_id": d["bookmark_id"], + "title": d["title"] if "title" in d else None, + "link": d["link"] if "link" in d else None, + "emoji": d["emoji"] if "emoji" in d else None, + }, + ), + client_op( + "remove_slack_bookmark", + "remove_bookmark", + description="Delete a channel bookmark.", + destructive=True, # permanent delete + parallelizable=False, + tags=("slack_workspace",), + input_schema={ + "channel_id": { + "type": "string", + "description": "Channel ID.", + "example": "", + }, + "bookmark_id": { + "type": "string", + "description": "Bookmark ID.", + "example": "", + }, + }, + ), + client_op( + "add_slack_reminder", + "add_reminder", + description=( + "Add a Slack reminder. time can be a Unix timestamp or " + "natural-language ('in 15 minutes'). Requires user token " + "(xoxp-) — bot tokens can't create reminders." + ), + parallelizable=False, + tags=("slack_workspace", "slack"), + input_schema={ + "text": { + "type": "string", + "description": "Reminder text.", + "example": "Send the weekly report", + }, + "time": { + "type": "string", + "description": ( + "Unix timestamp OR natural-language ('in 15 minutes')." + ), + "example": "in 15 minutes", + }, + "user": { + "type": "string", + "description": "User ID (optional, defaults to self).", + "example": "", + }, + }, + arg_map=lambda d: { + "text": d["text"], + "time": d["time"], + "user": d.get("user") or None, + }, + ), + client_op( + "list_slack_reminders", + "list_reminders", + description="List the authenticated user's Slack reminders.", + tags=("slack_workspace",), + input_schema={}, + ), + client_op( + "get_slack_reminder", + "get_reminder_info", + description="Get info about a single Slack reminder.", + tags=("slack_workspace",), + input_schema={ + "reminder": { + "type": "string", + "description": "Reminder ID.", + "example": "", + }, + }, + ), + client_op( + "complete_slack_reminder", + "complete_reminder", + description="Mark a Slack reminder as complete.", + parallelizable=False, + tags=("slack_workspace",), + input_schema={ + "reminder": { + "type": "string", + "description": "Reminder ID.", + "example": "", + }, + }, + ), + client_op( + "delete_slack_reminder", + "delete_reminder", + description="Delete a Slack reminder.", + destructive=True, # permanent delete + parallelizable=False, + tags=("slack_workspace",), + input_schema={ + "reminder": { + "type": "string", + "description": "Reminder ID.", + "example": "", + }, + }, + ), + ] diff --git a/craftos_integrations/providers/slack/provider.py b/craftos_integrations/providers/slack/provider.py new file mode 100644 index 00000000..b4970c1b --- /dev/null +++ b/craftos_integrations/providers/slack/provider.py @@ -0,0 +1,174 @@ +"""Slack provider — the first non-Google multi-account provider. + +Establishes the non-Google binding pattern: reuse the battle-tested API +surface of the legacy ``SlackClient`` unchanged, and override only its +credential plumbing with a small binding mixin (mirroring +``GoogleClientBinding``): the credential is injected per account by +``build_client`` and never read from ``spec.cred_file`` (which is +single-account and would cross-wire secondaries). + +Slack bot tokens do not expire, so there is no refresh path: the binding +has no ``refresh_access_token`` and ``refresh()`` returns None (the +contract's "non-expiring" signal). ``persist`` is still accepted and +stored for contract symmetry — future providers with rotating tokens +(Outlook, HubSpot) call it exactly like the Google binding does. + +One account = one Slack **workspace**; identity is the team id from the +credential (lowercased). OAuth parameters are referenced from the legacy +handler's ``OAuthFlow`` so the provider spec can never drift from it. +""" + +from __future__ import annotations + +import copy +from dataclasses import asdict, fields +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...integrations.slack import SLACK_SCOPES, SlackClient, SlackCredential, SlackHandler +from .listener import SlackListener +from .operations import build_operations + +_CRED_FIELDS = {f.name for f in fields(SlackCredential)} + + +class SlackClientBinding: + """Overrides SlackClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundSlackClient(SlackClientBinding, SlackClient): pass + + No token refresh — Slack bot tokens are non-expiring, so ``_persist`` + is never called (kept so the build_client contract is uniform across + providers). + """ + + _cred: Optional[SlackCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = SlackCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> SlackCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundSlackClient(SlackClientBinding, SlackClient): + """SlackClient with per-account credential binding (see SlackClientBinding).""" + + +class SlackProvider: + id = "slack" + display_name = "Slack" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundSlackClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Slack team (workspace) id, lowercased. None for pre-multi-account raw-token + credentials saved before the team id was captured.""" + team_id = credential.get("workspace_id") + if isinstance(team_id, str) and team_id.strip(): + return team_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec( + authorize_url=SlackHandler.oauth.auth_url, + token_url=SlackHandler.oauth.token_url, + scopes=tuple(s for s in SLACK_SCOPES.split(",") if s), + # Slack's authorize page always shows a workspace picker — no + # extra params needed to add a *different* workspace. + has_chooser=True, + ) + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # Slack bot tokens are non-expiring + + async def run_login(self) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]: + """Full add-account flow via the legacy handler's OAuthFlow — the + machinery behind the legacy ``invite()`` subcommand (HTTPS + localhost callback, ``oauth.v2.access`` exchange; the bot token + and team metadata arrive in the raw token response, Slack has no + OAuthFlow userinfo endpoint). The raw-bot-token ``login()`` path + is host UI territory and is not ported here. + + A *copy* of the shared flow gets the provider spec's + ``extra_authorize_params`` applied (empty — Slack's authorize + page always shows its own workspace picker); the shared handler + instance is never mutated. + + Returns (identity, credential, message). Identity is computed by + ``identity_of`` (team id). When Slack returns no team id the + credential is returned with identity None — the core stores it + under LEGACY_IDENTITY and upgrades it in place on the next + re-auth. + """ + oauth = copy.copy(SlackHandler.oauth) + oauth.extra_auth_params = dict(self.oauth_spec().extra_authorize_params) + result = await oauth.run() + if "error" in result and not result.get("access_token"): + return None, None, f"Slack OAuth failed: {result['error']}" + raw = result.get("raw") or {} + # Slack signals failure with HTTP 200 + ok:false — same check as + # the legacy invite(). + if not raw.get("ok"): + return None, None, f"Slack OAuth token exchange failed: {raw.get('error')}" + + bot_token = raw.get("access_token", "") + team = raw.get("team") or {} + team_id = team.get("id", "") + team_name = team.get("name", team_id) + credential = asdict( + SlackCredential( + bot_token=bot_token, + workspace_id=team_id, + team_name=team_name, + ) + ) + identity = self.identity_of(credential) + message = f"Slack connected via CraftOS app: {team_name} ({team_id})" + if not identity: + message = ( + "Slack connected, but no team id was returned — stored as " + "the legacy account until the next re-auth." + ) + return identity, credential, message + + def operations(self) -> List[Operation]: + return build_operations() + + def guidance(self) -> str: + path = Path(__file__).parent / "GUIDANCE.md" + try: + return path.read_text(encoding="utf-8") + except OSError: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> SlackListener: + """Workspace poll listener (legacy loop re-homed — see listener.py).""" + return SlackListener(client, cursor, emit) diff --git a/docs/plans/multi-account-v2-plan.md b/docs/plans/multi-account-v2-plan.md new file mode 100644 index 00000000..14073ddb --- /dev/null +++ b/docs/plans/multi-account-v2-plan.md @@ -0,0 +1,471 @@ +# Integrations v2 — Composable, Host-Agnostic Integration System with Multi-Account Support + +**Status:** Approved direction — decisions locked in §15 +**Target base:** `V1.4.2` — new branch `feature/integrations-v2`, built from scratch +**Origin:** Issue #368 (multi-account). PR #370 is abandoned; this design does not +reuse its architecture (condensed pitfalls checklist in §14). + +--- + +## 1. Goals + +1. **Multi-account:** each integration holds **one primary account plus any + number of additional accounts**, each with an optional user alias; every + agent operation takes an optional `account` selector; Settings UI manages + add/rename/switch-primary/disconnect. +2. **Composition:** the integration system is a **self-contained, + host-agnostic package**. Individual integrations are plugins ("providers") + that register themselves; the whole package can be mounted into a different + agent — or exposed over MCP — without touching CraftBot code. CraftBot is + simply the first host. +3. **Listener fan-out:** inbound event sources (Gmail/Outlook polling, Slack + events) run **per account**, not just for the primary — with a per-account + on/off toggle in the UI (§8). + +**Providers in scope (10):** Gmail, Google Calendar, Google Drive, Google Docs, +YouTube, Outlook, LinkedIn, Notion, HubSpot, Slack. Existing other +integrations keep working unchanged during the transition (§12). + +**Out of scope:** the chat-questionnaire subsystem (unrelated feature, own +issue). + +--- + +## 2. Composition architecture (ports & adapters) + +``` +craftos_integrations/ # ZERO imports from app/ or agent_core/ + contracts.py # every Protocol the package speaks + core/ + accounts.py # AccountSet: primary + N accounts (§4) + storage.py # CredentialStore backends (file default) + oauth.py # generic OAuth engine (host supplies transport) + registry.py # provider + client instance registry + listeners.py # ListenerManager: per-account fan-out (§8) + guidance.py # assembles agent guidance from providers + providers/ + gmail/ + provider.py # implements Provider + operations.py # Operation descriptors (the "actions", neutral) + GUIDANCE.md # provider prompt guidance (host-agnostic wording) + outlook/ … slack/ # one folder per provider, self-registering + hosts/ + mcp/server.py # later: whole package as an MCP server +CraftBot side (the host adapter — the ONLY CraftBot-specific code): + app/data/action/integrations/craftbot_adapter.py + app/ui_layer/... settings handlers # UI ops via IntegrationSystem (§6) +``` + +### The contracts (`contracts.py`) + +What a **provider** implements: + +```python +class Provider(Protocol): + id: str # "gmail" + family: str | None # "google" → shared aliases (§4) + def identity_of(self, credential: dict) -> str | None + def oauth_spec(self) -> OAuthSpec # urls, scopes, chooser params (§7) + def build_client(self, credential: dict) -> Any + def refresh(self, credential: dict) -> dict | None # None = non-expiring + def operations(self) -> list[Operation] + def guidance(self) -> str # contents of GUIDANCE.md + def make_listener(self, client, cursor: dict | None) -> Listener | None + # one instance PER listening account (§8) +``` + +```python +@dataclass(frozen=True) +class Operation: # a framework-neutral "action" + name: str # "send_gmail" + description: str + input_schema: dict # JSON-Schema properties (NO account key here) + output_schema: dict + fn: Callable[[Any, dict], Awaitable[dict]] # (client, input) -> result + destructive: bool = False # hosts may confirm/guard these + tags: tuple[str, ...] = () +``` + +What a **host** implements: + +```python +class OAuthTransport(Protocol): # how a redirect/callback physically happens + async def authorize(self, url: str) -> CallbackParams # CraftBot: local server + browser + +class CredentialStore(Protocol): # where AccountSets + listener cursors persist + def load(self, provider_id) -> dict | None + def replace(self, provider_id, data) -> None # atomic + def locked(self, provider_id) -> ContextManager # RMW lock + +class EventSink(Protocol): # where listener events go (host trigger system) + async def on_event(self, provider_id: str, identity: str, event: dict) -> None +``` + +The package ships a filesystem `CredentialStore` (the default, §5) and a +loopback `OAuthTransport`; a different agent can inject keyring/DB storage, +its own OAuth UX, and its own event routing without forking the package. + +### The single host-facing entry point + +```python +class IntegrationSystem: # what any agent embeds + def __init__(self, store, oauth, sink: EventSink | None = None, providers=DEFAULT) + # capability discovery + def providers(self) -> list[ProviderInfo] + def operations(self, provider_id=None) -> list[Operation] + def guidance(self, connected_only=True) -> str # for system prompts + # execution — multi-account handled HERE, uniformly + async def execute(self, provider_id, op_name, input: dict, account: str | None = None) -> dict + # account management (drives any settings UI) + def list_accounts(pid) / resolve(pid, hint) + async def add_account(pid) # runs OAuth via transport, upserts by identity + def set_alias(pid, hint, alias) / set_primary(pid, hint) / remove_account(pid, hint) + def set_listening(pid, hint, on: bool) + async def apply_account_changes(pid, batch) -> AccountList # UI batched save (§10) + # listeners + async def start_listeners(self) / stop_listeners(self) # host lifecycle hooks +``` + +**Why this solves multi-account better than per-action edits:** `execute()` +resolves `account → identity → client` once, centrally. Providers and their +operations never see account selection — they receive a ready client. The host +adapter advertises the `account` input on every generated action schema in one +line of code. There is no way to "forget" it on 80 of 290 actions (the failure +that made the old PR dangerous), and a `destructive=True` flag lets hosts add +confirm-or-clarify behavior uniformly. + +### Host adapters + +- **CraftBot adapter** (`craftbot_adapter.py`): iterates + `system.operations()`, generates one `@action` wrapper per Operation — + schema = `input_schema` + injected `account` property, execution = + `system.execute(...)`, errors mapped to the standard + `{"status": "error", "message": ...}` self-correction dict. INTEGRATION.md + essentials come from `system.guidance()`. Implements `EventSink` by mapping + events into CraftBot's trigger system with account context (§8). ~250 lines + total, replacing ~10 hand-maintained action files. +- **MCP host** (later): the same `operations()` list exposed as MCP tools, + `guidance()` as MCP resources/prompts, account management as tools. This is + the "plug the whole system into a different agent" story with an + industry-standard socket — any MCP-capable agent gets all 10 integrations, + multi-account included, for free. Aligns with the DONUT agent-agnostic + direction. + +Rules that keep it composable (CI-enforced, §11): +- `craftos_integrations/` may not import from `app/` or `agent_core/` + (import-linter contract in CI). +- Providers may not import each other or the host; they self-register via the + package registry on import. +- All host-visible behavior goes through `contracts.py` types. + +--- + +## 3. What changes vs. today's repo layout + +| Today | v2 | +|---|---| +| `app/data/action/integrations/_actions.py` — ~290 hand-written `@action` defs | generated by the CraftBot adapter from Operation descriptors | +| `craftos_integrations/integrations//__init__.py` — login/status/logout + client, imports app config | `providers//` — Provider impl + operations, host-blind | +| INTEGRATION.md essentials scattered per integration | `GUIDANCE.md` per provider, assembled by `guidance()` (connected-aware) | +| UI adapter calls integration functions directly | UI calls `IntegrationSystem` account-management API | +| one bare credential file per integration | one `AccountSet` document per provider (§5) | +| listeners hardwired to the single account | `ListenerManager` fan-out per listening account (§8) | + +Migration strategy for the other (non-scoped) integrations: they stay on the +old path untouched; the old and new registries coexist behind the current +`service.py` facade until each is ported (§12). Nothing breaks mid-transition. + +--- + +## 4. Account model + +One **AccountSet** document per provider: + +``` +{ version: 2, + primary: "a@x.com", # pointer — always valid, self-repairing + accounts: { + "a@x.com": {credential: {...}, alias: "work", listen: true, added_at: ...}, + "b@y.com": {credential: {...}, alias: "school", listen: true, added_at: ...} } } +``` + +- **Identity** = provider-stable key (email / workspace id / hub id / team id), + lowercase, from `Provider.identity_of`. +- **Primary is a pointer, not a copy** — two primaries structurally impossible; + dangling pointer repaired on load (oldest account, logged). +- **Aliases live in the account record** — no separate store to corrupt/leak. + Uniqueness enforced per family at set-time. `family="google"` propagates an + alias to the same identity across all five Google AccountSets (lazy + consistency sweep on read heals partial writes). +- **`listen`** — whether this account's inbound listener runs (§8). Defaults + `true` for every account ("connected means fully connected"); per-account + toggle in the Manage modal. + +**Resolution contract** for `account` hints (agents and UI both): +1. empty → primary +2. exact identity match (case-insensitive) — identity always outranks alias +3. exact alias match +4. unique substring of identity or alias +5. ambiguous → `AccountResolutionError` listing candidates +6. no match → `AccountResolutionError` listing connected accounts +Errors enumerate valid choices so the LLM self-corrects. Non-string hints are +rejected at the boundary — nothing unhashable reaches a cache key. + +--- + +## 5. Storage (default filesystem backend) + +- Same paths as today (`/credentials/gmail.json`) — the v2 wrapper + migrates a legacy bare credential on first load (idempotent, invisible). + Identity-less legacy credentials (old LinkedIn/Notion) get sentinel identity + `"legacy"` and are upgraded in place on next re-auth — never duplicated. +- **Atomic writes only:** tmp file (0600) + `os.replace`; read-modify-write + under an advisory `flock` (token refresh vs UI edit can't interleave). +- Corrupt file → quarantine as `.corrupt`, log loudly, provider reads as + disconnected. Never a silent `{}`, never a parse error escaping the API. +- Dir `0700`, files `0600`, enforced at every write. +- **Listener cursors** (per-account poll state, §8) persist separately from + credentials: `/credentials/_cursors/.json`, keyed by + identity — losing a cursor is harmless (worst case: one duplicate or missed + poll window), so they're excluded from the AccountSet's stronger guarantees. + +Client instances cached by `(provider_id, resolved_identity)` — resolution +happens **before** the cache, so alias spellings share one client and bad +hints never pollute the cache. `remove_account` / `set_primary` / `set_alias` +invalidate affected entries (alias changes re-point routing immediately). + +Token refresh: provider's `refresh()` result is written back via a locked RMW +of that one account entry. + +--- + +## 6. Multi-account UX spec + +- `check_integration_status` → per-provider `accounts` array + `{identity, alias, isPrimary, listen}`, plus a shared status text format + `- {alias or identity} ({identity}) [primary]` — formatted once in core, + impossible to drift per provider. +- Add account → real OAuth with account chooser (§7), applies immediately. +- Rename / set-primary / disconnect / listen-toggle → staged in the UI, + batched on save (§10). +- Removing the primary promotes the oldest remaining account and reports it. +- Removing the last account = plain disconnect, **uniform across all 10 + providers** (HubSpot's legacy stop-the-platform special case is dropped; + PR 2 verifies normal cache invalidation covers whatever it was masking). +- Disconnect deletes credentials **locally only** (today's semantics). + Provider-side token revocation is a flagged follow-up — it needs + Google-family awareness first (revoking one Google token can kill the whole + grant, i.e. disconnecting Gmail could break Calendar/Drive/Docs/YouTube for + that account). + +--- + +## 7. Provider specifics + +| Provider | Identity | Add-account chooser | Refresh | Listener | Notes | +|---|---|---|---|---|---| +| Gmail | `email` (userinfo) | `prompt=consent select_account` | yes (google mixin) | poller | reject empty-email userinfo (re-prompt) | +| Calendar / Drive / Docs / YouTube | `email` (userinfo) | same | yes | none | | +| Outlook | `email`/UPN | `prompt=select_account` — must ship | yes | poller | | +| LinkedIn | `email`, fallback `sub` | none exists in LinkedIn OAuth | yes (~60d) | none | UI copy: "log out of linkedin.com first to add a different account"; no fictitious params | +| Notion | workspace/bot id | native workspace picker | no | none | legacy token-only files → `"legacy"` sentinel | +| HubSpot | hub id | provider chooser | yes | none | last-logout unified (§6) | +| Slack | team id | provider-side picker | no | event listener | one connection per listening team | + +`OAuthSpec` carries these per-provider params declaratively; `core/oauth.py` +runs the flow through the host's `OAuthTransport`. + +--- + +## 8. Listener fan-out + +**Model:** one `Listener` instance per `(provider, account)` where +`listen=true` and the provider has inbound events (Gmail/Outlook pollers, +Slack event connection). Managed centrally by `core/listeners.py +ListenerManager`; providers only implement `make_listener(client, cursor)`. + +1. **Reconciliation:** the manager diffs desired state (AccountSets × + `listen` flags) against running instances — on account add/remove, + listen-toggle, or credential change it starts/stops exactly the affected + instance. Called on startup, after every `apply_account_changes`, and + after OAuth completion. +2. **Event tagging:** every event is delivered as + `sink.on_event(provider_id, identity, event)`. The CraftBot adapter + injects account context into the trigger payload so the agent (and the + user) can see *which* account fired: "New email in school Gmail + (b@y.com)". Trigger-driven replies then pass `account=` back + into operations — reply-from-the-right-mailbox falls out naturally. +3. **Per-account cursors:** poll state (last-seen ids/timestamps) is keyed by + identity (§5) — two Gmail accounts never share dedup state. Legacy + single-account cursor migrates to the primary's key on first run. +4. **Quota hygiene:** pollers for the same provider are staggered + (`stagger = interval / instance_count`) so N accounts don't burst + simultaneously; per-instance backoff on 429/5xx so one throttled account + doesn't stall the others. +5. **Failure isolation:** a listener crash-loop (e.g. revoked credential) + disables that instance after K consecutive failures, marks the account's + status ("listening paused — reconnect to resume"), and never affects other + accounts' listeners. +6. **Defaults:** `listen: true` for all accounts, primary included. The user + turns noise off per account in the Manage modal rather than discovering + that a connected account silently doesn't trigger. + +--- + +## 9. Agent guidance & prompts + +- `system.guidance(connected_only=True)` assembles provider GUIDANCE.md + sections for **connected** providers — replacing `_integration_essentials`'s + hardcoded keyword table. Keyword seeding (for just-in-time injection) + matches on **word boundaries** (`\bcalendar\b` — no "doctor"/"docker"/ + "driver" false-positives) and comes from provider metadata, not a central + hardcoded dict. +- Routing prompt (`agent_core/core/prompts/action.py`, written against + V1.4.2's structure): extract account qualifiers from natural language into + `account`; relay resolver errors verbatim (they list options); for + `destructive=True` operations with multiple accounts and no qualifier, ask + instead of defaulting to primary. +- AGENT.md: "every integration action accepts optional `account`" — true by + construction (adapter-injected). Document per-account listening and the + LinkedIn add-account caveat. Fix the pre-existing + `check_integration_status("google")` umbrella trap. + +--- + +## 10. Frontend — Manage modal (V1.4.2 session-native) + +UX: the integration card opens a Manage modal listing accounts (alias, +identity, primary badge, listen toggle). Edits — rename, set primary, +disconnect, listen on/off — are **staged locally** and committed on "Save +changes"; closing discards. "Add account" launches the real OAuth flow and +applies immediately. + +1. **Request correlation** — client `requestId` echoed in results; no + wall-clock timers; broadcasts from other tabs update data only. +2. **No side-effect modal opens** — only explicit user clicks open it. +3. **Staged-state lifecycle** — reset on every close path; pruned when + accounts vanish from refreshed lists; primary badge falls back to the real + primary. +4. **One batched save** — a single `integration_apply_account_changes` + request (server applies disconnects → primary → aliases → listen flags + inside the storage lock, then reconciles listeners); response carries the + final account list; on failure staged edits are kept and the error shown. +5. **Reliable transport** — saves use the queued/outbox send path; user input + is never dropped behind an `isConnected` guard. +6. Types: `accounts: [{identity, alias, isPrimary, listen}]` added to + integration status/info payloads in `app/ui_layer/components/types.py` ↔ + frontend `types/index.ts`, per session-native wire conventions. No + chat-component or chat-storage changes. + +--- + +## 11. Testing & CI + +1. **Accounts core** (pure, tmpdir): migration idempotency + `"legacy"` + upgrade-in-place; every resolution rule (identity-beats-alias, ambiguity, + non-string hints); alias uniqueness + family propagation + cleanup on + removal; primary repair / oldest-promotion / no-side-effects-on-failed- + remove; injected-crash atomicity; lock serialization; corruption + quarantine. +2. **Contracts conformance suite** — a reusable test class run against *every* + provider: `identity_of` on captured credential fixtures, `oauth_spec` + completeness (chooser params present unless explicitly declared + unsupported), every Operation's schema is valid JSON-Schema and + `destructive` set on anything named delete/clear/remove/revoke. New + providers inherit the suite — the plug-and-play quality gate. +3. **ListenerManager** (fake providers, fake clock): reconciliation + starts/stops exactly the right instances on add/remove/toggle; per-account + cursor isolation + legacy cursor migration; stagger + backoff; K-failure + disable isolates one account; events arrive tagged with the right + identity. +4. **Adapter test** — every generated CraftBot action has the injected + `account` property and routes through `execute()`; resolution errors come + back as the standard error dict, never a traceback; trigger payloads carry + account context. +5. **Isolation gate** — import-linter: `craftos_integrations` imports nothing + from `app/`/`agent_core/`; providers import neither hosts nor each other. + Plus `python -m compileall` + import-every-module, and `tsc --noEmit` + (cheap gates; their absence let a syntactically broken branch sit green for + a month). +6. **Manual matrix:** Google with two real accounts (add/alias/switch/ + disconnect + cross-account 403/404 isolation); Outlook, LinkedIn, Notion, + HubSpot, Slack against real accounts; two-account Gmail listener test + (event fires from the non-primary account, reply goes out from that + account); live conversational test ("my school calendar" routes correctly; + destructive ambiguity triggers a question). + +--- + +## 12. Delivery plan + +Branch `feature/integrations-v2` off `V1.4.2`. Old and new systems coexist +behind the current `service.py` facade until cut-over; non-scoped +integrations stay on the old path indefinitely. + +1. **PR 1 — Package skeleton + accounts core:** `contracts.py`, `core/*` + (AccountSet incl. `listen` field, storage, registry, oauth engine), + conformance suite, isolation gate. No user-visible change. (~1.5 days) +2. **PR 2 — Providers:** the 10 providers implemented against `Provider` + (client code largely portable from the existing integrations), OAuth + chooser params, GUIDANCE.md files, legacy sentinel upgrades, HubSpot + logout unification. Manual OAuth verification per provider lands here. + (~2 days + verification — the long pole) +3. **PR 3 — CraftBot adapter + prompts:** generated actions replace the 10 + hand-written action files, `_helpers` routing through `execute()`, + guidance/essentials rewiring, routing-prompt + AGENT.md updates, adapter + test. (~1 day) +4. **PR 4 — Frontend:** Manage modal (request-correlated batched saves incl. + listen toggles), type plumbing. (~1 day) +5. **PR 5 — Listener fan-out:** `ListenerManager`, Gmail/Outlook/Slack + listener ports to per-account instances, cursor migration, trigger + account-context in the adapter, failure isolation. (~1.5–2 days) +6. **PR 6 (later) — MCP host:** expose the system as an MCP server. + +Note the ordering: PRs 1–4 ship multi-account with listeners still effectively +primary-only (the `listen` flag exists but the manager isn't live); PR 5 turns +fan-out on. Each PR leaves the app fully working. + +--- + +## 13. Why composition + the AccountSet model reinforce each other + +- Account selection implemented **once** in `execute()` — not 290 times in + action files. The old PR's worst bug (destructive actions missing the + `account` param and silently hitting primary) is impossible by construction. +- Multi-account — outbound *and* inbound — arrives for **every current and + future provider** the moment it implements `Provider`; listeners need only + `make_listener`, and fan-out/stagger/failure-isolation come from the + manager. +- A different agent embeds `IntegrationSystem(store, oauth, sink)` — or + speaks MCP to it — and gets integrations, multi-account, aliases, listeners, + and guidance without any CraftBot code. + +## 14. Pitfalls checklist (from the abandoned PR's review — each has a regression test) + +- filename-collision credential overwrites → *no per-account filenames* +- non-atomic multi-file promote/remove losing tokens → *single-document atomic writes* +- alias shadowing a real identity; duplicate aliases → *§4 rules 2–3 + set-time uniqueness* +- stale cached clients after alias/primary changes → *§5 invalidation* +- cache keyed by raw hint → duplicate clients → *resolve-first caching* +- resolution errors escaping as tracebacks (incl. non-string hints) → *adapter error mapping* +- partial `account` coverage on destructive actions → *central injection + adapter test* +- missing (Outlook) or fictitious (LinkedIn) OAuth chooser params → *§7 + conformance suite* +- identity-less legacy credentials duplicating on re-auth → *`"legacy"` sentinel* +- corrupt store read as "no accounts" → *quarantine + loud log* +- substring keyword false-positives ("doctor", "hard drive") → *word-boundary matching* +- secondary accounts silently never triggering (undocumented primary-only + listeners) → *fan-out by default + visible listen toggle* +- UI: wall-clock save timers, broadcast-opened modals, staged edits surviving + close or wiped before results, unqueued sends dropping input → *§10* +- no compile/import CI → *§11.5 gates* + +## 15. Decisions (resolved 2026-08-10) + +1. **Listeners: build fan-out now** — per-account listener instances with + `listen` toggle, `ListenerManager`, account-tagged triggers (§8; PR 5). +2. **HubSpot last-logout: unified** on plain disconnect; PR 2 verifies cache + invalidation covers what the platform-stop was masking. +3. **Disconnect: local-delete only** (today's semantics). Provider-side + revocation deferred until Google-family-aware revoke logic exists. +4. **Packaging: in-tree** with the CI isolation gate; extract to a separate + distribution when a second consumer (MCP host / another agent) exists. diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integrations/conformance.py b/tests/integrations/conformance.py new file mode 100644 index 00000000..24843d87 --- /dev/null +++ b/tests/integrations/conformance.py @@ -0,0 +1,138 @@ +"""Provider conformance suite — the plug-and-play quality gate. + +Every provider gets these checks by subclassing: + + class TestGmailConformance(ProviderConformance): + provider = GmailProvider() + credential_fixtures = [ # captured real-shape credentials + {"email": "User@X.com", "access_token": "..."}, + ] + +The suite enforces the contract rules that made the abandoned PR +dangerous when they were left to diligence: + - operations never declare their own ``account`` input (central + injection would silently collide), + - destructive-looking operations are flagged ``destructive``, + - a provider without an OAuth account chooser must say so explicitly + AND explain the add-account workaround in its guidance. +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any, Dict, List + +import pytest + +# Reversible verbs (trash, archive) are deliberately absent — the flag +# exists for operations a wrong-account mistake can't undo. +DESTRUCTIVE_HINTS = re.compile( + r"(^|_)(delete|clear|remove|revoke|destroy|cancel)(_|$)" +) +VALID_OP_NAME = re.compile(r"^[a-z][a-z0-9_]*$") + + +class ProviderConformance: + provider: Any = None # subclass sets this + credential_fixtures: List[Dict[str, Any]] = [] + + # ── identity ───────────────────────────────────────────────────────── + + def test_provider_id_shape(self): + assert self.provider.id and VALID_OP_NAME.match(self.provider.id) + + def test_identity_of_fixtures_is_lowercase_stable(self): + assert self.credential_fixtures, ( + "Provide at least one captured credential fixture — identity " + "extraction is the root of every multi-account guarantee." + ) + for fixture in self.credential_fixtures: + identity = self.provider.identity_of(fixture) + if identity is not None: + assert identity == identity.lower(), ( + f"identity_of must return lowercase, got {identity!r}" + ) + assert identity.strip() == identity and identity != "" + + def test_identity_of_tolerates_junk(self): + # Must never raise on malformed input — it runs during migration. + assert self.provider.identity_of({}) is None or isinstance( + self.provider.identity_of({}), str + ) + + # ── oauth ──────────────────────────────────────────────────────────── + + def test_oauth_spec_urls(self): + spec = self.provider.oauth_spec() + assert spec.authorize_url.startswith("https://") + assert spec.token_url.startswith("https://") + + def test_missing_chooser_is_declared_and_documented(self): + spec = self.provider.oauth_spec() + if not spec.has_chooser: + guidance = self.provider.guidance().lower() + assert "account" in guidance, ( + f"{self.provider.id} declares no OAuth account chooser — its " + "guidance must explain how a user adds a different account " + "(e.g. LinkedIn: log out of linkedin.com first)." + ) + + # ── operations ─────────────────────────────────────────────────────── + + def test_operation_names_unique_and_snake_case(self): + names = [op.name for op in self.provider.operations()] + assert len(names) == len(set(names)), "duplicate operation names" + for name in names: + assert VALID_OP_NAME.match(name), f"bad operation name: {name}" + + def test_operations_never_declare_account_input(self): + # ``account`` is injected centrally by host adapters; a provider + # declaring its own would silently collide with the injected one. + for op in self.provider.operations(): + assert "account" not in op.input_schema, ( + f"{op.name} declares 'account' in its input_schema — remove " + "it; account selection is handled by IntegrationSystem." + ) + + def test_operation_schemas_are_well_formed(self): + for op in self.provider.operations(): + assert op.description.strip(), f"{op.name} has no description" + for schema in (op.input_schema, op.output_schema): + assert isinstance(schema, dict) + for key, value in schema.items(): + assert isinstance(value, dict) and "type" in value, ( + f"{op.name}.{key} schema entry must be a dict with a " + f"'type' (got {value!r})" + ) + + def test_destructive_operations_are_flagged(self): + unflagged = [ + op.name + for op in self.provider.operations() + if DESTRUCTIVE_HINTS.search(op.name) and not op.destructive + ] + assert not unflagged, ( + f"Operations that look destructive but aren't flagged " + f"destructive=True: {unflagged}. Hosts use this flag for " + "confirm-or-clarify on ambiguous multi-account requests." + ) + + def test_operation_fns_are_async(self): + for op in self.provider.operations(): + assert asyncio.iscoroutinefunction(op.fn), f"{op.name}.fn not async" + + # ── guidance / listener ────────────────────────────────────────────── + + def test_guidance_is_text(self): + assert isinstance(self.provider.guidance(), str) + + def test_make_listener_signature(self): + # None (no inbound events) is fine; raising is not. ``emit`` is the + # account-bound async event callable the core hands every listener. + async def emit(event: Dict[str, Any]) -> None: # no-op + pass + + result = self.provider.make_listener(object(), None, emit) + if result is not None: + assert hasattr(result, "start") and hasattr(result, "stop") diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py new file mode 100644 index 00000000..b12e9698 --- /dev/null +++ b/tests/integrations/conftest.py @@ -0,0 +1,52 @@ +"""Shared fixtures for the integrations core tests. + +Everything runs against a FileCredentialStore rooted in tmp_path — no +ConfigStore monkeypatching, no global state. +""" + +from __future__ import annotations + +import itertools + +import pytest + +from craftos_integrations.core.accounts import AccountManager +from craftos_integrations.core.storage import FileCredentialStore + +GOOGLE_FAMILY = ("gmail", "google_calendar") + + +def _family(pid: str): + return GOOGLE_FAMILY if pid in GOOGLE_FAMILY else (pid,) + + +@pytest.fixture +def store(tmp_path): + return FileCredentialStore(root=tmp_path) + + +@pytest.fixture +def clock(): + """Deterministic, strictly increasing timestamps.""" + counter = itertools.count(1) + return lambda: f"2026-08-10T00:00:{next(counter):02d}+00:00" + + +@pytest.fixture +def mgr(store, clock): + return AccountManager(store, family_members=_family, clock=clock) + + +def cred(identity: str, **extra): + """A synthetic credential blob.""" + return {"email": identity, "access_token": f"tok-{identity}", **extra} + + +@pytest.fixture +def two_accounts(mgr): + """gmail with a@x.com (primary, alias 'work') and b@y.com (alias 'school').""" + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("gmail", "b@y.com", cred("b@y.com")) + mgr.set_alias("gmail", "a@x.com", "work") + mgr.set_alias("gmail", "b@y.com", "school") + return mgr diff --git a/tests/integrations/test_calendar_provider.py b/tests/integrations/test_calendar_provider.py new file mode 100644 index 00000000..1c318e59 --- /dev/null +++ b/tests/integrations/test_calendar_provider.py @@ -0,0 +1,124 @@ +"""Google Calendar provider — conformance + one end-to-end wiring check. + +No network: the client API method is stubbed. What's real is the chain +execute() → resolve → bind → client method → shaped (lean) result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.google_calendar import GoogleCalendarProvider +from craftos_integrations.providers.google_calendar.provider import ( + BoundGoogleCalendarClient, +) + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +GOOGLE_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "client_secret": "csec", + "email": "a@x.com", +} + + +class TestCalendarConformance(ProviderConformance): + provider = GoogleCalendarProvider() + credential_fixtures = [ + GOOGLE_CRED, + {"access_token": "at", "email": " User@X.com "}, # messy legacy shape + {"access_token": "at"}, # identity-less pre-multi-account shape → None + ] + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), + providers=[GoogleCalendarProvider()], + ) + sys.store_credential("google_calendar", "a@x.com", dict(GOOGLE_CRED)) + sys.store_credential( + "google_calendar", + "b@y.com", + {**GOOGLE_CRED, "email": "b@y.com", "access_token": "at-b"}, + ) + sys.set_alias("google_calendar", "b@y.com", "school") + return sys + + +RAW_EVENT = { + "kind": "calendar#event", # metadata the lean shaping drops + "etag": '"etag-1"', + "id": "ev-1", + "summary": "Standup", + "start": {"dateTime": "2026-08-12T09:00:00Z"}, + "end": {"dateTime": "2026-08-12T09:15:00Z"}, + "status": "confirmed", + "htmlLink": "https://calendar.google.com/event?eid=ev-1", + "creator": {"email": "a@x.com"}, # dropped by lean shaping + "attendees": [ + {"email": "b@y.com", "responseStatus": "accepted", "self": True}, + ], +} + + +def test_execute_lists_events_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_list_events( + self, calendar_id="primary", time_min=None, time_max=None, max_results=50 + ): + seen.append((self._cred.email, calendar_id, time_min, time_max, max_results)) + return {"ok": True, "result": [RAW_EVENT]} + + monkeypatch.setattr(BoundGoogleCalendarClient, "list_events", fake_list_events) + + result = run( + system.execute( + "google_calendar", + "list_google_calendar_events", + {"time_min": "2026-08-12T00:00:00Z", "max_results": 10}, + account="school", + ) + ) + # school account's client, mapped args (calendar_id default applied) + assert seen == [("b@y.com", "primary", "2026-08-12T00:00:00Z", None, 10)] + # lean shaping applied (no include_metadata): metadata keys dropped, + # attendees reduced to email/displayName/responseStatus/organizer + assert result == { + "status": "success", + "result": [ + { + "id": "ev-1", + "summary": "Standup", + "start": {"dateTime": "2026-08-12T09:00:00Z"}, + "end": {"dateTime": "2026-08-12T09:15:00Z"}, + "status": "confirmed", + "htmlLink": "https://calendar.google.com/event?eid=ev-1", + "attendees": [{"email": "b@y.com", "responseStatus": "accepted"}], + } + ], + } + + raw = run( + system.execute( + "google_calendar", + "list_google_calendar_events", + {"include_metadata": True}, + ) + ) + assert seen[-1] == ("a@x.com", "primary", None, None, 50) # primary + defaults + assert raw["result"][0]["kind"] == "calendar#event" # raw passthrough diff --git a/tests/integrations/test_conformance_selftest.py b/tests/integrations/test_conformance_selftest.py new file mode 100644 index 00000000..2d6c4adf --- /dev/null +++ b/tests/integrations/test_conformance_selftest.py @@ -0,0 +1,78 @@ +"""Self-test: the conformance suite passes for a well-behaved fake provider +and fails for the specific contract violations it exists to catch.""" + +from __future__ import annotations + +import pytest + +from craftos_integrations.contracts import Operation + +from .conformance import ProviderConformance +from .test_system import FakeProvider + + +class TestFakeProviderConformance(ProviderConformance): + provider = FakeProvider("gmail", family="google") + credential_fixtures = [{"email": "a@x.com", "access_token": "tok"}] + + +def _operation(**overrides): + async def fn(client, input_data): + return {} + + defaults = dict( + name="delete_thing", + description="Delete a thing.", + input_schema={"id": {"type": "string", "description": "Thing id."}}, + output_schema={"status": {"type": "string"}}, + fn=fn, + destructive=True, + ) + defaults.update(overrides) + return Operation(**defaults) + + +class _BadProviderBase(FakeProvider): + def __init__(self, ops): + super().__init__("gmail") + self._ops = ops + + def operations(self): + return self._ops + + +def _suite_for(provider): + suite = ProviderConformance() + suite.provider = provider + suite.credential_fixtures = [{"email": "a@x.com"}] + return suite + + +def test_catches_operation_declaring_account(): + op = _operation( + input_schema={"account": {"type": "string"}, "id": {"type": "string"}} + ) + with pytest.raises(AssertionError, match="declares 'account'"): + _suite_for(_BadProviderBase([op])).test_operations_never_declare_account_input() + + +def test_catches_unflagged_destructive_operation(): + op = _operation(name="clear_google_calendar", destructive=False) + with pytest.raises(AssertionError, match="destructive"): + _suite_for(_BadProviderBase([op])).test_destructive_operations_are_flagged() + + +def test_catches_duplicate_operation_names(): + ops = [_operation(), _operation()] + with pytest.raises(AssertionError, match="duplicate"): + _suite_for(_BadProviderBase(ops)).test_operation_names_unique_and_snake_case() + + +def test_catches_uppercase_identity(): + class UppercaseIdentity(FakeProvider): + def identity_of(self, credential): + return credential.get("email", "").upper() or None + + suite = _suite_for(UppercaseIdentity("gmail")) + with pytest.raises(AssertionError, match="lowercase"): + suite.test_identity_of_fixtures_is_lowercase_stable() diff --git a/tests/integrations/test_craftbot_adapter.py b/tests/integrations/test_craftbot_adapter.py new file mode 100644 index 00000000..df4f416e --- /dev/null +++ b/tests/integrations/test_craftbot_adapter.py @@ -0,0 +1,147 @@ +"""CraftBot adapter: generated @action wrappers + the one-time legacy +upgrade migration. + +Loads app/data/action/integrations/craftbot_adapter.py exactly the way the +action loader does (file-location import — app/data/action is not a +package) and verifies the central account injection end-to-end. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def adapter_registry(): + """Import the adapter once; return the agent_core action registry.""" + from agent_core.core.action_framework.registry import registry_instance + + path = REPO / "app" / "data" / "action" / "integrations" / "craftbot_adapter.py" + spec = importlib.util.spec_from_file_location("test_craftbot_adapter_mod", path) + module = importlib.util.module_from_spec(spec) + sys.modules["test_craftbot_adapter_mod"] = module + spec.loader.exec_module(module) + return registry_instance + + +def _all_ops(): + from craftos_integrations.providers import default_providers + + return [(p, op) for p in default_providers() for op in p.operations()] + + +def test_every_operation_registered_with_injected_account(adapter_registry): + ops = _all_ops() + assert len(ops) >= 397 + missing, no_account = [], [] + for provider, op in ops: + registered = adapter_registry.get_action_implementation(op.name) + if registered is None: + missing.append(op.name) + continue + if "account" not in registered.metadata.input_schema: + no_account.append(op.name) + assert registered.metadata.irreversible == op.destructive, op.name + assert registered.metadata.parallelizable == op.parallelizable, op.name + assert registered.metadata.action_sets == list(op.tags), op.name + assert not missing, f"operations not registered as actions: {missing[:10]}" + assert not no_account, f"actions without injected account: {no_account[:10]}" + + +@pytest.fixture +def live_system(tmp_path, monkeypatch): + """Point the singleton system at a tmp credentials dir with 2 accounts.""" + from craftos_integrations.config import ConfigStore + + import app.integrations as bootstrap + + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + bootstrap.reset_system() + system = bootstrap.get_system() + cred = lambda email: {"email": email, "access_token": f"tok-{email}"} + system.store_credential("gmail", "a@x.com", cred("a@x.com")) + system.store_credential("gmail", "b@y.com", cred("b@y.com")) + system.set_alias("gmail", "b@y.com", "school") + yield system + bootstrap.reset_system() + + +def _handler(adapter_registry, name): + return adapter_registry.get_action_implementation(name).handler + + +def test_generated_action_routes_account_to_client( + adapter_registry, live_system, monkeypatch +): + from craftos_integrations.providers.gmail.provider import BoundGmailClient + + seen = [] + monkeypatch.setattr( + BoundGmailClient, + "list_emails", + lambda self, n=5, unread_only=True: ( + seen.append((self._cred.email, n)) or {"ok": True, "result": ["m"]} + ), + ) + handler = _handler(adapter_registry, "list_gmail") + result = asyncio.run(handler({"count": 2, "account": "school"})) + assert result == {"status": "success", "result": ["m"]} + assert seen == [("b@y.com", 2)] + + +def test_generated_action_bad_account_is_self_correcting( + adapter_registry, live_system +): + handler = _handler(adapter_registry, "list_gmail") + result = asyncio.run(handler({"account": "ghost"})) + assert result["status"] == "error" + assert "No gmail account matches 'ghost'" in result["message"] + assert "a@x.com" in result["message"] # enumerates choices + + +def test_generated_action_not_connected(adapter_registry, live_system): + handler = _handler(adapter_registry, "list_slack_channels") + result = asyncio.run(handler({})) + assert result["status"] == "error" + assert "not connected" in result["message"] + + +# ── one-time legacy upgrade migration (through the real bootstrap) ────── + + +def test_migration_imports_legacy_file_then_doc_is_source_of_truth( + tmp_path, monkeypatch +): + from craftos_integrations.config import ConfigStore + + import app.integrations as bootstrap + + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + bootstrap.reset_system() + system = bootstrap.get_system() + # Pre-multi-account install (≤ V1.4.2): only a legacy file exists → first contact + # migrates it into an AccountSet document... + legacy = tmp_path / ".credentials" + legacy.mkdir(parents=True, exist_ok=True) + (legacy / "notion.json").write_text(json.dumps({"token": "t"}), encoding="utf-8") + assert len(system.list_accounts("notion")) == 1 + # ...after which the document is the sole source of truth: deleting the + # legacy file no longer reads as a logout. + (legacy / "notion.json").unlink() + assert len(system.list_accounts("notion")) == 1 + bootstrap.reset_system() + + +def test_pure_v2_single_account_is_stable(live_system, tmp_path): + # Slack was connected purely via the integration system (no legacy file ever existed). + live_system.store_credential("slack", "t123", {"team_id": "T123"}) + assert len(live_system.list_accounts("slack")) == 1 + assert len(live_system.list_accounts("slack")) == 1 # and stays stable diff --git a/tests/integrations/test_docs_provider.py b/tests/integrations/test_docs_provider.py new file mode 100644 index 00000000..8758f1ca --- /dev/null +++ b/tests/integrations/test_docs_provider.py @@ -0,0 +1,87 @@ +"""Google Docs provider — conformance + one end-to-end wiring check. + +No network: the client API method is stubbed. What's real is the chain +execute() → resolve → bind → client method → shaped result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.google_docs import GoogleDocsProvider +from craftos_integrations.providers.google_docs.provider import BoundGoogleDocsClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +GOOGLE_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "client_secret": "csec", + "email": "a@x.com", +} + + +class TestGoogleDocsConformance(ProviderConformance): + provider = GoogleDocsProvider() + credential_fixtures = [ + GOOGLE_CRED, + {"access_token": "at", "email": " User@X.com "}, # messy legacy shape + {"access_token": "at"}, # identity-less pre-multi-account shape → None + ] + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[GoogleDocsProvider()] + ) + sys.store_credential("google_docs", "a@x.com", dict(GOOGLE_CRED)) + sys.store_credential( + "google_docs", + "b@y.com", + {**GOOGLE_CRED, "email": "b@y.com", "access_token": "at-b"}, + ) + sys.set_alias("google_docs", "b@y.com", "school") + return sys + + +def test_execute_runs_search_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_search(self, query, max_results=50): + seen.append((self._cred.email, query, max_results)) + return { + "ok": True, + "result": [{"id": "doc-1", "name": "Meeting Notes"}], + } + + monkeypatch.setattr(BoundGoogleDocsClient, "search_documents", fake_search) + + result = run( + system.execute( + "google_docs", + "search_google_docs", + {"query": "Meeting", "max_results": 3}, + account="school", + ) + ) + # school account's client, mapped args + assert seen == [("b@y.com", "Meeting", 3)] + assert result == { + "status": "success", + "result": [{"id": "doc-1", "name": "Meeting Notes"}], + } + + run(system.execute("google_docs", "search_google_docs", {"query": "Meeting"})) + assert seen[-1] == ("a@x.com", "Meeting", 50) # primary + arg-map default diff --git a/tests/integrations/test_drive_provider.py b/tests/integrations/test_drive_provider.py new file mode 100644 index 00000000..c445b2a2 --- /dev/null +++ b/tests/integrations/test_drive_provider.py @@ -0,0 +1,84 @@ +"""Google Drive provider — conformance + one end-to-end wiring check. + +No network: the client API method is stubbed. What's real is the chain +execute() → resolve → bind → client method → shaped result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.google_drive import GoogleDriveProvider +from craftos_integrations.providers.google_drive.provider import BoundGoogleDriveClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +GOOGLE_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "client_secret": "csec", + "email": "a@x.com", +} + + +class TestGoogleDriveConformance(ProviderConformance): + provider = GoogleDriveProvider() + credential_fixtures = [ + GOOGLE_CRED, + {"access_token": "at", "email": " User@X.com "}, # messy legacy shape + {"access_token": "at"}, # identity-less pre-multi-account shape → None + ] + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[GoogleDriveProvider()] + ) + sys.store_credential("google_drive", "a@x.com", dict(GOOGLE_CRED)) + sys.store_credential( + "google_drive", + "b@y.com", + {**GOOGLE_CRED, "email": "b@y.com", "access_token": "at-b"}, + ) + sys.set_alias("google_drive", "b@y.com", "work") + return sys + + +def test_execute_runs_search_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_search(self, query, max_results=50, fields=None): + seen.append((self._cred.email, query, max_results)) + return {"ok": True, "result": [{"id": "f1", "name": "budget.pdf"}]} + + monkeypatch.setattr(BoundGoogleDriveClient, "search_drive", fake_search) + + result = run( + system.execute( + "google_drive", + "search_drive_files", + {"query": "name contains 'budget'", "max_results": 5}, + account="work", + ) + ) + # work account's client, mapped args (query passthrough, max_results) + assert seen == [("b@y.com", "name contains 'budget'", 5)] + assert result == { + "status": "success", + "result": [{"id": "f1", "name": "budget.pdf"}], + } + + run(system.execute("google_drive", "search_drive_files", {"query": "q2"})) + assert seen[-1] == ("a@x.com", "q2", 50) # primary + legacy default of 50 diff --git a/tests/integrations/test_google_providers.py b/tests/integrations/test_google_providers.py new file mode 100644 index 00000000..57d717ca --- /dev/null +++ b/tests/integrations/test_google_providers.py @@ -0,0 +1,140 @@ +"""Google provider base + Gmail reference provider. + +No network: HTTP is monkeypatched; client API methods are stubbed. What's +real is the full chain execute() → resolve → bind → client method → shaped +result, and refresh-persistence routing. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import craftos_integrations.providers._google as google_mod +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.gmail import GmailProvider +from craftos_integrations.providers.gmail.provider import BoundGmailClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +GOOGLE_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "client_secret": "csec", + "email": "a@x.com", +} + + +class TestGmailConformance(ProviderConformance): + provider = GmailProvider() + credential_fixtures = [ + GOOGLE_CRED, + {"access_token": "at", "email": " User@X.com "}, # messy legacy shape + {"access_token": "at"}, # identity-less pre-multi-account shape → None + ] + + +def test_oauth_spec_carries_the_chooser_fix(): + spec = GmailProvider().oauth_spec() + assert spec.extra_authorize_params["prompt"] == "consent select_account" + assert spec.extra_authorize_params["access_type"] == "offline" + assert spec.has_chooser + + +def test_binding_replaces_disk_plumbing(): + client = BoundGmailClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(GOOGLE_CRED, lambda c: None) + assert client.has_credentials() + assert client._load().email == "a@x.com" + assert client._load().access_token == "at-1" + + +def test_refresh_persists_through_core_not_disk(monkeypatch): + persisted = {} + + def fake_http(method, url, **kwargs): + assert url == google_mod.GOOGLE_TOKEN_URL + assert kwargs["data"]["refresh_token"] == "rt-1" + return {"result": {"access_token": "at-2", "expires_in": 3600}} + + monkeypatch.setattr(google_mod, "http_request", fake_http) + client = BoundGmailClient() + client.bind_credential(dict(GOOGLE_CRED), persisted.update) + token = client.refresh_access_token() + assert token == "at-2" + assert persisted["access_token"] == "at-2" + assert persisted["refresh_token"] == "rt-1" # carried forward + assert persisted["email"] == "a@x.com" + + +def test_refresh_failure_returns_none_and_persists_nothing(monkeypatch): + persisted = {} + monkeypatch.setattr( + google_mod, "http_request", lambda *a, **k: {"error": "invalid_grant"} + ) + client = BoundGmailClient() + client.bind_credential(dict(GOOGLE_CRED), persisted.update) + assert client.refresh_access_token() is None + assert persisted == {} + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[GmailProvider()] + ) + sys.store_credential("gmail", "a@x.com", dict(GOOGLE_CRED)) + sys.store_credential( + "gmail", "b@y.com", {**GOOGLE_CRED, "email": "b@y.com", "access_token": "at-b"} + ) + sys.set_alias("gmail", "b@y.com", "school") + return sys + + +def test_execute_runs_operation_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_list_emails(self, n=5, unread_only=True): + seen.append((self._cred.email, n, unread_only)) + return {"ok": True, "result": ["mail"]} + + monkeypatch.setattr(BoundGmailClient, "list_emails", fake_list_emails) + + result = run(system.execute("gmail", "list_gmail", {"count": 3}, account="school")) + assert result == {"status": "success", "result": ["mail"]} + assert seen == [("b@y.com", 3, True)] # school account's client, mapped args + + run(system.execute("gmail", "list_gmail", {})) + assert seen[-1] == ("a@x.com", 5, True) # primary + client-side defaults + + +def test_operation_error_shape_is_agent_friendly(system, monkeypatch): + monkeypatch.setattr( + BoundGmailClient, + "send_email", + lambda self, **k: {"error": "API error: 403", "details": "insufficient scope"}, + ) + result = run( + system.execute( + "gmail", "send_gmail", {"subject": "s", "body": "b"}, account="a@x.com" + ) + ) + assert result["status"] == "error" + assert "403" in result["message"] + + +def test_default_providers_importable(): + from craftos_integrations.providers import default_providers + + providers = default_providers() + assert any(p.id == "gmail" for p in providers) diff --git a/tests/integrations/test_host_listener_wiring.py b/tests/integrations/test_host_listener_wiring.py new file mode 100644 index 00000000..36578896 --- /dev/null +++ b/tests/integrations/test_host_listener_wiring.py @@ -0,0 +1,376 @@ +"""PR 5 host wiring: listener fan-out. + +Covers the three host-side pieces: + +1. ``CraftBotEventSink`` — enriches listener events with account + context (``account`` key + ``(alias-or-identity)`` source suffix) and + forwards them to the same ``ConfigStore.on_message`` callback the + legacy manager uses. +2. ``ExternalCommsManager(exclude_platforms=...)`` — the legacy manager + never starts listening on platforms owned by the ListenerManager + (start / start_platform / reload), while staying backward compatible. +3. Browser-adapter initial-connect cut-over — ``connect_oauth`` for a multi-account + provider id routes through ``IntegrationSystem.add_account`` while + broadcasting the unchanged ``integration_connect_result`` shape; + legacy ids keep the legacy handler login. + +No pytest-asyncio in this repo — async paths are driven with asyncio.run. +The ListenerManager itself is built by a parallel PR; the one test that +needs the real module skips when it is not importable yet. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +import app.integrations as integrations +import app.ui_layer.adapters.browser_adapter as ba +from app.integrations import CraftBotEventSink +from app.ui_layer.adapters.browser_adapter import BrowserAdapter +from craftos_integrations.config import ConfigStore +from craftos_integrations.contracts import AccountInfo +from craftos_integrations.manager import ExternalCommsManager + + +def acct(identity: str, alias: Optional[str] = None) -> AccountInfo: + return AccountInfo( + identity=identity, alias=alias, is_primary=False, listen=True, + added_at="2026-08-10T00:00:00+00:00", + ) + + +def event() -> Dict[str, Any]: + """The payload-dict shape ExternalCommsManager._handle_platform_message builds.""" + return { + "source": "Gmail", + "integrationType": "gmail", + "contactId": "c-1", + "contactName": "Carol", + "messageBody": "hello", + "channelId": None, + "channelName": None, + "messageId": "m-1", + "is_self_message": False, + "raw": {}, + } + + +# ── CraftBotEventSink ──────────────────────────────────────────────────── + + +class _AccountsOnlySystem: + def __init__(self, accounts: List[AccountInfo], raise_on_list: bool = False): + outer_accounts = accounts + outer_raise = raise_on_list + + class _Accounts: + def list_accounts(self, provider_id: str) -> List[AccountInfo]: + if outer_raise: + raise RuntimeError("accounts unavailable") + return list(outer_accounts) + + self.accounts = _Accounts() + + +@pytest.fixture +def captured(monkeypatch): + """ConfigStore.on_message replaced with a recording async callback.""" + payloads: List[Dict[str, Any]] = [] + + async def on_message(payload: Dict[str, Any]) -> None: + payloads.append(payload) + + monkeypatch.setattr(ConfigStore, "on_message", on_message) + return payloads + + +def sink_with_accounts(monkeypatch, accounts, raise_on_list=False) -> CraftBotEventSink: + fake = _AccountsOnlySystem(accounts, raise_on_list=raise_on_list) + monkeypatch.setattr(integrations, "get_system", lambda: fake) + return CraftBotEventSink() + + +def test_sink_enriches_and_forwards_alias_preferred(monkeypatch, captured): + sink = sink_with_accounts( + monkeypatch, [acct("a@x.com", "work"), acct("b@y.com")] + ) + asyncio.run(sink.on_event("gmail", "a@x.com", event())) + (payload,) = captured + assert payload["account"] == "a@x.com" + assert payload["source"] == "Gmail (work)" # alias preferred over identity + # rest of the legacy payload contract travels through untouched + assert payload["integrationType"] == "gmail" + assert payload["messageBody"] == "hello" + + +def test_sink_falls_back_to_identity_without_alias(monkeypatch, captured): + sink = sink_with_accounts(monkeypatch, [acct("b@y.com", None)]) + asyncio.run(sink.on_event("gmail", "b@y.com", event())) + (payload,) = captured + assert payload["source"] == "Gmail (b@y.com)" + + +def test_sink_alias_lookup_failure_is_best_effort(monkeypatch, captured): + sink = sink_with_accounts(monkeypatch, [], raise_on_list=True) + asyncio.run(sink.on_event("gmail", "a@x.com", event())) + (payload,) = captured + assert payload["account"] == "a@x.com" + assert payload["source"] == "Gmail (a@x.com)" + + +def test_sink_drops_event_when_no_callback(monkeypatch, captured): + monkeypatch.setattr(ConfigStore, "on_message", None) + sink = sink_with_accounts(monkeypatch, [acct("a@x.com", "work")]) + asyncio.run(sink.on_event("gmail", "a@x.com", event())) # must not raise + assert captured == [] + + +def test_sink_does_not_mutate_the_original_event(monkeypatch, captured): + sink = sink_with_accounts(monkeypatch, [acct("a@x.com", "work")]) + original = event() + asyncio.run(sink.on_event("gmail", "a@x.com", original)) + assert original == event() # enrichment happened on a copy + assert captured[0] is not original + + +# ── legacy manager exclusion ───────────────────────────────────────────── + + +class FakeClient: + def __init__(self, supports_listening=True, has_creds=True): + self.supports_listening = supports_listening + self._has_creds = has_creds + self.is_listening = False + self.start_calls = 0 + + def has_credentials(self) -> bool: + return self._has_creds + + async def start_listening(self, callback) -> None: + self.start_calls += 1 + self.is_listening = True + + async def stop_listening(self) -> None: + self.is_listening = False + + +@pytest.fixture +def platforms(monkeypatch): + """Two listen-capable fake platforms wired into the manager module.""" + clients = {"gmail": FakeClient(), "telegram": FakeClient()} + import craftos_integrations.manager as manager_mod + + monkeypatch.setattr(manager_mod, "autoload_integrations", lambda: None) + monkeypatch.setattr(manager_mod, "get_all_clients", lambda: dict(clients)) + monkeypatch.setattr(manager_mod, "get_client", clients.get) + monkeypatch.setattr(manager_mod, "invalidate_client", lambda pid: None) + return clients + + +async def _noop_on_message(payload: Dict[str, Any]) -> None: + pass + + +def test_start_skips_excluded_platforms(platforms): + mgr = ExternalCommsManager(_noop_on_message, exclude_platforms=["gmail"]) + asyncio.run(mgr.start()) + assert platforms["gmail"].start_calls == 0 + assert platforms["telegram"].start_calls == 1 + assert set(mgr.get_status()["channels"]) == {"telegram"} + + +def test_start_platform_refuses_excluded(platforms): + mgr = ExternalCommsManager(_noop_on_message, exclude_platforms=["gmail"]) + assert asyncio.run(mgr.start_platform("gmail")) is False + assert platforms["gmail"].start_calls == 0 + assert asyncio.run(mgr.start_platform("telegram")) is True + + +def test_reload_never_starts_excluded(platforms): + mgr = ExternalCommsManager(_noop_on_message, exclude_platforms=["gmail"]) + asyncio.run(mgr.start()) + result = asyncio.run(mgr.reload()) + assert result["success"] is True + assert "gmail" not in result["started"] + assert platforms["gmail"].start_calls == 0 + + +def test_no_exclusion_is_backward_compatible(platforms): + mgr = ExternalCommsManager(_noop_on_message) + asyncio.run(mgr.start()) + assert platforms["gmail"].start_calls == 1 + assert platforms["telegram"].start_calls == 1 + + +# ── connect_oauth cut-over (browser adapter) ────────────────────────── + + +def make_adapter() -> Tuple[BrowserAdapter, List[Dict[str, Any]]]: + """A BrowserAdapter with only the state the OAuth handler touches.""" + adapter = object.__new__(BrowserAdapter) + adapter._oauth_tasks = {} + sent: List[Dict[str, Any]] = [] + + async def _broadcast(message: Dict[str, Any]) -> None: + sent.append(message) + + async def _list_stub() -> None: + sent.append({"type": "integration_list", "data": {"stub": True}}) + + adapter._broadcast = _broadcast + adapter._handle_integration_list = _list_stub + return adapter, sent + + +async def drain_tasks() -> None: + while True: + others = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + if not others: + return + await asyncio.gather(*others) + + +def results_of(sent: List[Dict[str, Any]], msg_type: str) -> List[Dict[str, Any]]: + return [m["data"] for m in sent if m["type"] == msg_type] + + +class FakeV2System: + def __init__(self, known=("gmail",)): + self._known = set(known) + self.add_calls: List[str] = [] + self.add_result: Tuple[bool, str] = (True, "Connected a@x.com") + + outer = self + + class _Registry: + def get(_self, pid): + return object() if pid in outer._known else None + + self.registry = _Registry() + + async def add_account(self, provider_id: str): + self.add_calls.append(provider_id) + ok, message = self.add_result + return ok, message, [acct("a@x.com", "work")] + + +@pytest.fixture +def v2_system(monkeypatch): + fake = FakeV2System(known=("gmail",)) + monkeypatch.setattr(integrations, "get_system", lambda: fake) + return fake + + +@pytest.fixture +def legacy_oauth(monkeypatch): + calls: List[str] = [] + + async def fake_connect(integration_id: str): + calls.append(integration_id) + return True, "legacy connected" + + monkeypatch.setattr(ba, "connect_integration_oauth", fake_connect) + return calls + + +def test_connect_oauth_routes_v2_through_add_account(v2_system, legacy_oauth): + adapter, sent = make_adapter() + + async def scenario(): + await adapter._handle_integration_connect_oauth("gmail") + await drain_tasks() + + asyncio.run(scenario()) + assert v2_system.add_calls == ["gmail"] + assert legacy_oauth == [] # legacy login must not run for a multi-account id + (data,) = results_of(sent, "integration_connect_result") + assert data == {"success": True, "message": "Connected a@x.com", "id": "gmail"} + # success still refreshes the integration list, task registry is clean + assert results_of(sent, "integration_list") + assert adapter._oauth_tasks == {} + + +def test_connect_oauth_v2_failure_keeps_result_shape(v2_system, legacy_oauth): + adapter, sent = make_adapter() + v2_system.add_result = (False, "OAuth timed out") + + async def scenario(): + await adapter._handle_integration_connect_oauth("gmail") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_connect_result") + assert data == {"success": False, "message": "OAuth timed out", "id": "gmail"} + assert not results_of(sent, "integration_list") + + +def test_connect_oauth_non_v2_uses_legacy_handler(v2_system, legacy_oauth): + adapter, sent = make_adapter() + + async def scenario(): + await adapter._handle_integration_connect_oauth("jira") + await drain_tasks() + + asyncio.run(scenario()) + assert legacy_oauth == ["jira"] + assert v2_system.add_calls == [] + (data,) = results_of(sent, "integration_connect_result") + assert data == {"success": True, "message": "legacy connected", "id": "jira"} + + +# ── start_listeners wiring (needs the parallel PR's ListenerManager) ───── + +# importorskip would skip this whole module (all tests above included), so +# the optional dependency is probed with a plain try/except + skipif. +try: + import craftos_integrations.core.listeners as listeners_mod +except ImportError: # pragma: no cover - parallel PR not merged yet + listeners_mod = None + + +class FakeListenerManager: + instances: List["FakeListenerManager"] = [] + + def __init__(self, system, sink, cursors): + self.system = system + self.sink = sink + self.cursors = cursors + self.started = 0 + self.stopped = 0 + FakeListenerManager.instances.append(self) + + async def start(self) -> None: + self.started += 1 + + async def stop(self) -> None: + self.stopped += 1 + + +@pytest.mark.skipif( + listeners_mod is None, + reason="ListenerManager lands in a parallel PR; wiring is code-complete", +) +def test_start_listeners_builds_once_and_attaches(monkeypatch): + FakeListenerManager.instances = [] + system = _AccountsOnlySystem([]) + monkeypatch.setattr(integrations, "get_system", lambda: system) + monkeypatch.setattr(integrations, "_listeners", None) + monkeypatch.setattr(integrations, "_listener_task", None) + monkeypatch.setattr(listeners_mod, "ListenerManager", FakeListenerManager) + monkeypatch.setattr(listeners_mod, "FileCursorStore", lambda: "cursors") + + asyncio.run(integrations.start_listeners()) + asyncio.run(integrations.start_listeners()) # idempotent construction + + assert len(FakeListenerManager.instances) == 1 + manager = FakeListenerManager.instances[0] + assert getattr(system, "listeners") is manager + assert isinstance(manager.sink, CraftBotEventSink) + assert manager.cursors == "cursors" + assert manager.started == 2 + + asyncio.run(integrations.stop_listeners()) + assert manager.stopped == 1 diff --git a/tests/integrations/test_hubspot_provider.py b/tests/integrations/test_hubspot_provider.py new file mode 100644 index 00000000..11c5b6a3 --- /dev/null +++ b/tests/integrations/test_hubspot_provider.py @@ -0,0 +1,245 @@ +"""HubSpot provider — first non-Google provider with rotating tokens. + +No network: HTTP is monkeypatched; client API methods are stubbed. What's +real is conformance, the credential binding, refresh-persistence routing +through the core (the part that differs from Slack), and the full chain +execute() → resolve → bind → client method → shaped result (incl. the +legacy pick_result shaping). +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +import craftos_integrations.providers.hubspot.provider as hubspot_mod +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.hubspot import HubSpotProvider +from craftos_integrations.providers.hubspot.provider import BoundHubSpotClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +HUBSPOT_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "hub_id": "12345678", + "hub_domain": "acme.hubspot.com", + "user_email": "ops@acme.com", + "auth_kind": "oauth", +} + + +class TestHubSpotConformance(ProviderConformance): + provider = HubSpotProvider() + credential_fixtures = [ + HUBSPOT_CRED, # real OAuth-invite shape (hub id captured) + # pre-identity Private-App-token shape (hub_id never captured) → None + {"access_token": "pat-na1-old-token", "auth_kind": "token"}, + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_hub_id(): + provider = HubSpotProvider() + assert provider.identity_of(HUBSPOT_CRED) == "12345678" + assert provider.identity_of({"hub_id": 12345678}) == "12345678" # int tolerated + assert provider.identity_of({"access_token": "pat-na1-x"}) is None + assert provider.identity_of({"hub_id": ""}) is None + assert provider.identity_of({"hub_id": " "}) is None + + +def test_oauth_spec_matches_legacy_handler(): + spec = HubSpotProvider().oauth_spec() + assert spec.authorize_url == "https://app.hubspot.com/oauth/authorize" + assert spec.token_url == "https://api.hubapi.com/oauth/v1/token" + assert "crm.objects.contacts.read" in spec.scopes and "oauth" in spec.scopes + assert spec.has_chooser # HubSpot's authorize page has an account/hub chooser + + +def test_operations_are_the_full_legacy_surface(): + assert len(HubSpotProvider().operations()) == 90 + + +def test_binding_replaces_disk_plumbing(): + client = BoundHubSpotClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(HUBSPOT_CRED, lambda c: None) + assert client.has_credentials() + assert client._load().access_token == "at-1" + assert client._load().hub_id == "12345678" + + +# ── refresh: legacy logic, AccountSet persistence ──────────────────────────────── + + +@pytest.fixture +def oauth_config(monkeypatch): + monkeypatch.setattr( + hubspot_mod.ConfigStore, + "_oauth", + { + "HUBSPOT_SHARED_CLIENT_ID": "cid", + "HUBSPOT_SHARED_CLIENT_SECRET": "csec", + }, + ) + + +def test_refresh_persists_through_core_not_disk(monkeypatch, oauth_config): + persisted = {} + + def fake_http(method, url, **kwargs): + assert method == "POST" + assert url == "https://api.hubapi.com/oauth/v1/token" + assert kwargs["data"] == { + "grant_type": "refresh_token", + "client_id": "cid", + "client_secret": "csec", + "refresh_token": "rt-1", + } + return {"ok": True, "result": {"access_token": "at-2", "expires_in": 1800}} + + monkeypatch.setattr(hubspot_mod, "http_request", fake_http) + client = BoundHubSpotClient() + # Expired token: the inherited _get_valid_access_token must refresh + # inline through the binding's override. + client.bind_credential({**HUBSPOT_CRED, "token_expiry": 100.0}, persisted.update) + token = client._get_valid_access_token() + assert token == "at-2" + assert persisted["access_token"] == "at-2" + assert persisted["refresh_token"] == "rt-1" # not rotated → carried forward + assert persisted["hub_id"] == "12345678" + assert persisted["token_expiry"] > time.time() # 1800s ahead minus 60s margin + + +def test_refresh_keeps_rotated_refresh_token(monkeypatch, oauth_config): + persisted = {} + monkeypatch.setattr( + hubspot_mod, + "http_request", + lambda *a, **k: { + "ok": True, + "result": {"access_token": "at-2", "refresh_token": "rt-2"}, + }, + ) + client = BoundHubSpotClient() + client.bind_credential(dict(HUBSPOT_CRED), persisted.update) + assert client._refresh_access_token() == "at-2" + assert persisted["refresh_token"] == "rt-2" # HubSpot rotated it + + +def test_refresh_failure_returns_stale_token_and_persists_nothing( + monkeypatch, oauth_config +): + persisted = {} + monkeypatch.setattr( + hubspot_mod, "http_request", lambda *a, **k: {"error": "invalid_grant"} + ) + client = BoundHubSpotClient() + client.bind_credential({**HUBSPOT_CRED, "token_expiry": 100.0}, persisted.update) + assert client._refresh_access_token() is None + assert persisted == {} + # Legacy fallback: stale token is returned so HubSpot answers a clean 401. + assert client._get_valid_access_token() == "at-1" + + +def test_private_app_tokens_never_hit_the_refresh_endpoint(monkeypatch): + def exploding_http(*a, **k): # pragma: no cover - fails the test if reached + raise AssertionError("Private App tokens must not attempt refresh") + + monkeypatch.setattr(hubspot_mod, "http_request", exploding_http) + cred = { + "access_token": "pat-na1-token", + "hub_id": "999", + "auth_kind": "token", + } + client = BoundHubSpotClient() + client.bind_credential(cred, lambda c: None) + assert client._get_valid_access_token() == "pat-na1-token" + assert run(HubSpotProvider().refresh(dict(cred))) is None # non-expiring + + +# ── execute() wiring through IntegrationSystem ─────────────────────────── + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[HubSpotProvider()] + ) + sys.store_credential("hubspot", "12345678", dict(HUBSPOT_CRED)) + sys.store_credential( + "hubspot", + "87654321", + { + **HUBSPOT_CRED, + "hub_id": "87654321", + "hub_domain": "beta.hubspot.com", + "access_token": "at-beta", + }, + ) + sys.set_alias("hubspot", "87654321", "beta") + return sys + + +def test_execute_runs_operation_against_resolved_hubs_client(system, monkeypatch): + seen = [] + + async def fake_create_contact(self, properties, **kw): + seen.append((self._cred.hub_id, properties)) + # Full mutated object, as HubSpot returns it — the legacy + # pick_result(["id"]) shaping must reduce it. + return { + "ok": True, + "result": { + "id": "999", + "properties": properties, + "createdAt": "2026-01-01T00:00:00Z", + }, + } + + monkeypatch.setattr(BoundHubSpotClient, "create_contact", fake_create_contact) + + result = run( + system.execute( + "hubspot", + "create_hubspot_contact", + {"properties": {"email": "jane@example.com"}}, + account="beta", + ) + ) + # ok-envelope collapsed + legacy pick_result(["id"]) shaping. + assert result == {"status": "success", "result": {"id": "999"}} + assert seen == [("87654321", {"email": "jane@example.com"})] # beta hub's client + + run( + system.execute( + "hubspot", "create_hubspot_contact", {"properties": {"email": "b@x.com"}} + ) + ) + assert seen[-1][0] == "12345678" # primary hub by default + + +def test_operation_error_shape_is_agent_friendly(system, monkeypatch): + async def fake_delete_contact(self, contact_id): + return {"error": "API error: 404", "details": "contact not found"} + + monkeypatch.setattr(BoundHubSpotClient, "delete_contact", fake_delete_contact) + result = run( + system.execute( + "hubspot", + "delete_hubspot_contact", + {"contact_id": "404404"}, + account="12345678", + ) + ) + assert result["status"] == "error" + assert "404" in result["message"] diff --git a/tests/integrations/test_integration_essentials.py b/tests/integrations/test_integration_essentials.py new file mode 100644 index 00000000..e1c48045 --- /dev/null +++ b/tests/integrations/test_integration_essentials.py @@ -0,0 +1,110 @@ +"""Just-in-time essentials matching (word boundaries, bare tokens, +specific-key suppression, provider GUIDANCE.md sourcing).""" + +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def essentials(): + path = REPO / "app" / "data" / "action" / "integrations" / "_integration_essentials.py" + spec = importlib.util.spec_from_file_location("test_essentials_mod", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _ids(essentials, message): + return re.findall( + r"^### (\S+)", essentials.get_essentials_for_message(message), re.M + ) + + +def test_bare_calendar_matches_calendar_integrations(essentials): + # The original bug this file exists to fix: only "google calendar" + # matched; "what's on my school calendar" injected nothing. + ids = _ids(essentials, "what's on my school calendar") + assert "google_calendar" in ids + assert "lark_calendar" in ids # ambiguous bare word → both candidates + + +def test_bare_docs_drive_youtube_match(essentials): + assert _ids(essentials, "open that docs file") == ["google_docs"] + assert set(_ids(essentials, "upload it to drive")) == { + "google_drive", + "lark_drive", + } + assert _ids(essentials, "check youtube comments") == ["google_youtube"] + + +def test_word_boundaries_prevent_false_positives(essentials): + assert _ids(essentials, "the doctor said to check docker drivers") == [] + assert _ids(essentials, "the online documentation") == [] + + +def test_specific_key_suppresses_generic_family_token(essentials): + assert _ids(essentials, "open my google docs") == ["google_docs"] + assert _ids(essentials, "lark calendar event") == ["lark_calendar"] + + +def test_v2_guidance_is_sourced_with_multi_account_rules(essentials): + block = essentials.get_essentials_for_message("send a gmail to alice") + assert "### gmail" in block + # The provider GUIDANCE.md multi-account rules reach the router. + assert "account" in block + assert "primary" in block.lower() + + +def test_no_mention_no_block(essentials): + assert essentials.get_essentials_for_message("what's the weather?") == "" + assert essentials.get_essentials_for_message("") == "" + + +def test_connected_accounts_injected_into_essentials(essentials, tmp_path, monkeypatch): + from craftos_integrations.config import ConfigStore + + import app.integrations as bootstrap + + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + bootstrap.reset_system() + system = bootstrap.get_system() + system.store_credential( + "gmail", "a@x.com", {"email": "a@x.com", "access_token": "t"} + ) + system.store_credential( + "gmail", "b@y.com", {"email": "b@y.com", "access_token": "t"} + ) + system.set_alias("gmail", "b@y.com", "job search") + try: + block = essentials.get_essentials_for_message("check my gmail") + assert "Connected accounts:" in block + assert "a@x.com" in block and "[primary]" in block + assert 'b@y.com (alias: "job search")' in block + finally: + bootstrap.reset_system() + + +def test_essentials_without_accounts_have_no_note(essentials, tmp_path, monkeypatch): + from craftos_integrations.config import ConfigStore + + import app.integrations as bootstrap + + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + bootstrap.reset_system() + try: + block = essentials.get_essentials_for_message("check my gmail") + assert "Connected accounts:" not in block + finally: + bootstrap.reset_system() + + +def test_email_synonym_matches_mail_integrations(essentials): + ids = _ids(essentials, "any updates for my job email?") + assert "gmail" in ids or "outlook" in ids diff --git a/tests/integrations/test_isolation.py b/tests/integrations/test_isolation.py new file mode 100644 index 00000000..ff432dce --- /dev/null +++ b/tests/integrations/test_isolation.py @@ -0,0 +1,61 @@ +"""Isolation gate: the integrations package must stay host-blind. + +``craftos_integrations`` (contracts + core, and providers/ when it lands) +may not import from the host application (``app``, ``agent_core``, +``agent_file_system``) — that boundary is what makes the package mountable +into a different agent. This test walks the AST of every module so the +gate needs no extra dependency (import-linter) to run. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import craftos_integrations + +FORBIDDEN_ROOTS = {"app", "agent_core", "agent_file_system", "decorators"} + +# Scope: the integrations-package surface. (The pre-multi-account modules already follow the same rule +# by convention; they get added here as they're ported.) +PACKAGE_PATHS = ["contracts.py", "core", "providers", "hosts"] + + +def _iter_package_modules(): + package_root = Path(craftos_integrations.__file__).parent + for rel in PACKAGE_PATHS: + path = package_root / rel + if path.is_file(): + yield path + elif path.is_dir(): + yield from sorted(path.rglob("*.py")) + + +def _imported_roots(tree: ast.AST): + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name.split(".")[0] + elif isinstance(node, ast.ImportFrom): + if node.level == 0 and node.module: # absolute imports only + yield node.module.split(".")[0] + + +def test_package_never_imports_the_host(): + violations = [] + for module_path in _iter_package_modules(): + tree = ast.parse(module_path.read_text(encoding="utf-8")) + for root in _imported_roots(tree): + if root in FORBIDDEN_ROOTS: + violations.append(f"{module_path.name} imports {root}") + assert not violations, ( + "Host imports leaked into the integrations package:\n " + + "\n ".join(violations) + ) + + +def test_every_module_parses(): + modules = list(_iter_package_modules()) + assert modules, "integration modules not found — did the layout move?" + for module_path in modules: + ast.parse(module_path.read_text(encoding="utf-8")) diff --git a/tests/integrations/test_linkedin_provider.py b/tests/integrations/test_linkedin_provider.py new file mode 100644 index 00000000..928fb627 --- /dev/null +++ b/tests/integrations/test_linkedin_provider.py @@ -0,0 +1,255 @@ +"""LinkedIn provider — first expiring-token non-Google provider. + +No network: HTTP and client API methods are stubbed. What's real is +conformance, the credential binding, the legacy-shaped token refresh +persisting through the core (never to linkedin.json), the chooser-less +OAuth declaration, and the full chain execute() → resolve → bind → +person-URN construction → client method → shaped result. +""" + +from __future__ import annotations + +import asyncio +import time + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.linkedin import LinkedInProvider +from craftos_integrations.providers.linkedin.provider import BoundLinkedInClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Real OAuth shape: legacy LinkedInCredential fields + the identity +# keys (email/sub from the OpenID userinfo) captured at login. +LINKEDIN_CRED = { + "access_token": "AQV-work-token", + "refresh_token": "AQW-work-refresh", + "token_expiry": time.time() + 3600, + "client_id": "li-client-id", + "client_secret": "li-client-secret", + "linkedin_id": "AbC123xYz", + "user_id": "AbC123xYz", + "email": "Person@Example.com", + "sub": "AbC123xYz", +} + +# LinkedIn returned no email (member hid it) — sub is the identity. +SUB_ONLY_CRED = { + "access_token": "AQV-sub-token", + "linkedin_id": "AbC123xYz", + "sub": "AbC123xYz", +} + +# Pre-multi-account linkedin.json shape: neither email nor sub key → LEGACY_IDENTITY. +LEGACY_CRED = { + "access_token": "AQV-old-token", + "refresh_token": "AQW-old-refresh", + "token_expiry": 0.0, + "client_id": "li-client-id", + "client_secret": "li-client-secret", + "linkedin_id": "OldId999", + "user_id": "OldId999", +} + + +class TestLinkedInConformance(ProviderConformance): + provider = LinkedInProvider() + credential_fixtures = [ + LINKEDIN_CRED, # real OAuth shape (email captured) + SUB_ONLY_CRED, # no email → identity is the OpenID sub claim + LEGACY_CRED, # pre-identity legacy shape → None + {}, # junk — must not raise + ] + + +def test_identity_is_email_then_sub_then_none(): + provider = LinkedInProvider() + assert provider.identity_of(LINKEDIN_CRED) == "person@example.com" + assert provider.identity_of(SUB_ONLY_CRED) == "abc123xyz" + assert provider.identity_of({"email": " ", "sub": "AbC123xYz"}) == "abc123xyz" + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + assert provider.identity_of({}) is None + + +def test_oauth_spec_has_no_chooser_and_no_fictitious_prompt_param(): + spec = LinkedInProvider().oauth_spec() + assert spec.authorize_url == "https://www.linkedin.com/oauth/v2/authorization" + assert spec.token_url == "https://www.linkedin.com/oauth/v2/accessToken" + assert set(spec.scopes) == {"openid", "profile", "email", "w_member_social"} + # LinkedIn's OAuth documents NO account-chooser/prompt parameter. The + # abandoned PR shipped a fictitious ``prompt=login`` that does nothing + # — declare the missing chooser instead and document the browser + # log-out workaround (conformance-enforced via GUIDANCE.md). + assert spec.has_chooser is False + assert "prompt" not in spec.extra_authorize_params + assert dict(spec.extra_authorize_params) == {} + + +def test_guidance_documents_the_add_account_workaround(): + guidance = LinkedInProvider().guidance().lower() + assert "log out of linkedin.com" in guidance + assert "add account" in guidance + + +def test_binding_replaces_disk_plumbing(): + client = BoundLinkedInClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(LINKEDIN_CRED, lambda c: None) + assert client.has_credentials() + assert client._load().access_token == "AQV-work-token" + assert client._load().linkedin_id == "AbC123xYz" + + +def test_refresh_persists_through_the_core(monkeypatch): + """Legacy refresh semantics, but the refreshed credential goes through + persist() (the core routes it to the right account entry) and keeps + the identity keys that are not LinkedInCredential fields.""" + calls = [] + + def fake_http_request(method, url, **kwargs): + calls.append((method, url, kwargs.get("data"))) + return {"ok": True, "result": {"access_token": "AQV-new", "expires_in": 5184000}} + + monkeypatch.setattr( + "craftos_integrations.providers.linkedin.provider.http_request", + fake_http_request, + ) + + persisted = [] + provider = LinkedInProvider() + client = provider.build_client(dict(LINKEDIN_CRED), persisted.append) + token = client.refresh_access_token() + + assert token == "AQV-new" + assert calls == [ + ( + "POST", + "https://www.linkedin.com/oauth/v2/accessToken", + { + "grant_type": "refresh_token", + "refresh_token": "AQW-work-refresh", + "client_id": "li-client-id", + "client_secret": "li-client-secret", + }, + ) + ] + assert len(persisted) == 1 + updated = persisted[0] + assert updated["access_token"] == "AQV-new" + assert updated["refresh_token"] == "AQW-work-refresh" # unchanged + # ~60-day expiry, renewed a day early (legacy math preserved). + assert updated["token_expiry"] == pytest.approx( + time.time() + 5184000 - 86400, abs=30 + ) + # Identity keys are not dataclass fields — they must survive refresh, + # or the account would degrade to legacy shape on its next migration. + assert updated["email"] == "Person@Example.com" + assert updated["sub"] == "AbC123xYz" + + +def test_provider_refresh_returns_updated_credential(monkeypatch): + monkeypatch.setattr( + "craftos_integrations.providers.linkedin.provider.http_request", + lambda *a, **kw: {"ok": True, "result": {"access_token": "AQV-oob"}}, + ) + provider = LinkedInProvider() + refreshed = run(provider.refresh(dict(LINKEDIN_CRED))) + assert refreshed is not None and refreshed["access_token"] == "AQV-oob" + + # Missing refresh material → None (nothing persisted, nothing raised). + assert run(provider.refresh(dict(SUB_ONLY_CRED))) is None + + +def test_refresh_failure_persists_nothing(monkeypatch): + monkeypatch.setattr( + "craftos_integrations.providers.linkedin.provider.http_request", + lambda *a, **kw: {"error": "invalid_grant"}, + ) + persisted = [] + client = LinkedInProvider().build_client(dict(LINKEDIN_CRED), persisted.append) + assert client.refresh_access_token() is None + assert persisted == [] + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[LinkedInProvider()] + ) + sys.store_credential("linkedin", "person@example.com", dict(LINKEDIN_CRED)) + sys.store_credential( + "linkedin", + "consult@example.com", + { + **{k: v for k, v in LINKEDIN_CRED.items()}, + "access_token": "AQV-consult-token", + "linkedin_id": "ZzTop777", + "user_id": "ZzTop777", + "email": "Consult@Example.com", + "sub": "ZzTop777", + }, + ) + sys.set_alias("linkedin", "consult@example.com", "consulting") + return sys + + +def test_execute_builds_person_urn_from_resolved_accounts_client( + system, monkeypatch +): + seen = [] + + def fake_create_text_post(self, author_urn, text, visibility="PUBLIC"): + seen.append((self._cred.linkedin_id, author_urn, text, visibility)) + return {"ok": True, "result": {"id": "urn:li:share:9"}} + + monkeypatch.setattr(BoundLinkedInClient, "create_text_post", fake_create_text_post) + + result = run( + system.execute( + "linkedin", + "create_linkedin_post", + {"text": "Hello network"}, + account="consulting", + ) + ) + assert result == {"status": "success", "result": {"id": "urn:li:share:9"}} + # The consulting account's client and ITS person URN — not primary's. + assert seen == [("ZzTop777", "urn:li:person:ZzTop777", "Hello network", "PUBLIC")] + + run( + system.execute( + "linkedin", + "create_linkedin_post", + {"text": "hi", "visibility": "CONNECTIONS"}, + ) + ) + assert seen[-1] == ( + "AbC123xYz", + "urn:li:person:AbC123xYz", + "hi", + "CONNECTIONS", + ) # primary account by default + + +def test_operation_error_shape_is_agent_friendly(system, monkeypatch): + def fake_get_post(self, post_urn): + return {"error": "API error: 401", "details": "revoked"} + + monkeypatch.setattr(BoundLinkedInClient, "get_post", fake_get_post) + result = run( + system.execute( + "linkedin", + "get_linkedin_post", + {"post_urn": "urn:li:share:123"}, + account="person@example.com", + ) + ) + assert result["status"] == "error" + assert "401" in result["message"] diff --git a/tests/integrations/test_listener_manager.py b/tests/integrations/test_listener_manager.py new file mode 100644 index 00000000..6acb07e6 --- /dev/null +++ b/tests/integrations/test_listener_manager.py @@ -0,0 +1,443 @@ +"""ListenerManager / FileCursorStore behavior — all fakes, no network. + +Covers the §8 guarantees: exact-diff reconciliation, per-account event +tagging, per-identity cursors, crash-loop isolation, credential-change +restarts, and cursor persistence on stop. +""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +from craftos_integrations.contracts import OAuthSpec +from craftos_integrations.core.listeners import ( + PAUSED_STATUS, + FileCursorStore, + ListenerManager, +) +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem + + +# ── fakes ──────────────────────────────────────────────────────────────── + + +class FakeListener: + def __init__( + self, + emit, + cursor: Optional[Dict[str, Any]], + *, + events: Tuple[Dict[str, Any], ...] = (), + crash: bool = False, + cursor_out: Optional[Dict[str, Any]] = None, + poll_interval: Optional[float] = None, + ) -> None: + self.emit = emit + self.cursor_in = cursor + self.events = events + self.crash = crash + self.cursor_out = cursor_out + if poll_interval is not None: + self.poll_interval = poll_interval + self.start_count = 0 + self.stop_called = False + self._stop = asyncio.Event() + + async def start(self) -> None: + self.start_count += 1 + if self.crash: + raise RuntimeError("boom") + for event in self.events: + await self.emit(event) + await self._stop.wait() + + async def stop(self) -> None: + self.stop_called = True + self._stop.set() + + def cursor(self) -> Optional[Dict[str, Any]]: + return self.cursor_out + + +class FakeProvider: + family = None + + def __init__( + self, + pid: str = "fakemail", + *, + has_listener: bool = True, + crash_for: Tuple[str, ...] = (), + poll_interval: Optional[float] = None, + ) -> None: + self.id = pid + self.has_listener = has_listener + self.crash_for = crash_for + self.poll_interval = poll_interval + self.built: List[Dict[str, Any]] = [] # every make_listener call + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + email = credential.get("email") + return email.lower() if isinstance(email, str) else None + + def oauth_spec(self) -> OAuthSpec: + return OAuthSpec("https://auth.example/a", "https://auth.example/t") + + def build_client(self, credential, persist) -> Dict[str, Any]: + return {"email": credential.get("email"), "token": credential.get("access_token")} + + async def refresh(self, credential): + return None + + def operations(self): + return [] + + def guidance(self) -> str: + return "" + + def make_listener(self, client, cursor, emit): + if not self.has_listener: + return None + identity = client.get("email") + listener = FakeListener( + emit, + cursor, + events=({"kind": "mail", "for": identity},), + crash=identity in self.crash_for, + cursor_out={"last_seen": f"msg-{identity}"}, + poll_interval=self.poll_interval, + ) + self.built.append( + {"client": client, "cursor": cursor, "listener": listener} + ) + return listener + + +class FakeSink: + def __init__(self) -> None: + self.events: List[Tuple[str, str, Dict[str, Any]]] = [] + + async def on_event(self, provider_id, identity, event) -> None: + self.events.append((provider_id, identity, event)) + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def cred(identity: str, token: str = "tok") -> Dict[str, Any]: + return {"email": identity, "access_token": f"{token}-{identity}"} + + +def build(tmp_path, provider, **manager_kwargs): + system = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[provider] + ) + sink = FakeSink() + cursors = FileCursorStore(root=tmp_path) + manager_kwargs.setdefault("max_failures", 3) + manager_kwargs.setdefault("backoff_base", 0.005) + manager_kwargs.setdefault("stagger_default", 0.0) + manager = ListenerManager(system, sink, cursors, **manager_kwargs) + return system, sink, cursors, manager + + +async def eventually(predicate, timeout: float = 2.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + await asyncio.sleep(0.005) + return predicate() + + +def running_keys(manager) -> set: + return set(manager._instances.keys()) + + +# ── reconciliation ─────────────────────────────────────────────────────── + + +def test_reconcile_starts_and_stops_exact_instances(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + system.store_credential("fakemail", "b@y.com", cred("b@y.com")) + + async def main(): + await manager.reconcile() + assert running_keys(manager) == { + ("fakemail", "a@x.com"), + ("fakemail", "b@y.com"), + } + assert len(provider.built) == 2 + survivor = manager._instances[("fakemail", "a@x.com")].listener + + # Toggle one off → exactly that instance stops; the other is the + # very same listener object, untouched. + system.set_listening("fakemail", "b@y.com", False) + await manager.reconcile() + assert running_keys(manager) == {("fakemail", "a@x.com")} + assert manager._instances[("fakemail", "a@x.com")].listener is survivor + # Instances are built in sorted-identity order: [0]=a@x.com, [1]=b@y.com + assert provider.built[1]["listener"].stop_called + assert not survivor.stop_called + + # Remove the remaining account → nothing runs. + system.remove_account("fakemail", "a@x.com") + await manager.reconcile() + assert running_keys(manager) == set() + assert survivor.stop_called + await manager.stop() + + asyncio.run(main()) + + +def test_listen_false_accounts_never_start(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + system.store_credential("fakemail", "b@y.com", cred("b@y.com")) + system.set_listening("fakemail", "b@y.com", False) + + async def main(): + await manager.reconcile() + assert running_keys(manager) == {("fakemail", "a@x.com")} + assert [b["client"]["email"] for b in provider.built] == ["a@x.com"] + await manager.stop() + + asyncio.run(main()) + + +def test_provider_without_listener_starts_nothing(tmp_path): + provider = FakeProvider(has_listener=False) + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + + async def main(): + await manager.reconcile() + assert running_keys(manager) == set() + await manager.stop() + + asyncio.run(main()) + + +# ── event tagging ──────────────────────────────────────────────────────── + + +def test_events_tagged_with_provider_and_identity(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + system.store_credential("fakemail", "b@y.com", cred("b@y.com")) + + async def main(): + await manager.reconcile() + assert await eventually(lambda: len(sink.events) >= 2) + tagged = {(pid, ident) for pid, ident, _ in sink.events} + assert tagged == {("fakemail", "a@x.com"), ("fakemail", "b@y.com")} + for pid, ident, event in sink.events: + assert event == {"kind": "mail", "for": ident} + await manager.stop() + + asyncio.run(main()) + + +# ── cursors ────────────────────────────────────────────────────────────── + + +def test_cursor_persisted_per_identity_and_handed_back(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + system.store_credential("fakemail", "b@y.com", cred("b@y.com")) + + async def main(): + await manager.reconcile() + # First build gets no cursor (nothing persisted yet). + assert all(b["cursor"] is None for b in provider.built) + await manager.stop() + + asyncio.run(main()) + + assert cursors.get("fakemail", "a@x.com") == {"last_seen": "msg-a@x.com"} + assert cursors.get("fakemail", "b@y.com") == {"last_seen": "msg-b@y.com"} + + # A fresh manager hands each identity exactly its own cursor back. + manager2 = ListenerManager( + system, sink, cursors, max_failures=3, backoff_base=0.005, + stagger_default=0.0, + ) + + async def again(): + await manager2.reconcile() + by_identity = { + b["client"]["email"]: b["cursor"] for b in provider.built[2:] + } + assert by_identity == { + "a@x.com": {"last_seen": "msg-a@x.com"}, + "b@y.com": {"last_seen": "msg-b@y.com"}, + } + await manager2.stop() + + asyncio.run(again()) + + +def test_stop_persists_cursors(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + + async def main(): + await manager.reconcile() + assert await eventually( + lambda: manager._instances[("fakemail", "a@x.com")].state + in ("running", "idle") + ) + await manager.stop() + + asyncio.run(main()) + assert cursors.get("fakemail", "a@x.com") == {"last_seen": "msg-a@x.com"} + # Written to /_cursors/.json + assert (tmp_path / "_cursors" / "fakemail.json").exists() + + +def test_cursor_store_survives_corrupt_file(tmp_path): + cursors = FileCursorStore(root=tmp_path) + cursors.set("fakemail", "a@x.com", {"last_seen": "1"}) + (tmp_path / "_cursors" / "fakemail.json").write_text("{not json", "utf-8") + assert cursors.get("fakemail", "a@x.com") is None # harmless loss + cursors.set("fakemail", "a@x.com", {"last_seen": "2"}) + assert cursors.get("fakemail", "a@x.com") == {"last_seen": "2"} + + +# ── failure isolation ──────────────────────────────────────────────────── + + +def test_crash_loop_pauses_instance_and_isolates_others(tmp_path): + provider = FakeProvider(crash_for=("b@y.com",)) + system, sink, cursors, manager = build(tmp_path, provider, max_failures=3) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + system.store_credential("fakemail", "b@y.com", cred("b@y.com")) + + async def main(): + await manager.reconcile() + bad = manager._instances[("fakemail", "b@y.com")] + assert await eventually(lambda: bad.state == "paused") + assert bad.failures == 3 + status = manager.status() + assert status["fakemail:b@y.com"]["state"] == "paused" + assert status["fakemail:b@y.com"]["detail"] == PAUSED_STATUS + # The healthy sibling keeps running and its events keep flowing. + assert status["fakemail:a@x.com"]["state"] in ("running", "idle") + assert ("fakemail", "a@x.com", {"kind": "mail", "for": "a@x.com"}) in [ + (p, i, e) for p, i, e in sink.events + ] + + # A plain reconcile (no account/credential change) leaves it paused + # — no new listener is built for the paused identity. + built_before = len(provider.built) + await manager.reconcile() + assert manager._instances[("fakemail", "b@y.com")].state == "paused" + assert len(provider.built) == built_before + + # Re-auth (credential change) is what revives it. + system.store_credential("fakemail", "b@y.com", cred("b@y.com", "new")) + await manager.reconcile() + revived = manager._instances[("fakemail", "b@y.com")] + assert revived is not bad and revived.failures == 0 + await manager.stop() + + asyncio.run(main()) + + +def test_credential_change_restarts_instance(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com", "old")) + + async def main(): + await manager.reconcile() + original = manager._instances[("fakemail", "a@x.com")].listener + assert provider.built[0]["client"]["token"] == "old-a@x.com" + + # No change → no restart. + await manager.reconcile() + assert manager._instances[("fakemail", "a@x.com")].listener is original + + # Re-auth with a new token → exactly this instance restarts, + # rebuilt against the new credential. + system.store_credential("fakemail", "a@x.com", cred("a@x.com", "new")) + await manager.reconcile() + replacement = manager._instances[("fakemail", "a@x.com")].listener + assert replacement is not original + assert original.stop_called + assert provider.built[-1]["client"]["token"] == "new-a@x.com" + await manager.stop() + + asyncio.run(main()) + + +# ── stagger ────────────────────────────────────────────────────────────── + + +def test_same_provider_pollers_are_staggered(tmp_path): + provider = FakeProvider(poll_interval=60.0) + system, sink, cursors, manager = build(tmp_path, provider) + for identity in ("a@x.com", "b@y.com", "c@z.com"): + system.store_credential("fakemail", identity, cred(identity)) + + async def main(): + await manager.reconcile() + delays = sorted( + info["delay"] for info in manager.status().values() + ) + assert delays == [0.0, 20.0, 40.0] # k * (60 / 3) + await manager.stop() + + asyncio.run(main()) + + +# ── system integration ─────────────────────────────────────────────────── + + +def test_system_mutations_trigger_reconcile(tmp_path): + provider = FakeProvider() + system, sink, cursors, manager = build(tmp_path, provider) + system.listeners = manager + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + + async def main(): + await manager.reconcile() + assert running_keys(manager) == {("fakemail", "a@x.com")} + + # set_listening schedules a reconcile by itself — no manual call. + system.set_listening("fakemail", "a@x.com", False) + assert await eventually(lambda: running_keys(manager) == set()) + + system.set_listening("fakemail", "a@x.com", True) + assert await eventually( + lambda: running_keys(manager) == {("fakemail", "a@x.com")} + ) + + # apply_account_changes schedules one too. + system.apply_account_changes( + "fakemail", {"listen": {"a@x.com": False}} + ) + assert await eventually(lambda: running_keys(manager) == set()) + await manager.stop() + + asyncio.run(main()) + + +def test_reconcile_listeners_without_manager_is_noop(tmp_path): + provider = FakeProvider() + system, _, _, _ = build(tmp_path, provider) + system.store_credential("fakemail", "a@x.com", cred("a@x.com")) + # No manager attached, no running loop — must not raise. + system.reconcile_listeners() + system.set_listening("fakemail", "a@x.com", False) diff --git a/tests/integrations/test_login.py b/tests/integrations/test_login.py new file mode 100644 index 00000000..31ad6696 --- /dev/null +++ b/tests/integrations/test_login.py @@ -0,0 +1,281 @@ +"""Provider run_login flows and IntegrationSystem.add_account. + +The OAuth dance itself is monkeypatched at OAuthFlow.run — these tests +assert the surrounding contract: which authorize params the flow was +given, how identity is extracted, and what credential shape is returned. + +No pytest-asyncio in this repo — async paths are driven with asyncio.run. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.contracts import LEGACY_IDENTITY +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.oauth_flow import OAuthFlow +from craftos_integrations.providers.hubspot.provider import HubSpotProvider +from craftos_integrations.providers.linkedin.provider import LinkedInProvider +from craftos_integrations.providers.notion.provider import NotionProvider +from craftos_integrations.providers.outlook.provider import OutlookProvider +from craftos_integrations.providers.slack.provider import SlackProvider + +from .conftest import cred +from .test_system import FakeProvider + + +def run(coro): + return asyncio.run(coro) + + +def patch_flow(monkeypatch, result): + """Stub OAuthFlow.run with a canned result, capturing the effective + per-run flow config (authorize params, endpoint).""" + captured = {} + + async def fake_run(self): + captured["extra"] = dict(self.extra_auth_params) + captured["auth_url"] = self.auth_url + return result + + monkeypatch.setattr(OAuthFlow, "run", fake_run) + return captured + + +# ════════════════════════════════════════════════════════════════════════ +# run_login — one smoke per provider +# ════════════════════════════════════════════════════════════════════════ + + +def test_outlook_run_login_extracts_upn_and_forces_chooser(monkeypatch): + captured = patch_flow( + monkeypatch, + { + "access_token": "at", + "refresh_token": "rt", + "expires_in": 3600, + "userinfo": {"mail": "User@Corp.com", "userPrincipalName": "u@corp.com"}, + "raw": {}, + }, + ) + identity, credential, message = run(OutlookProvider().run_login()) + assert identity == "user@corp.com" # mail outranks UPN, lowercased + assert credential["access_token"] == "at" + assert credential["refresh_token"] == "rt" + assert "user@corp.com" in message + # The chooser fix this port exists for + the carried legacy param. + assert captured["extra"]["prompt"] == "select_account" + assert captured["extra"]["response_mode"] == "query" + # The shared handler flow is copied, never mutated. + from craftos_integrations.integrations.outlook import OutlookHandler + + assert "prompt" not in OutlookHandler.oauth.extra_auth_params + + +def test_outlook_run_login_refuses_identityless_result(monkeypatch): + patch_flow( + monkeypatch, + {"access_token": "at", "refresh_token": "", "expires_in": 0, "userinfo": {}, "raw": {}}, + ) + identity, credential, message = run(OutlookProvider().run_login()) + # Documented judgment call: Graph /me always returns a UPN on success, + # so an empty userinfo means the fetch failed — re-prompt, don't store. + assert identity is None + assert credential is None + assert "try again" in message.lower() + + +def test_linkedin_run_login_no_fictitious_params(monkeypatch): + captured = patch_flow( + monkeypatch, + { + "access_token": "at", + "refresh_token": "rt", + "expires_in": 5184000, + "userinfo": {"email": "Me@Corp.com", "sub": "AbC123", "name": "Me"}, + "raw": {}, + }, + ) + identity, credential, message = run(LinkedInProvider().run_login()) + assert identity == "me@corp.com" + assert credential["email"] == "Me@Corp.com" + assert credential["sub"] == "AbC123" + assert credential["linkedin_id"] == "AbC123" + assert LinkedInProvider().identity_of(credential) == identity + # LinkedIn's OAuth has NO chooser param — nothing may be invented here. + assert captured["extra"] == {} + + +def test_linkedin_run_login_identityless_still_returns_credential(monkeypatch): + patch_flow( + monkeypatch, + {"access_token": "at", "refresh_token": "", "expires_in": 0, "userinfo": {}, "raw": {}}, + ) + identity, credential, message = run(LinkedInProvider().run_login()) + assert identity is None + assert credential is not None # stored under LEGACY_IDENTITY by the core + assert credential["access_token"] == "at" + + +def test_notion_run_login_workspace_identity(monkeypatch): + captured = patch_flow( + monkeypatch, + { + "access_token": "ntok", + "refresh_token": "", + "expires_in": 0, + "userinfo": {}, + "raw": {"workspace_id": "WS-1", "bot_id": "B1", "workspace_name": "Acme"}, + }, + ) + identity, credential, message = run(NotionProvider().run_login()) + assert identity == "ws-1" + assert credential["token"] == "ntok" # legacy client key, accepted by build_client + assert credential["workspace_name"] == "Acme" + assert NotionProvider().identity_of(credential) == identity + assert "Acme" in message + assert captured["extra"] == {"owner": "user"} # same as the legacy flow + + +def test_hubspot_run_login_introspects_hub_id(monkeypatch): + patch_flow( + monkeypatch, + {"access_token": "hs-at", "refresh_token": "hs-rt", "expires_in": 1800, "userinfo": {}, "raw": {}}, + ) + import craftos_integrations.providers.hubspot.provider as hs + + calls = [] + + def fake_request(method, url, **kwargs): + calls.append(url) + return {"result": {"hub_id": 12345, "hub_domain": "acme.hubspot.com", "user": "me@acme.com"}} + + monkeypatch.setattr(hs, "http_request", fake_request) + identity, credential, message = run(HubSpotProvider().run_login()) + assert identity == "12345" + assert credential["hub_id"] == "12345" + assert credential["auth_kind"] == "oauth" + assert credential["user_email"] == "me@acme.com" + assert "acme.hubspot.com" in message + assert any("access-tokens/hs-at" in url for url in calls) + + +def test_hubspot_run_login_survives_failed_introspection(monkeypatch): + patch_flow( + monkeypatch, + {"access_token": "hs-at", "refresh_token": "hs-rt", "expires_in": 1800, "userinfo": {}, "raw": {}}, + ) + import craftos_integrations.providers.hubspot.provider as hs + + monkeypatch.setattr(hs, "http_request", lambda *a, **k: {"error": "HTTP 500"}) + identity, credential, message = run(HubSpotProvider().run_login()) + assert identity is None + assert credential is not None # the token itself is valid — keep it + assert credential["access_token"] == "hs-at" + assert "legacy" in message + + +def test_slack_run_login_team_identity(monkeypatch): + patch_flow( + monkeypatch, + { + "access_token": "xoxb-1", + "refresh_token": "", + "expires_in": 0, + "userinfo": {}, + "raw": {"ok": True, "access_token": "xoxb-1", "team": {"id": "T123", "name": "Acme"}}, + }, + ) + identity, credential, message = run(SlackProvider().run_login()) + assert identity == "t123" + assert credential["bot_token"] == "xoxb-1" + assert credential["workspace_id"] == "T123" + assert "Acme" in message + + +def test_slack_run_login_surfaces_ok_false(monkeypatch): + patch_flow( + monkeypatch, + { + "access_token": "", + "refresh_token": "", + "expires_in": 0, + "userinfo": {}, + "raw": {"ok": False, "error": "invalid_code"}, + }, + ) + identity, credential, message = run(SlackProvider().run_login()) + assert identity is None and credential is None + assert "invalid_code" in message + + +def test_run_login_oauth_error_fails_cleanly(monkeypatch): + patch_flow(monkeypatch, {"error": "access_denied"}) + for provider in (OutlookProvider(), LinkedInProvider(), NotionProvider(), SlackProvider()): + identity, credential, message = run(provider.run_login()) + assert identity is None and credential is None + assert "access_denied" in message + + +# ════════════════════════════════════════════════════════════════════════ +# IntegrationSystem.add_account +# ════════════════════════════════════════════════════════════════════════ + + +class LoginFakeProvider(FakeProvider): + """FakeProvider with a canned run_login result.""" + + def __init__(self, pid, login_result): + super().__init__(pid) + self.login_result = login_result + + async def run_login(self): + return self.login_result + + +def make_system(tmp_path, *providers): + return IntegrationSystem(store=FileCredentialStore(root=tmp_path), providers=list(providers)) + + +def test_add_account_success_stores_and_lists(tmp_path): + provider = LoginFakeProvider( + "slack", ("t1", {"email": "t1", "bot_token": "xoxb"}, "Slack connected") + ) + system = make_system(tmp_path, provider) + ok, message, accounts = run(system.add_account("slack")) + assert ok is True + assert message == "Slack connected" + assert [a.identity for a in accounts] == ["t1"] + assert accounts[0].is_primary + # The integration system writes ONLY the AccountSet document — no legacy mirror file. + assert (tmp_path / "slack.accounts.json").exists() + assert not (tmp_path / "slack.json").exists() + + +def test_add_account_failure_returns_current_accounts(tmp_path): + provider = LoginFakeProvider("slack", (None, None, "Slack OAuth failed: denied")) + system = make_system(tmp_path, provider) + system.store_credential("slack", "t0", cred("t0")) + ok, message, accounts = run(system.add_account("slack")) + assert ok is False + assert "denied" in message + assert [a.identity for a in accounts] == ["t0"] # untouched + + +def test_add_account_identityless_stores_legacy_sentinel(tmp_path): + provider = LoginFakeProvider("linkedin", (None, {"access_token": "at"}, "connected")) + system = make_system(tmp_path, provider) + ok, message, accounts = run(system.add_account("linkedin")) + assert ok is True + assert [a.identity for a in accounts] == [LEGACY_IDENTITY] + + +def test_add_account_without_run_login_raises(tmp_path): + system = make_system(tmp_path, FakeProvider("gmail")) + with pytest.raises(LookupError, match="interactive login"): + run(system.add_account("gmail")) + with pytest.raises(LookupError, match="Unknown integration"): + run(system.add_account("github")) diff --git a/tests/integrations/test_management_actions.py b/tests/integrations/test_management_actions.py new file mode 100644 index 00000000..895dbaae --- /dev/null +++ b/tests/integrations/test_management_actions.py @@ -0,0 +1,307 @@ +"""Agent-facing integration-management actions routed through the integration system. + +Covers the legacy-decommission cutover for the 10 multi-account providers: +- check_integration_status reads connection state + accounts from + IntegrationSystem.list_accounts (plan-§6 line format + structured array), +- connect_integration's manual-token path validates like the legacy + handler login but stores via IntegrationSystem.store_credential, +- disconnect_integration removes accounts (targeted and disconnect-all). + +Loads app/data/action/integrations/integration_management.py the way the +action loader does (file-location import) and drives the registered +handlers directly against a tmp-rooted credential store. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] + + +@pytest.fixture(scope="module") +def action_registry(): + """Import the management-action module once; return the action registry.""" + from agent_core.core.action_framework.registry import registry_instance + + path = ( + REPO + / "app" + / "data" + / "action" + / "integrations" + / "integration_management.py" + ) + spec = importlib.util.spec_from_file_location( + "test_integration_management_mod", path + ) + module = importlib.util.module_from_spec(spec) + sys.modules["test_integration_management_mod"] = module + spec.loader.exec_module(module) + return registry_instance + + +def _run(action_registry, name, input_data): + handler = action_registry.get_action_implementation(name).handler + result = handler(input_data) + if asyncio.iscoroutine(result): + result = asyncio.run(result) + return result + + +@pytest.fixture +def v2_system(tmp_path, monkeypatch): + """Singleton system pointed at a tmp credentials dir.""" + from craftos_integrations.config import ConfigStore + + import app.integrations as bootstrap + + monkeypatch.setattr(ConfigStore, "project_root", tmp_path) + bootstrap.reset_system() + yield bootstrap.get_system() + bootstrap.reset_system() + + +@pytest.fixture +def gmail_two_accounts(v2_system): + cred = lambda email: {"email": email, "access_token": f"tok-{email}"} + v2_system.store_credential("gmail", "a@x.com", cred("a@x.com")) + v2_system.store_credential("gmail", "b@y.com", cred("b@y.com")) + v2_system.set_alias("gmail", "b@y.com", "school") + return v2_system + + +# ── check_integration_status ───────────────────────────────────────────── + + +def test_status_shows_v2_accounts(action_registry, gmail_two_accounts): + result = _run(action_registry, "check_integration_status", {"integration_id": "gmail"}) + assert result["status"] == "success" + assert result["connected"] is True + assert result["accounts"] == [ + {"identity": "a@x.com", "alias": None, "isPrimary": True, "listen": True}, + {"identity": "b@y.com", "alias": "school", "isPrimary": False, "listen": True}, + ] + # Shared plan-§6 status-line format. + assert "- a@x.com (a@x.com) [primary]" in result["message"] + assert "- school (b@y.com)" in result["message"] + assert "2 account(s)" in result["message"] + + +def test_status_v2_not_connected(action_registry, v2_system): + result = _run( + action_registry, "check_integration_status", {"integration_id": "slack"} + ) + assert result["status"] == "success" + assert result["connected"] is False + assert result["accounts"] == [] + assert "not connected" in result["message"] + + +def test_status_normalizes_aliases_to_v2_ids(action_registry, gmail_two_accounts): + # 'mail' → gmail via the alias table; still served by the integration system. + result = _run( + action_registry, "check_integration_status", {"integration_id": "mail"} + ) + assert result["connected"] is True + assert len(result["accounts"]) == 2 + + +# ── connect_integration (manual token → account store) ──────────────────────── + + +def test_slack_token_connect_stores_through_v2( + action_registry, v2_system, monkeypatch, tmp_path +): + import craftos_integrations.integrations.slack as slack_mod + + calls = [] + + def fake_slack_call(method, path, headers, **kw): + calls.append((method, path, headers)) + return {"ok": True, "team_id": "T999", "team": "Acme"} + + monkeypatch.setattr(slack_mod, "_slack_call", fake_slack_call) + + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "slack", + "credentials": {"bot_token": "xoxb-test-token"}, + "auth_method": "token", + }, + ) + assert result == { + "status": "success", + "message": "Slack connected: Acme (T999)", + "auth_type": "token", + } + # Verified exactly like the legacy login: auth.test with the bot token. + assert calls == [ + ("POST", "auth.test", {"Authorization": "Bearer xoxb-test-token"}) + ] + # Stored through the integration system under the team-id identity... + accounts = v2_system.list_accounts("slack") + assert [a.identity for a in accounts] == ["t999"] + stored = v2_system.accounts.credential_for("slack", "t999") + assert stored["bot_token"] == "xoxb-test-token" + assert stored["workspace_id"] == "T999" + assert stored["team_name"] == "Acme" + + +def test_slack_token_connect_rejects_bad_token(action_registry, v2_system): + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "slack", + "credentials": {"bot_token": "not-a-slack-token"}, + "auth_method": "token", + }, + ) + assert result["status"] == "error" + assert "xoxb-" in result["message"] + assert v2_system.list_accounts("slack") == [] + + +def test_slack_token_connect_auth_failure_stores_nothing( + action_registry, v2_system, monkeypatch +): + import craftos_integrations.integrations.slack as slack_mod + + monkeypatch.setattr( + slack_mod, "_slack_call", lambda *a, **k: {"error": "invalid_auth"} + ) + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "slack", + "credentials": {"bot_token": "xoxb-revoked"}, + "auth_method": "token", + }, + ) + assert result["status"] == "error" + assert "invalid_auth" in result["message"] + assert v2_system.list_accounts("slack") == [] + + +def test_notion_token_connect_lands_on_legacy_sentinel( + action_registry, v2_system, monkeypatch +): + """Token-only Notion credentials carry no workspace id — plan §7 says + they live under the LEGACY sentinel until an OAuth re-auth upgrades + them in place.""" + import craftos_integrations.integrations.notion as notion_mod + + monkeypatch.setattr( + notion_mod, + "_notion_call", + lambda method, path, headers, **kw: {"bot": {"workspace_name": "Acme WS"}}, + ) + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "notion", + "credentials": {"token": "secret_abc"}, + "auth_method": "token", + }, + ) + assert result == { + "status": "success", + "message": "Notion connected: Acme WS", + "auth_type": "token", + } + accounts = v2_system.list_accounts("notion") + assert [a.identity for a in accounts] == ["legacy"] + assert v2_system.accounts.credential_for("notion", "legacy") == { + "token": "secret_abc" + } + + +def test_hubspot_token_connect_uses_hub_id_identity( + action_registry, v2_system, monkeypatch +): + import craftos_integrations.integrations.hubspot as hubspot_mod + import app.data.action.integrations._helpers as helpers_mod # noqa: F401 + + def fake_request(method, url, headers=None, expected=None, **kw): + assert url.endswith("/account-info/v3/details") + assert headers == {"Authorization": "Bearer pat-na1-xyz"} + return {"result": {"portalId": 424242, "uiDomain": "app.hubspot.com"}} + + # The verifier resolves `request` from craftos_integrations.helpers at + # call time. + import craftos_integrations.helpers as ci_helpers + + monkeypatch.setattr(ci_helpers, "request", fake_request) + + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "hubspot", + "credentials": {"access_token": "pat-na1-xyz"}, + "auth_method": "token", + }, + ) + assert result["status"] == "success" + assert "app.hubspot.com" in result["message"] + accounts = v2_system.list_accounts("hubspot") + assert [a.identity for a in accounts] == ["424242"] + stored = v2_system.accounts.credential_for("hubspot", "424242") + assert stored["access_token"] == "pat-na1-xyz" + assert stored["auth_kind"] == "token" + + +# ── disconnect_integration ─────────────────────────────────────────────── + + +def test_disconnect_all_removes_v2_accounts_and_stale_legacy_file( + action_registry, gmail_two_accounts, tmp_path +): + # A surviving pre-multi-account credential file (as on a migrated install) must be + # deleted with the last account — otherwise the one-time upgrade + # migration would re-import it and resurrect the disconnected account. + legacy = tmp_path / ".credentials" / "gmail.json" + legacy.parent.mkdir(parents=True, exist_ok=True) + legacy.write_text('{"email": "a@x.com", "access_token": "stale"}') + + result = _run( + action_registry, "disconnect_integration", {"integration_id": "gmail"} + ) + assert result["status"] == "success" + assert "2 account(s)" in result["message"] + assert gmail_two_accounts.list_accounts("gmail") == [] + assert not legacy.exists() # deleted with the last account + assert gmail_two_accounts.list_accounts("gmail") == [] # no resurrection + + +def test_disconnect_targeted_account_by_alias(action_registry, gmail_two_accounts): + result = _run( + action_registry, + "disconnect_integration", + {"integration_id": "gmail", "account_id": "school"}, + ) + assert result["status"] == "success" + assert "b@y.com" in result["message"] + remaining = gmail_two_accounts.list_accounts("gmail") + assert [a.identity for a in remaining] == ["a@x.com"] + assert remaining[0].is_primary + + +def test_disconnect_v2_id_with_nothing_connected(action_registry, v2_system): + result = _run( + action_registry, "disconnect_integration", {"integration_id": "slack"} + ) + # Same shape as the legacy behavior: an error explaining nothing is + # connected. + assert result["status"] == "error" + assert "No Slack credentials" in result["message"] diff --git a/tests/integrations/test_migration.py b/tests/integrations/test_migration.py new file mode 100644 index 00000000..33752c4d --- /dev/null +++ b/tests/integrations/test_migration.py @@ -0,0 +1,132 @@ +"""The one-time legacy upgrade migration, and sentinel upgrade on re-auth. + +AccountManager itself never reads pre-multi-account single-credential files — the +migration lives one layer up, in ``IntegrationSystem._migrate_legacy``: +a legacy file with NO AccountSet document (a user upgrading from ≤ V1.4.2) +is imported as the first account, with a provider-derived identity +(LEGACY sentinel if the credential predates identity capture). Once the +document exists the legacy file is never consulted again, and removing the +last account deletes the legacy file too — so a disconnect can never be +resurrected by the migration. +""" + +from __future__ import annotations + +import json + +from craftos_integrations.contracts import LEGACY_IDENTITY +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem + +from .conftest import cred + + +def _write_legacy(tmp_path, pid, payload): + (tmp_path / f"{pid}.json").write_text(json.dumps(payload), encoding="utf-8") + + +def test_legacy_file_alone_is_ignored(mgr, tmp_path): + _write_legacy(tmp_path, "notion", {"token": "secret"}) + assert mgr.list_accounts("notion") == [] + assert mgr.load_set("notion") is None + + +def test_ignoring_legacy_leaves_the_file_untouched(mgr, tmp_path): + _write_legacy(tmp_path, "notion", {"token": "secret"}) + mgr.list_accounts("notion") + assert (tmp_path / "notion.json").exists() + assert json.loads((tmp_path / "notion.json").read_text()) == { + "token": "secret" + } + + +def test_reauth_upgrades_sentinel_in_place_never_duplicates(mgr, tmp_path): + # A sentinel account can still exist (e.g. an identity-less OAuth + # success, or a migrated credential without a derivable identity); + # seed one directly. + mgr.upsert_account("linkedin", LEGACY_IDENTITY, {"access_token": "old"}) + mgr.set_alias("linkedin", LEGACY_IDENTITY, "me") + mgr.set_listening("linkedin", LEGACY_IDENTITY, False) + + stored = mgr.upsert_account("linkedin", "A@Corp.com", cred("a@corp.com")) + + assert stored == "a@corp.com" + accounts = mgr.list_accounts("linkedin") + assert [a.identity for a in accounts] == ["a@corp.com"] # no duplicate + upgraded = accounts[0] + assert upgraded.is_primary + assert upgraded.alias == "me" # alias survived the upgrade + assert upgraded.listen is False # listen flag survived + assert mgr.credential_for("linkedin", "a@corp.com")["access_token"] == "tok-a@corp.com" + + +def test_upsert_refuses_empty_identity(mgr): + import pytest + + with pytest.raises(ValueError, match="unaddressable"): + mgr.upsert_account("gmail", "", cred("x")) + with pytest.raises(ValueError, match="unaddressable"): + mgr.upsert_account("gmail", None, cred("x")) + + +def test_no_legacy_no_v2_reads_as_disconnected(mgr): + assert mgr.list_accounts("gmail") == [] + assert mgr.load_set("gmail") is None + + +# ════════════════════════════════════════════════════════════════════════ +# System-level one-time migration (IntegrationSystem._migrate_legacy) +# ════════════════════════════════════════════════════════════════════════ + + +def _system(tmp_path): + from .test_system import FakeProvider + + return IntegrationSystem( + store=FileCredentialStore(root=tmp_path), + providers=[FakeProvider("gmail")], + ) + + +def test_system_migrates_legacy_file_on_first_load(tmp_path): + _write_legacy(tmp_path, "gmail", cred("old@x.com")) + system = _system(tmp_path) + accounts = system.list_accounts("gmail") + assert [a.identity for a in accounts] == ["old@x.com"] # real identity + assert accounts[0].is_primary + assert (tmp_path / "gmail.accounts.json").exists() + # The legacy file is left in place until disconnect — but is never + # consulted again once the document exists: + _write_legacy(tmp_path, "gmail", cred("intruder@x.com")) + assert [a.identity for a in system.list_accounts("gmail")] == ["old@x.com"] + + +def test_system_migrates_identityless_credential_to_sentinel(tmp_path): + _write_legacy(tmp_path, "gmail", {"access_token": "tok"}) # no email + system = _system(tmp_path) + assert [a.identity for a in system.list_accounts("gmail")] == [LEGACY_IDENTITY] + + +def test_disconnect_after_migration_deletes_legacy_and_never_resurrects(tmp_path): + _write_legacy(tmp_path, "gmail", cred("old@x.com")) + system = _system(tmp_path) + assert [a.identity for a in system.list_accounts("gmail")] == ["old@x.com"] + + system.remove_account("gmail", "old@x.com") + + assert not (tmp_path / "gmail.accounts.json").exists() # document gone + assert not (tmp_path / "gmail.json").exists() # legacy file gone too + # ...so the migration has nothing to re-import: no resurrection. + assert system.list_accounts("gmail") == [] + assert not (tmp_path / "gmail.accounts.json").exists() + + +def test_batch_disconnect_all_also_deletes_legacy(tmp_path): + _write_legacy(tmp_path, "gmail", cred("old@x.com")) + system = _system(tmp_path) + system.list_accounts("gmail") # migrate + + system.apply_account_changes("gmail", {"disconnect": ["old@x.com"]}) + + assert not (tmp_path / "gmail.json").exists() + assert system.list_accounts("gmail") == [] diff --git a/tests/integrations/test_mutations.py b/tests/integrations/test_mutations.py new file mode 100644 index 00000000..c61466af --- /dev/null +++ b/tests/integrations/test_mutations.py @@ -0,0 +1,198 @@ +"""Mutations: upsert, remove, primary, listen, aliases (incl. family), batch.""" + +from __future__ import annotations + +import pytest + +from craftos_integrations.contracts import AccountResolutionError +from craftos_integrations.core.accounts import AccountManager + +from .conftest import _family, cred + + +# ── upsert ─────────────────────────────────────────────────────────────── + + +def test_first_account_becomes_primary(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + accounts = mgr.list_accounts("gmail") + assert accounts[0].is_primary and accounts[0].identity == "a@x.com" + + +def test_second_account_does_not_steal_primary(two_accounts): + accounts = two_accounts.list_accounts("gmail") + assert [a.identity for a in accounts] == ["a@x.com", "b@y.com"] + assert accounts[0].is_primary and not accounts[1].is_primary + + +def test_reauth_updates_credential_in_place(two_accounts): + two_accounts.upsert_account("gmail", "A@X.com", {"access_token": "fresh"}) + accounts = two_accounts.list_accounts("gmail") + assert len(accounts) == 2 # no duplicate from case difference + assert two_accounts.credential_for("gmail", "a@x.com") == {"access_token": "fresh"} + assert accounts[0].alias == "work" # alias untouched by re-auth + + +# ── remove ─────────────────────────────────────────────────────────────── + + +def test_remove_secondary(two_accounts): + two_accounts.remove_account("gmail", "school") + assert [a.identity for a in two_accounts.list_accounts("gmail")] == ["a@x.com"] + + +def test_remove_primary_promotes_oldest_remaining(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("gmail", "b@y.com", cred("b@y.com")) + mgr.upsert_account("gmail", "c@z.com", cred("c@z.com")) + mgr.remove_account("gmail", "a@x.com") + accounts = mgr.list_accounts("gmail") + assert accounts[0].identity == "b@y.com" # oldest remaining + assert accounts[0].is_primary + + +def test_remove_last_account_deletes_document(mgr, tmp_path): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.remove_account("gmail", "a@x.com") + assert mgr.list_accounts("gmail") == [] + assert not (tmp_path / "gmail.accounts.json").exists() + + +def test_failed_remove_has_no_side_effects(two_accounts): + with pytest.raises(AccountResolutionError): + two_accounts.remove_account("gmail", "nope") + assert len(two_accounts.list_accounts("gmail")) == 2 + + +# ── primary / listen ───────────────────────────────────────────────────── + + +def test_set_primary_by_alias(two_accounts): + two_accounts.set_primary("gmail", "school") + accounts = two_accounts.list_accounts("gmail") + assert accounts[0].identity == "b@y.com" and accounts[0].is_primary + + +def test_listen_defaults_true_and_toggles(two_accounts): + assert all(a.listen for a in two_accounts.list_accounts("gmail")) + two_accounts.set_listening("gmail", "school", False) + by_id = {a.identity: a for a in two_accounts.list_accounts("gmail")} + assert by_id["b@y.com"].listen is False + assert by_id["a@x.com"].listen is True + + +# ── aliases ────────────────────────────────────────────────────────────── + + +def test_duplicate_alias_rejected(two_accounts): + with pytest.raises(ValueError, match="already the nickname"): + two_accounts.set_alias("gmail", "b@y.com", "work") + + +def test_alias_clear(two_accounts): + two_accounts.set_alias("gmail", "b@y.com", None) + by_id = {a.identity: a for a in two_accounts.list_accounts("gmail")} + assert by_id["b@y.com"].alias is None + + +def test_alias_propagates_across_google_family(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("google_calendar", "a@x.com", cred("a@x.com")) + mgr.set_alias("gmail", "a@x.com", "work") + calendar = mgr.list_accounts("google_calendar") + assert calendar[0].alias == "work" + assert mgr.resolve("google_calendar", "work") == "a@x.com" + + +def test_alias_uniqueness_is_family_wide(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("google_calendar", "b@y.com", cred("b@y.com")) + mgr.set_alias("gmail", "a@x.com", "work") + with pytest.raises(ValueError, match="already the nickname"): + mgr.set_alias("google_calendar", "b@y.com", "work") + + +def test_sync_family_aliases_heals_partial_write(mgr, store): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("google_calendar", "a@x.com", cred("a@x.com")) + mgr.set_alias("gmail", "a@x.com", "work") + # Simulate a partial family write: calendar's copy reverted out-of-band + # to an older alias state. + raw = store.load("google_calendar") + raw["accounts"]["a@x.com"]["alias"] = "stale" + raw["accounts"]["a@x.com"]["alias_updated_at"] = "2020-01-01T00:00:00+00:00" + store.replace("google_calendar", raw) + + mgr.sync_family_aliases("google_calendar") + assert mgr.list_accounts("google_calendar")[0].alias == "work" + + +def test_alias_dies_with_account_and_is_reusable(two_accounts): + two_accounts.remove_account("gmail", "school") + two_accounts.upsert_account("gmail", "c@z.com", cred("c@z.com")) + two_accounts.set_alias("gmail", "c@z.com", "school") # no leak, no clash + assert two_accounts.resolve("gmail", "school") == "c@z.com" + + +# ── batched UI save ────────────────────────────────────────────────────── + + +def test_apply_changes_runs_in_deterministic_order(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("gmail", "b@y.com", cred("b@y.com")) + mgr.upsert_account("gmail", "c@z.com", cred("c@z.com")) + result = mgr.apply_changes( + "gmail", + { + "disconnect": ["a@x.com"], # removes the current primary + "primary": "c@z.com", # then explicit primary choice wins + "aliases": {"c@z.com": "main"}, + "listen": {"b@y.com": False}, + }, + ) + by_id = {a.identity: a for a in result} + assert set(by_id) == {"b@y.com", "c@z.com"} + assert by_id["c@z.com"].is_primary and by_id["c@z.com"].alias == "main" + assert by_id["b@y.com"].listen is False + + +def test_apply_changes_ui_wire_batch_alias_survives_reopen(store, clock): + """Regression (Manage-modal alias bug hunt): the EXACT wire shape the + frontend sends on "Save changes" — empty disconnect list, null primary, + aliases keyed by identity, empty listen map — must persist the alias so a + fresh manager over the same store (= closing and reopening the modal) + still sees it, with alias_updated_at stamped.""" + mgr = AccountManager(store, family_members=_family, clock=clock) + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("gmail", "b@y.com", cred("b@y.com")) + + result = mgr.apply_changes( + "gmail", + {"disconnect": [], "primary": None, + "aliases": {"b@y.com": "jobsearch"}, "listen": {}}, + ) + assert {a.identity: a.alias for a in result} == { + "a@x.com": None, "b@y.com": "jobsearch", + } + + # "Reopen": a brand-new manager over the same store, after the family + # alias sync that every UI list path runs. + reopened = AccountManager(store, family_members=_family, clock=clock) + reopened.sync_family_aliases("gmail") + assert {a.identity: a.alias for a in reopened.list_accounts("gmail")} == { + "a@x.com": None, "b@y.com": "jobsearch", + } + raw = store.load("gmail") + assert raw["accounts"]["b@y.com"]["alias_updated_at"] # stamped + + +def test_apply_changes_failure_keeps_earlier_valid_steps(mgr): + mgr.upsert_account("gmail", "a@x.com", cred("a@x.com")) + mgr.upsert_account("gmail", "b@y.com", cred("b@y.com")) + with pytest.raises(AccountResolutionError): + mgr.apply_changes( + "gmail", + {"disconnect": ["b@y.com"], "primary": "ghost@nowhere.com"}, + ) + # The disconnect (individually atomic and valid) stayed applied. + assert [a.identity for a in mgr.list_accounts("gmail")] == ["a@x.com"] diff --git a/tests/integrations/test_notion_provider.py b/tests/integrations/test_notion_provider.py new file mode 100644 index 00000000..177176df --- /dev/null +++ b/tests/integrations/test_notion_provider.py @@ -0,0 +1,125 @@ +"""Notion provider — conformance + wiring. + +No network: the client API method is stubbed. What's real is the full +chain execute() → resolve → bind → client method → shaped result. +""" + +from __future__ import annotations + +import asyncio + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.notion import NotionProvider +from craftos_integrations.providers.notion.provider import BoundNotionClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Real OAuth-response shape (workspace-scoped token; no expiry fields). +NOTION_CRED = { + "access_token": "secret-at-1", + "workspace_id": "WS-1234-ABCD", # mixed case: identity must lowercase it + "workspace_name": "Acme", + "bot_id": "Bot-99", +} + +# Pre-multi-account notion.json shape — token only, no identity → LEGACY_IDENTITY. +LEGACY_CRED = {"token": "secret_legacytoken"} + + +class TestNotionConformance(ProviderConformance): + provider = NotionProvider() + credential_fixtures = [ + NOTION_CRED, + LEGACY_CRED, # identity-less pre-multi-account shape → None + {}, # junk + ] + + +def test_identity_is_workspace_id_lowercased(): + provider = NotionProvider() + assert provider.identity_of(NOTION_CRED) == "ws-1234-abcd" + # bot id is the fallback when workspace id is missing + assert provider.identity_of({"bot_id": "Bot-99"}) == "bot-99" + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + + +def test_oauth_spec_is_the_native_workspace_picker(): + spec = NotionProvider().oauth_spec() + assert spec.authorize_url == "https://api.notion.com/v1/oauth/authorize" + assert spec.token_url == "https://api.notion.com/v1/oauth/token" + assert spec.extra_authorize_params["owner"] == "user" + assert spec.has_chooser # Notion's authorize page picks the workspace + + +def test_refresh_is_none_tokens_do_not_expire(): + assert run(NotionProvider().refresh(dict(NOTION_CRED))) is None + + +def test_binding_accepts_both_token_key_shapes(): + client = BoundNotionClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(dict(NOTION_CRED), lambda c: None) + assert client.has_credentials() + assert client._load().token == "secret-at-1" + + legacy_client = BoundNotionClient() + legacy_client.bind_credential(dict(LEGACY_CRED), lambda c: None) + assert legacy_client._load().token == "secret_legacytoken" + + +def test_execute_runs_operation_against_resolved_accounts_client( + tmp_path, monkeypatch +): + system = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[NotionProvider()] + ) + system.store_credential("notion", "ws-1234-abcd", dict(NOTION_CRED)) + system.store_credential( + "notion", + "ws-other", + {**NOTION_CRED, "workspace_id": "ws-other", "access_token": "secret-at-2"}, + ) + system.set_alias("notion", "ws-other", "company") + + seen = [] + + def fake_search(self, query, filter_type=None, page_size=100): + seen.append((self._cred.token, query, filter_type)) + return [ + { + "id": "p1", + "object": "page", + "url": "https://notion.so/p1", + "properties": { + "Name": {"type": "title", "title": [{"plain_text": "Roadmap"}]} + }, + } + ] + + monkeypatch.setattr(BoundNotionClient, "search", fake_search) + + result = run( + system.execute( + "notion", "search_notion", {"query": "roadmap"}, account="company" + ) + ) + assert result["status"] == "success" + # lean shaping (default include_metadata=False) mirrors the legacy action + assert result["result"] == [ + { + "id": "p1", + "object": "page", + "title": "Roadmap", + "url": "https://notion.so/p1", + } + ] + assert seen == [("secret-at-2", "roadmap", None)] # company workspace's client + + run(system.execute("notion", "search_notion", {"query": "roadmap"})) + assert seen[-1] == ("secret-at-1", "roadmap", None) # primary by default diff --git a/tests/integrations/test_outlook_provider.py b/tests/integrations/test_outlook_provider.py new file mode 100644 index 00000000..65b81ae8 --- /dev/null +++ b/tests/integrations/test_outlook_provider.py @@ -0,0 +1,200 @@ +"""Outlook provider — first non-Google provider WITH token refresh. + +No network: HTTP is monkeypatched; client API methods are stubbed. What's +real is conformance, the credential binding, refresh-persistence routing +through the core (incl. Microsoft's refresh-token rotation), the +select_account chooser fix, and the full chain execute() → resolve → +bind → client method → shaped result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import craftos_integrations.providers.outlook.provider as outlook_mod +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.outlook import OutlookProvider +from craftos_integrations.providers.outlook.provider import BoundOutlookClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +OUTLOOK_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "email": "a@contoso.com", +} + + +class TestOutlookConformance(ProviderConformance): + provider = OutlookProvider() + credential_fixtures = [ + OUTLOOK_CRED, # real login shape (email/UPN captured) + {"access_token": "at", "email": " User@Contoso.com "}, # messy shape + {"access_token": "at", "refresh_token": "rt"}, # no-email legacy → None + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_email(): + provider = OutlookProvider() + assert provider.identity_of(OUTLOOK_CRED) == "a@contoso.com" + assert provider.identity_of({"email": " User@Contoso.com "}) == "user@contoso.com" + assert provider.identity_of({"access_token": "at"}) is None + assert provider.identity_of({"email": " "}) is None + + +def test_oauth_spec_matches_legacy_handler_and_carries_the_chooser_fix(): + spec = OutlookProvider().oauth_spec() + assert ( + spec.authorize_url + == "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + ) + assert spec.token_url == "https://login.microsoftonline.com/common/oauth2/v2.0/token" + assert "Mail.Send" in spec.scopes and "offline_access" in spec.scopes + # THE multi-account fix: without select_account, "Add account" silently + # re-auths the browser's signed-in Microsoft account. + assert spec.extra_authorize_params["prompt"] == "select_account" + assert spec.extra_authorize_params["response_mode"] == "query" # legacy param + assert spec.has_chooser + + +def test_binding_replaces_disk_plumbing(): + client = BoundOutlookClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(OUTLOOK_CRED, lambda c: None) + assert client.has_credentials() + assert client._load().email == "a@contoso.com" + assert client._load().access_token == "at-1" + + +def test_refresh_persists_through_core_not_disk(monkeypatch): + persisted = {} + + def fake_http(method, url, **kwargs): + assert url == outlook_mod.MS_TOKEN_URL + data = kwargs["data"] + assert data["refresh_token"] == "rt-1" + assert data["grant_type"] == "refresh_token" + assert data["scope"] == outlook_mod.OUTLOOK_SCOPES + assert "client_secret" not in data # PKCE public client + return { + "result": { + "access_token": "at-2", + "refresh_token": "rt-2", # Microsoft rotates refresh tokens + "expires_in": 3600, + } + } + + monkeypatch.setattr(outlook_mod, "http_request", fake_http) + client = BoundOutlookClient() + client.bind_credential(dict(OUTLOOK_CRED), persisted.update) + token = client.refresh_access_token() + assert token == "at-2" + assert persisted["access_token"] == "at-2" + assert persisted["refresh_token"] == "rt-2" # rotated token persisted + assert persisted["email"] == "a@contoso.com" # identity carried forward + + +def test_refresh_keeps_old_refresh_token_when_not_rotated(monkeypatch): + persisted = {} + monkeypatch.setattr( + outlook_mod, + "http_request", + lambda *a, **k: {"result": {"access_token": "at-2", "expires_in": 3600}}, + ) + client = BoundOutlookClient() + client.bind_credential(dict(OUTLOOK_CRED), persisted.update) + assert client.refresh_access_token() == "at-2" + assert persisted["refresh_token"] == "rt-1" # carried forward + + +def test_refresh_failure_returns_none_and_persists_nothing(monkeypatch): + persisted = {} + monkeypatch.setattr( + outlook_mod, "http_request", lambda *a, **k: {"error": "invalid_grant"} + ) + client = BoundOutlookClient() + client.bind_credential(dict(OUTLOOK_CRED), persisted.update) + assert client.refresh_access_token() is None + assert persisted == {} + + +def test_provider_refresh_returns_refreshed_credential(monkeypatch): + """Out-of-band refresh (GoogleProviderBase.refresh style): the provider + returns the refreshed dict for the core to store.""" + monkeypatch.setattr( + outlook_mod, + "http_request", + lambda *a, **k: {"result": {"access_token": "at-2", "expires_in": 3600}}, + ) + refreshed = run(OutlookProvider().refresh(dict(OUTLOOK_CRED))) + assert refreshed is not None + assert refreshed["access_token"] == "at-2" + assert refreshed["email"] == "a@contoso.com" + + monkeypatch.setattr( + outlook_mod, "http_request", lambda *a, **k: {"error": "invalid_grant"} + ) + assert run(OutlookProvider().refresh(dict(OUTLOOK_CRED))) is None + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[OutlookProvider()] + ) + sys.store_credential("outlook", "a@contoso.com", dict(OUTLOOK_CRED)) + sys.store_credential( + "outlook", + "b@fabrikam.com", + {**OUTLOOK_CRED, "email": "b@fabrikam.com", "access_token": "at-b"}, + ) + sys.set_alias("outlook", "b@fabrikam.com", "work") + return sys + + +def test_execute_runs_operation_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_list_emails(self, n=10, unread_only=False, folder="inbox"): + seen.append((self._cred.email, n, unread_only)) + return {"ok": True, "result": {"emails": [], "count": 0}} + + monkeypatch.setattr(BoundOutlookClient, "list_emails", fake_list_emails) + + result = run( + system.execute("outlook", "list_outlook_emails", {"count": 3}, account="work") + ) + assert result == {"status": "success", "result": {"emails": [], "count": 0}} + assert seen == [("b@fabrikam.com", 3, False)] # work account's client, mapped args + + run(system.execute("outlook", "list_outlook_emails", {})) + assert seen[-1] == ("a@contoso.com", 10, False) # primary + legacy defaults + + +def test_operation_error_shape_is_agent_friendly(system, monkeypatch): + monkeypatch.setattr( + BoundOutlookClient, + "send_email", + lambda self, **k: {"error": "API error: 403", "details": "insufficient scope"}, + ) + result = run( + system.execute( + "outlook", + "send_outlook_email", + {"to": "x@y.com", "subject": "s", "body": "b"}, + account="a@contoso.com", + ) + ) + assert result["status"] == "error" + assert "403" in result["message"] diff --git a/tests/integrations/test_provider_listeners.py b/tests/integrations/test_provider_listeners.py new file mode 100644 index 00000000..2a3b5eed --- /dev/null +++ b/tests/integrations/test_provider_listeners.py @@ -0,0 +1,444 @@ +"""PR 5 — provider listeners. + +Per real listener (gmail / outlook / slack), with the client's HTTP layer +monkeypatched: start → synthetic incoming event → ``emit`` receives the +exact payload shape the legacy ``ExternalCommsManager`` built from +``PlatformMessage``; ``cursor()`` round-trips into a fresh listener that +does NOT re-emit the same event; ``stop()`` terminates cleanly. Plus: all +ten providers accept the 3-arg ``make_listener``. + +No pytest-asyncio in this repo — async paths are driven with asyncio.run. +""" + +from __future__ import annotations + +import asyncio +import time + +import craftos_integrations.integrations.gmail as gmail_mod +import craftos_integrations.integrations.outlook as outlook_mod +import craftos_integrations.integrations.slack as slack_mod +import craftos_integrations.providers.slack.listener as slack_listener_mod +from craftos_integrations.providers import default_providers +from craftos_integrations.providers.gmail.provider import GmailProvider +from craftos_integrations.providers.outlook.provider import OutlookProvider +from craftos_integrations.providers.slack.provider import SlackProvider + + +def run(coro): + return asyncio.run(coro) + + +async def wait_until(predicate, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + await asyncio.sleep(0.01) + return False + + +async def settle(): + """Let in-flight callbacks finish after a fake API call was observed.""" + await asyncio.sleep(0.05) + + +def collector(): + events = [] + + async def emit(event): + events.append(event) + + return events, emit + + +# ════════════════════════════════════════════════════════════════════════ +# Gmail +# ════════════════════════════════════════════════════════════════════════ + +GMAIL_CRED = { + "access_token": "tok", + "refresh_token": "ref", + "token_expiry": time.time() + 3600, + "client_id": "cid", + "client_secret": "cs", + "email": "me@x.com", +} + +GMAIL_MESSAGE = { + "id": "m1", + "threadId": "t1", + "snippet": "hello there", + "payload": { + "headers": [ + {"name": "From", "value": "Alice "}, + {"name": "Subject", "value": "Hi"}, + {"name": "Date", "value": "Tue, 11 Aug 2026 10:00:00 +0000"}, + ] + }, +} + + +class FakeGmailAPI: + """Serves profile / history.list / messages.get like the Gmail REST API.""" + + def __init__(self): + self.profile_calls = 0 + self.history_calls = 0 + self.history_response = { + "historyId": "101", + "history": [ + {"messagesAdded": [{"message": {"id": "m1", "labelIds": ["INBOX"]}}]} + ], + } + + async def arequest(self, method, url, **kwargs): + if url.endswith("/users/me/profile"): + self.profile_calls += 1 + return {"result": {"emailAddress": "me@x.com", "historyId": "100"}} + if url.endswith("/users/me/history"): + self.history_calls += 1 + return {"result": self.history_response} + if "/users/me/messages/" in url: + assert url.rsplit("/", 1)[1] == "m1" + return {"result": GMAIL_MESSAGE} + raise AssertionError(f"unexpected URL {url}") + + +def _gmail_setup(monkeypatch): + fake = FakeGmailAPI() + monkeypatch.setattr(gmail_mod, "arequest", fake.arequest) + # Config file may not exist in the test env; serve the default (toggle on). + monkeypatch.setattr(gmail_mod, "load_config", lambda *a, **k: gmail_mod.GmailConfig()) + provider = GmailProvider() + client = provider.build_client(dict(GMAIL_CRED), lambda d: None) + return fake, provider, client + + +class TestGmailListener: + def test_start_emits_payload_and_cursor(self, monkeypatch): + fake, provider, client = _gmail_setup(monkeypatch) + events, emit = collector() + listener = provider.make_listener(client, None, emit) + assert listener.poll_interval == gmail_mod.POLL_INTERVAL + + async def scenario(): + await listener.start() + assert await wait_until(lambda: events) + cursor = listener.cursor() + await listener.stop() + return cursor + + cursor = run(scenario()) + + assert fake.profile_calls == 1 # fresh start baselines from profile + assert events == [ + { + "source": "Gmail", + "integrationType": "gmail", + "contactId": "alice@x.com", + "contactName": "Alice", + "messageBody": "Subject: Hi\nhello there", + "channelId": "t1", + "channelName": "", + "messageId": "m1", + "is_self_message": False, + "raw": GMAIL_MESSAGE, + } + ] + assert cursor == {"history_id": "101", "seen_ids": ["m1"]} + assert client._poll_task is None # stop() tore the task down + + def test_cursor_resume_does_not_reemit(self, monkeypatch): + fake, provider, client = _gmail_setup(monkeypatch) + events, emit = collector() + cursor = {"history_id": "101", "seen_ids": ["m1"]} + listener = provider.make_listener(client, dict(cursor), emit) + + async def scenario(): + await listener.start() + assert await wait_until(lambda: fake.history_calls >= 1) + await settle() + await listener.stop() + + run(scenario()) + + assert events == [] # m1 replayed by history.list but deduped + assert fake.profile_calls == 0 # resume never re-baselines + assert listener.cursor() == cursor # round-trip stable + + def test_self_messages_are_dropped(self, monkeypatch): + fake, provider, client = _gmail_setup(monkeypatch) + monkeypatch.setitem( + GMAIL_MESSAGE["payload"]["headers"][0], "value", "Me " + ) + events, emit = collector() + listener = provider.make_listener(client, None, emit) + + async def scenario(): + await listener.start() + assert await wait_until(lambda: fake.history_calls >= 1) + await settle() + await listener.stop() + + run(scenario()) + assert events == [] + + +# ════════════════════════════════════════════════════════════════════════ +# Outlook +# ════════════════════════════════════════════════════════════════════════ + +OUTLOOK_CRED = { + "access_token": "tok", + "refresh_token": "ref", + "token_expiry": time.time() + 3600, + "client_id": "cid", + "email": "me@o.com", +} + +OUTLOOK_MESSAGE = { + "id": "om1", + "from": {"emailAddress": {"address": "bob@x.com", "name": "Bob"}}, + "subject": "Yo", + "bodyPreview": "preview text", + "receivedDateTime": "2026-08-12T10:00:00Z", + "conversationId": "conv1", +} + + +class FakeGraphAPI: + def __init__(self): + self.profile_calls = 0 + self.messages_calls = 0 + self.last_filter = None + + async def arequest(self, method, url, **kwargs): + if url.endswith("/me"): + self.profile_calls += 1 + return {"result": {"mail": "me@o.com"}} + if url.endswith("/me/messages"): + self.messages_calls += 1 + self.last_filter = (kwargs.get("params") or {}).get("$filter") + return {"result": {"value": [OUTLOOK_MESSAGE]}} + raise AssertionError(f"unexpected URL {url}") + + +def _outlook_setup(monkeypatch): + fake = FakeGraphAPI() + monkeypatch.setattr(outlook_mod, "arequest", fake.arequest) + provider = OutlookProvider() + client = provider.build_client(dict(OUTLOOK_CRED), lambda d: None) + return fake, provider, client + + +class TestOutlookListener: + def test_start_emits_payload_and_cursor(self, monkeypatch): + fake, provider, client = _outlook_setup(monkeypatch) + events, emit = collector() + listener = provider.make_listener(client, None, emit) + assert listener.poll_interval == outlook_mod.POLL_INTERVAL + + async def scenario(): + await listener.start() + assert await wait_until(lambda: events) + cursor = listener.cursor() + await listener.stop() + return cursor + + cursor = run(scenario()) + + assert fake.profile_calls == 1 + assert events == [ + { + "source": "Outlook", + "integrationType": "outlook", + "contactId": "bob@x.com", + "contactName": "Bob", + "messageBody": "Subject: Yo\npreview text", + "channelId": "conv1", + "channelName": "", + "messageId": "om1", + "is_self_message": False, + "raw": OUTLOOK_MESSAGE, + } + ] + # Watermark advanced to the newest receivedDateTime; dedup ids kept. + assert cursor == { + "last_poll_time": "2026-08-12T10:00:00Z", + "seen_ids": ["om1"], + } + assert client._poll_task is None + + def test_cursor_resume_does_not_reemit(self, monkeypatch): + fake, provider, client = _outlook_setup(monkeypatch) + events, emit = collector() + cursor = {"last_poll_time": "2026-08-12T10:00:00Z", "seen_ids": ["om1"]} + listener = provider.make_listener(client, dict(cursor), emit) + + async def scenario(): + await listener.start() + assert await wait_until(lambda: fake.messages_calls >= 1) + await settle() + await listener.stop() + + run(scenario()) + + assert events == [] # om1 in the overlap window but deduped + # The Graph query resumed from the persisted watermark, not "now". + assert fake.last_filter == "receivedDateTime ge 2026-08-12T10:00:00Z" + assert listener.cursor() == cursor + + +# ════════════════════════════════════════════════════════════════════════ +# Slack +# ════════════════════════════════════════════════════════════════════════ + +SLACK_CRED = {"bot_token": "xoxb-1", "workspace_id": "T1", "team_name": "Team"} + + +class FakeSlackAPI: + """Routes _slack_acall by endpoint; history honors the ``oldest`` ts + watermark exclusively, like conversations.history does by default.""" + + def __init__(self, messages): + self.messages = messages + self.auth_calls = 0 + self.list_calls = 0 + self.history_calls = 0 + + async def acall(self, method, path, headers, **kw): + params = kw.get("params") or {} + if path == "auth.test": + self.auth_calls += 1 + return {"ok": True, "user_id": "UBOT"} + if path == "conversations.list": + self.list_calls += 1 + return { + "channels": [{"id": "C1", "is_member": True}], + "response_metadata": {}, + } + if path == "conversations.history": + self.history_calls += 1 + oldest = float(params.get("oldest", "0")) + return { + "messages": [ + m for m in self.messages if float(m["ts"]) > oldest + ] + } + raise AssertionError(f"unexpected Slack call {path}") + + +def _slack_setup(monkeypatch, messages): + fake = FakeSlackAPI(messages) + # Both the legacy client module and the listener module bind the name. + monkeypatch.setattr(slack_mod, "_slack_acall", fake.acall) + monkeypatch.setattr(slack_listener_mod, "_slack_acall", fake.acall) + provider = SlackProvider() + client = provider.build_client(dict(SLACK_CRED), lambda d: None) + monkeypatch.setattr( + client, + "get_user_info", + lambda user_id: {"ok": True, "user": {"profile": {"display_name": "Zed"}}}, + ) + return fake, provider, client + + +class TestSlackListener: + def test_start_emits_payload_and_cursor(self, monkeypatch): + msg_ts = f"{time.time() + 10:.6f}" # after the catch-up watermark + message = {"ts": msg_ts, "user": "U2", "text": "hello"} + fake, provider, client = _slack_setup(monkeypatch, [message]) + events, emit = collector() + listener = provider.make_listener(client, None, emit) + assert listener.poll_interval == slack_mod.POLL_INTERVAL + + async def scenario(): + await listener.start() + assert await wait_until(lambda: events) + cursor = listener.cursor() + await listener.stop() + return cursor + + cursor = run(scenario()) + + assert fake.auth_calls == 1 + assert client._bot_user_id == "UBOT" + assert events == [ + { + "source": "Slack", + "integrationType": "slack", + "contactId": "U2", + "contactName": "Zed", + "messageBody": "hello", + "channelId": "C1", + "channelName": "", + "messageId": msg_ts, + "is_self_message": False, + "raw": message, + } + ] + assert cursor == {"last_timestamps": {"C1": msg_ts}} + assert not client._listening # stop() flagged the loop off + + def test_cursor_resume_does_not_reemit(self, monkeypatch): + msg_ts = f"{time.time() + 10:.6f}" + message = {"ts": msg_ts, "user": "U2", "text": "hello"} + fake, provider, client = _slack_setup(monkeypatch, [message]) + events, emit = collector() + cursor = {"last_timestamps": {"C1": msg_ts}} + listener = provider.make_listener(client, dict(cursor), emit) + + async def scenario(): + await listener.start() + assert await wait_until(lambda: fake.history_calls >= 1) + await settle() + await listener.stop() + + run(scenario()) + + assert events == [] # ts watermark excludes the already-seen message + assert listener.cursor() == cursor + + def test_bot_and_self_messages_are_dropped(self, monkeypatch): + future = time.time() + 10 + messages = [ + {"ts": f"{future:.6f}", "user": "UBOT", "text": "own message"}, + {"ts": f"{future + 1:.6f}", "bot_id": "B9", "text": "bot message"}, + {"ts": f"{future + 2:.6f}", "user": "U3", "subtype": "channel_join"}, + ] + fake, provider, client = _slack_setup(monkeypatch, messages) + events, emit = collector() + listener = provider.make_listener(client, None, emit) + + async def scenario(): + await listener.start() + assert await wait_until(lambda: fake.history_calls >= 1) + await settle() + await listener.stop() + + run(scenario()) + assert events == [] + + +# ════════════════════════════════════════════════════════════════════════ +# All providers: the 3-arg contract +# ════════════════════════════════════════════════════════════════════════ + + +def test_every_provider_accepts_three_arg_make_listener(): + async def emit(event): # no-op + pass + + providers = default_providers() + assert len(providers) == 10 + with_listeners = set() + for provider in providers: + listener = provider.make_listener(object(), None, emit) + if listener is not None: + with_listeners.add(provider.id) + assert hasattr(listener, "start") + assert hasattr(listener, "stop") + assert hasattr(listener, "cursor") + assert listener.poll_interval > 0 + assert with_listeners == {"gmail", "outlook", "slack"} diff --git a/tests/integrations/test_resolution.py b/tests/integrations/test_resolution.py new file mode 100644 index 00000000..f1a5d3e2 --- /dev/null +++ b/tests/integrations/test_resolution.py @@ -0,0 +1,70 @@ +"""Every rule of the account-resolution contract (plan §4).""" + +from __future__ import annotations + +import pytest + +from craftos_integrations.contracts import AccountResolutionError + +from .conftest import cred + + +def test_empty_hint_resolves_to_primary(two_accounts): + assert two_accounts.resolve("gmail", None) == "a@x.com" + assert two_accounts.resolve("gmail", "") == "a@x.com" + assert two_accounts.resolve("gmail", " ") == "a@x.com" + + +def test_exact_identity_match_case_insensitive(two_accounts): + assert two_accounts.resolve("gmail", "B@Y.COM") == "b@y.com" + + +def test_identity_always_outranks_alias(mgr): + # The abandoned PR's wrong-account bug: an alias equal to another + # account's real email must never steal its resolution. set_alias + # refuses to create that state; even if legacy data contains it, exact + # identity wins because rule 2 runs before rule 3. + mgr.upsert_account("gmail", "one@x.com", cred("one@x.com")) + mgr.upsert_account("gmail", "two@x.com", cred("two@x.com")) + with pytest.raises(ValueError, match="another connected account's identity"): + mgr.set_alias("gmail", "one@x.com", "two@x.com") + assert mgr.resolve("gmail", "two@x.com") == "two@x.com" + + +def test_exact_alias_match(two_accounts): + assert two_accounts.resolve("gmail", "school") == "b@y.com" + assert two_accounts.resolve("gmail", "SCHOOL") == "b@y.com" + + +def test_unique_substring_of_identity(two_accounts): + assert two_accounts.resolve("gmail", "b@y") == "b@y.com" + + +def test_unique_substring_of_alias(two_accounts): + assert two_accounts.resolve("gmail", "scho") == "b@y.com" + + +def test_ambiguous_substring_lists_candidates(two_accounts): + with pytest.raises(AccountResolutionError) as err: + two_accounts.resolve("gmail", "com") # matches both identities + message = str(err.value) + assert "a@x.com" in message and "b@y.com" in message + assert "work" in message and "school" in message + + +def test_no_match_lists_connected_accounts(two_accounts): + with pytest.raises(AccountResolutionError) as err: + two_accounts.resolve("gmail", "nope") + message = str(err.value) + assert "No gmail account matches 'nope'" in message + assert "a@x.com" in message and "b@y.com" in message + + +def test_non_string_hint_is_rejected_with_helpful_error(two_accounts): + with pytest.raises(AccountResolutionError, match="must be a string"): + two_accounts.resolve("gmail", ["work"]) # LLMs emit lists sometimes + + +def test_not_connected(mgr): + with pytest.raises(AccountResolutionError, match="not connected"): + mgr.resolve("gmail", "anything") diff --git a/tests/integrations/test_slack_provider.py b/tests/integrations/test_slack_provider.py new file mode 100644 index 00000000..a67c1211 --- /dev/null +++ b/tests/integrations/test_slack_provider.py @@ -0,0 +1,138 @@ +"""Slack provider — the first non-Google provider. + +No network: client API methods are stubbed. What's real is conformance, +the credential binding, and the full chain execute() → resolve → bind → +client method → shaped result (incl. the legacy pick_result shaping). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.slack import SlackProvider +from craftos_integrations.providers.slack.provider import BoundSlackClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +SLACK_CRED = { + "bot_token": "xoxb-acme-token", + "workspace_id": "T0AB12CD3", + "team_name": "Acme", +} + + +class TestSlackConformance(ProviderConformance): + provider = SlackProvider() + credential_fixtures = [ + SLACK_CRED, # real OAuth/login shape (team id captured) + {"bot_token": "xoxb-old-token"}, # pre-identity legacy shape → None + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_team_id(): + provider = SlackProvider() + assert provider.identity_of(SLACK_CRED) == "t0ab12cd3" + assert provider.identity_of({"bot_token": "xoxb-old-token"}) is None + assert provider.identity_of({"workspace_id": " "}) is None + + +def test_oauth_spec_matches_legacy_handler(): + spec = SlackProvider().oauth_spec() + assert spec.authorize_url == "https://slack.com/oauth/v2/authorize" + assert spec.token_url == "https://slack.com/api/oauth.v2.access" + assert "chat:write" in spec.scopes and "channels:read" in spec.scopes + assert spec.has_chooser # Slack's authorize page has a workspace picker + + +def test_binding_replaces_disk_plumbing(): + client = BoundSlackClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential(SLACK_CRED, lambda c: None) + assert client.has_credentials() + assert client._load().bot_token == "xoxb-acme-token" + assert client._load().workspace_id == "T0AB12CD3" + + +def test_refresh_is_a_noop_for_non_expiring_tokens(): + assert run(SlackProvider().refresh(dict(SLACK_CRED))) is None + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[SlackProvider()] + ) + sys.store_credential("slack", "t0ab12cd3", dict(SLACK_CRED)) + sys.store_credential( + "slack", + "t9zz99xy8", + { + "bot_token": "xoxb-beta-token", + "workspace_id": "T9ZZ99XY8", + "team_name": "Beta", + }, + ) + sys.set_alias("slack", "t9zz99xy8", "beta") + return sys + + +def test_execute_runs_operation_against_resolved_workspaces_client( + system, monkeypatch +): + seen = [] + + async def fake_send_message(self, recipient, text, **kwargs): + seen.append( + (self._cred.workspace_id, recipient, text, kwargs.get("thread_ts")) + ) + # Slack-style body: "ok" sits alongside the payload fields. + return { + "ok": True, + "channel": recipient, + "ts": "111.222", + "message": {"text": text}, + } + + monkeypatch.setattr(BoundSlackClient, "send_message", fake_send_message) + + result = run( + system.execute( + "slack", + "send_slack_message", + {"channel": "C1", "text": "hi"}, + account="beta", + ) + ) + # ok-envelope collapsed + legacy pick_result(["channel", "ts"]) shaping. + assert result == {"status": "success", "result": {"channel": "C1", "ts": "111.222"}} + assert seen == [("T9ZZ99XY8", "C1", "hi", None)] # beta workspace's client + + run(system.execute("slack", "send_slack_message", {"channel": "C2", "text": "yo"})) + assert seen[-1][0] == "T0AB12CD3" # primary workspace by default + + +def test_operation_error_shape_is_agent_friendly(system, monkeypatch): + async def fake_send_message(self, recipient, text, **kwargs): + return {"error": "not_in_channel", "details": {"ok": False}} + + monkeypatch.setattr(BoundSlackClient, "send_message", fake_send_message) + result = run( + system.execute( + "slack", + "send_slack_message", + {"channel": "C1", "text": "hi"}, + account="t0ab12cd3", + ) + ) + assert result["status"] == "error" + assert "not_in_channel" in result["message"] diff --git a/tests/integrations/test_storage.py b/tests/integrations/test_storage.py new file mode 100644 index 00000000..bf37c001 --- /dev/null +++ b/tests/integrations/test_storage.py @@ -0,0 +1,86 @@ +"""FileCredentialStore: atomicity, quarantine, permissions, legacy reads.""" + +from __future__ import annotations + +import json +import os +import stat + +import pytest + +import craftos_integrations.core.storage as storage_mod + + +DOC = {"version": 2, "primary": "a@x.com", "accounts": {}} + + +def test_replace_then_load_roundtrip(store): + store.replace("gmail", DOC) + assert store.load("gmail") == DOC + + +def test_replace_is_atomic_under_crash(store, tmp_path, monkeypatch): + store.replace("gmail", DOC) + real_replace = os.replace + + def crash(src, dst): + raise OSError("simulated crash between tmp-write and rename") + + monkeypatch.setattr(storage_mod.os, "replace", crash) + with pytest.raises(OSError): + store.replace("gmail", {"version": 2, "primary": "clobbered", "accounts": {}}) + monkeypatch.setattr(storage_mod.os, "replace", real_replace) + # The original document survived untouched. + assert store.load("gmail") == DOC + + +def test_corrupt_document_is_quarantined_not_silently_empty(store, tmp_path): + path = tmp_path / "gmail.accounts.json" + path.write_text("{this is not json", encoding="utf-8") + assert store.load("gmail") is None + assert not path.exists() + quarantined = tmp_path / "gmail.accounts.json.corrupt" + assert quarantined.exists() + assert quarantined.read_text(encoding="utf-8") == "{this is not json" + + +def test_written_files_are_owner_only(store, tmp_path): + store.replace("gmail", DOC) + mode = stat.S_IMODE(os.stat(tmp_path / "gmail.accounts.json").st_mode) + assert mode == (stat.S_IRUSR | stat.S_IWUSR) + + +def test_load_legacy_reads_bare_file_and_never_mutates_it(store, tmp_path): + legacy = {"email": "a@x.com", "access_token": "tok"} + (tmp_path / "gmail.json").write_text(json.dumps(legacy), encoding="utf-8") + assert store.load_legacy("gmail") == legacy + assert json.loads((tmp_path / "gmail.json").read_text()) == legacy + + +def test_load_legacy_corrupt_is_skipped_and_left_alone(store, tmp_path): + (tmp_path / "gmail.json").write_text("garbage", encoding="utf-8") + assert store.load_legacy("gmail") is None + assert (tmp_path / "gmail.json").read_text() == "garbage" + + +def test_legacy_filename_override(tmp_path): + store = storage_mod.FileCredentialStore( + root=tmp_path, legacy_filenames={"gmail": "google_gmail.json"} + ) + (tmp_path / "google_gmail.json").write_text(json.dumps({"a": 1}), encoding="utf-8") + assert store.load_legacy("gmail") == {"a": 1} + + +def test_delete_missing_is_noop(store): + store.delete("gmail") # no raise + assert store.load("gmail") is None + + +def test_delete_legacy_removes_file_and_is_noop_when_absent(tmp_path): + store = storage_mod.FileCredentialStore( + root=tmp_path, legacy_filenames={"gmail": "google_gmail.json"} + ) + (tmp_path / "google_gmail.json").write_text(json.dumps({"a": 1}), encoding="utf-8") + store.delete_legacy("gmail") # honors the filename override + assert not (tmp_path / "google_gmail.json").exists() + store.delete_legacy("gmail") # no raise on second call diff --git a/tests/integrations/test_system.py b/tests/integrations/test_system.py new file mode 100644 index 00000000..a7c0c46e --- /dev/null +++ b/tests/integrations/test_system.py @@ -0,0 +1,152 @@ +"""IntegrationSystem: execute() routing, client caching, invalidation. + +No pytest-asyncio in this repo — async paths are driven with asyncio.run. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import pytest + +from craftos_integrations.contracts import ( + AccountResolutionError, + OAuthSpec, + Operation, +) +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem + +from .conftest import cred + + +def run(coro): + return asyncio.run(coro) + + +@dataclass +class FakeClient: + credential: Dict[str, Any] + calls: List[str] = field(default_factory=list) + + +class FakeProvider: + def __init__(self, pid: str, family: Optional[str] = None): + self.id = pid + self.family = family + self.built: List[FakeClient] = [] + + def identity_of(self, credential): + return credential.get("email") + + def oauth_spec(self): + return OAuthSpec(authorize_url="https://auth", token_url="https://token") + + def build_client(self, credential, persist): + client = FakeClient(credential) + self.built.append(client) + return client + + async def refresh(self, credential): + return None + + def operations(self): + async def whoami(client, input_data): + client.calls.append("whoami") + return {"status": "success", "email": client.credential["email"]} + + return [ + Operation( + name="whoami", + description="Report which account this ran as.", + input_schema={}, + output_schema={"email": {"type": "string"}}, + fn=whoami, + ) + ] + + def guidance(self): + return f"## {self.id} guidance" + + def make_listener(self, client, cursor, emit): + return None + + +@pytest.fixture +def system(tmp_path): + gmail = FakeProvider("gmail", family="google") + calendar = FakeProvider("google_calendar", family="google") + slack = FakeProvider("slack") + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), + providers=[gmail, calendar, slack], + ) + sys.store_credential("gmail", "a@x.com", cred("a@x.com")) + sys.store_credential("gmail", "b@y.com", cred("b@y.com")) + sys.set_alias("gmail", "b@y.com", "school") + return sys + + +def test_execute_routes_to_resolved_account(system): + assert run(system.execute("gmail", "whoami", {}, account="school"))["email"] == "b@y.com" + assert run(system.execute("gmail", "whoami", {}))["email"] == "a@x.com" # → primary + + +def test_client_cached_by_identity_not_hint(system): + run(system.execute("gmail", "whoami", {}, account="school")) + run(system.execute("gmail", "whoami", {}, account="SCHOOL")) + run(system.execute("gmail", "whoami", {}, account="b@y.com")) + assert len(system.registry.get("gmail").built) == 1 # one client, three spellings + + +def test_bad_hint_never_pollutes_cache_and_is_llm_friendly(system): + with pytest.raises(AccountResolutionError) as err: + run(system.execute("gmail", "whoami", {}, account="ghost")) + assert "Connected gmail accounts" in str(err.value) + assert system.registry.get_cached_client("gmail", "ghost") is None + + +def test_set_alias_invalidates_cached_client(system): + run(system.execute("gmail", "whoami", {}, account="school")) + system.set_alias("gmail", "b@y.com", "uni") + run(system.execute("gmail", "whoami", {}, account="uni")) + assert len(system.registry.get("gmail").built) == 2 # rebuilt after alias change + + +def test_remove_account_invalidates_and_repoints_primary(system): + system.remove_account("gmail", "a@x.com") + assert run(system.execute("gmail", "whoami", {}))["email"] == "b@y.com" + + +def test_unknown_provider_and_operation(system): + with pytest.raises(LookupError, match="Unknown integration"): + system.operations("github") + with pytest.raises(LookupError, match="no operation 'nope'"): + run(system.execute("gmail", "nope", {})) + + +def test_guidance_connected_only(system): + text = system.guidance(connected_only=True) + assert "gmail" in text + assert "slack" not in text # not connected + assert "slack" in system.guidance(connected_only=False) + + +def test_family_alias_visible_from_sibling(system): + system.store_credential("google_calendar", "b@y.com", cred("b@y.com")) + system.set_alias("gmail", "b@y.com", "uni") + infos = system.list_accounts("google_calendar") + assert infos[-1].alias == "uni" + + +def test_apply_account_changes_end_to_end(system): + result = system.apply_account_changes( + "gmail", + {"primary": "school", "aliases": {"a@x.com": "personal"}}, + ) + by_id = {a.identity: a for a in result} + assert by_id["b@y.com"].is_primary + assert by_id["a@x.com"].alias == "personal" + assert run(system.execute("gmail", "whoami", {}))["email"] == "b@y.com" diff --git a/tests/integrations/test_ws_account_handlers.py b/tests/integrations/test_ws_account_handlers.py new file mode 100644 index 00000000..0232ae72 --- /dev/null +++ b/tests/integrations/test_ws_account_handlers.py @@ -0,0 +1,474 @@ +"""WS multi-account handlers on BrowserAdapter (PR 4 backend). + +No pytest-asyncio in this repo — async paths are driven with asyncio.run. + +The adapter is instantiated without __init__ (object.__new__) and given a +recording ``_broadcast`` plus a stub ``_handle_integration_list``, so the +handlers run in isolation: no aiohttp server, no real websockets. The +integration system and the legacy facade functions are replaced with fakes via +monkeypatching ``app.integrations.get_system`` and the names imported +into the browser_adapter module namespace. +""" + +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Tuple + +import pytest + +import app.integrations as integrations +import app.ui_layer.adapters.browser_adapter as ba +from app.ui_layer.adapters.browser_adapter import BrowserAdapter +from craftos_integrations.contracts import AccountInfo, AccountResolutionError + + +# ── harness ────────────────────────────────────────────────────────────── + + +def make_adapter() -> Tuple[BrowserAdapter, List[Dict[str, Any]]]: + """A BrowserAdapter with only the state the integration handlers touch.""" + adapter = object.__new__(BrowserAdapter) + adapter._oauth_tasks = {} + sent: List[Dict[str, Any]] = [] + + async def _broadcast(message: Dict[str, Any]) -> None: + sent.append(message) + + async def _list_stub() -> None: + sent.append({"type": "integration_list", "data": {"stub": True}}) + + adapter._broadcast = _broadcast + adapter._handle_integration_list = _list_stub + return adapter, sent + + +async def drain_tasks() -> None: + """Await every task spawned by a handler (handlers use create_task).""" + while True: + others = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + if not others: + return + await asyncio.gather(*others) + + +def results_of(sent: List[Dict[str, Any]], msg_type: str) -> List[Dict[str, Any]]: + return [m["data"] for m in sent if m["type"] == msg_type] + + +def acct(identity: str, alias: Optional[str] = None, primary: bool = False, + listen: bool = True) -> AccountInfo: + return AccountInfo( + identity=identity, alias=alias, is_primary=primary, listen=listen, + added_at="2026-08-10T00:00:00+00:00", + ) + + +class FakeSystem: + """Just enough of IntegrationSystem for the WS handlers.""" + + def __init__(self, known=("gmail",), accounts: Optional[List[AccountInfo]] = None): + self._known = set(known) + self._accounts = list(accounts or []) + self.removed: List[Tuple[str, str]] = [] + self.applied: List[Tuple[str, Dict[str, Any]]] = [] + self.add_result: Tuple[bool, str, Optional[List[AccountInfo]]] = ( + True, "Connected", None, + ) + self.apply_error: Optional[Exception] = None + + class _Registry: + def get(_self, pid): + return object() if pid in self._known else None + + self.registry = _Registry() + + def list_accounts(self, provider_id: str) -> List[AccountInfo]: + return list(self._accounts) + + async def add_account(self, provider_id: str): + ok, message, accounts = self.add_result + return ok, message, self._accounts if accounts is None else accounts + + def apply_account_changes(self, provider_id: str, batch: Dict[str, Any]): + if self.apply_error is not None: + raise self.apply_error + self.applied.append((provider_id, batch)) + return list(self._accounts) + + def remove_account(self, provider_id: str, hint: Optional[str]) -> str: + match = next( + (a for a in self._accounts if hint in (a.identity, a.alias)), None + ) + if match is None: + raise AccountResolutionError(f"No account matching '{hint}'") + self._accounts.remove(match) + self.removed.append((provider_id, match.identity)) + return match.identity + + +TWO = lambda: [acct("a@x.com", "work", primary=True), acct("b@y.com", "school")] + +WIRE_TWO = [ + {"identity": "a@x.com", "alias": "work", "isPrimary": True, "listen": True}, + {"identity": "b@y.com", "alias": "school", "isPrimary": False, "listen": True}, +] + + +@pytest.fixture +def system(monkeypatch): + fake = FakeSystem(known=("gmail",), accounts=TWO()) + monkeypatch.setattr(integrations, "get_system", lambda: fake) + return fake + + +# ── integration_info: v2 accounts ride TOP-LEVEL ``data.accounts`` ────────── +# +# CONTRACT (frontend): IntegrationsSettings' ``integration_info`` handler +# reads ``data.accounts`` (sibling of ``data.integration``) and only renders +# the AccountsManager (Add account / alias / primary / listen) when that key +# is a ManagedAccount[] — ``{identity, alias, isPrimary, listen}``. The +# legacy status-parsed ``{display, id}`` rows stay INSIDE +# ``data.integration.accounts`` and must never be replaced with v2-shaped +# objects (the legacy modal body renders ``account.display``/``account.id``). + + +def test_info_carries_v2_accounts_at_top_level(system, monkeypatch): + legacy_accounts = [{"display": "legacy", "id": "legacy"}] + adapter, sent = make_adapter() + monkeypatch.setattr( + ba, + "get_integration_info", + lambda _id: {"id": _id, "connected": True, + "accounts": list(legacy_accounts)}, + ) + asyncio.run(adapter._handle_integration_info("gmail")) + (data,) = results_of(sent, "integration_info") + assert data["success"] is True + # The exact key the frontend reads: + assert data["accounts"] == WIRE_TWO + # Every row carries exactly the ManagedAccount wire keys: + for row in data["accounts"]: + assert set(row) == {"identity", "alias", "isPrimary", "listen"} + # Legacy-shaped rows inside ``integration`` are left untouched: + assert data["integration"]["accounts"] == legacy_accounts + + +def test_info_non_v2_has_no_top_level_accounts(system, monkeypatch): + adapter, sent = make_adapter() + legacy_accounts = [{"display": "Me", "id": "me-1"}] + monkeypatch.setattr( + ba, + "get_integration_info", + lambda _id: {"id": _id, "connected": True, "accounts": legacy_accounts}, + ) + asyncio.run(adapter._handle_integration_info("jira")) + (data,) = results_of(sent, "integration_info") + # Absent top-level key → frontend keeps managedAccounts = null → legacy UI. + assert "accounts" not in data + assert data["integration"]["accounts"] == legacy_accounts + + +def test_info_v2_lookup_failure_degrades_to_legacy(monkeypatch): + """get_system() blowing up must not break the payload — no top-level + accounts (legacy modal), success still True, and the failure is loud.""" + adapter, sent = make_adapter() + + def boom(): + raise RuntimeError("bootstrap failed") + + monkeypatch.setattr(integrations, "get_system", boom) + legacy_accounts = [{"display": "a@x.com", "id": "a@x.com"}] + monkeypatch.setattr( + ba, + "get_integration_info", + lambda _id: {"id": _id, "connected": True, "accounts": legacy_accounts}, + ) + asyncio.run(adapter._handle_integration_info("gmail")) + (data,) = results_of(sent, "integration_info") + assert data["success"] is True + assert "accounts" not in data + assert data["integration"]["accounts"] == legacy_accounts + + +# ── integration_accounts_add ───────────────────────────────────────────── + + +def test_accounts_add_success_echoes_request_id(system): + adapter, sent = make_adapter() + system.add_result = (True, "Connected c@z.com", TWO() + [acct("c@z.com")]) + + async def scenario(): + await adapter._handle_integration_accounts_add("gmail", "req-42") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data["id"] == "gmail" + assert data["requestId"] == "req-42" + assert data["ok"] is True + assert data["message"] == "Connected c@z.com" + assert [a["identity"] for a in data["accounts"]] == [ + "a@x.com", "b@y.com", "c@z.com", + ] + # success refreshes the integration list + assert results_of(sent, "integration_list") + # task cleaned itself out of the oauth-task registry + assert adapter._oauth_tasks == {} + + +def test_accounts_add_failure_reports_ok_false(system): + adapter, sent = make_adapter() + system.add_result = (False, "OAuth timed out", []) + + async def scenario(): + await adapter._handle_integration_accounts_add("gmail", "req-7") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data["ok"] is False + assert data["requestId"] == "req-7" + assert data["message"] == "OAuth timed out" + assert not results_of(sent, "integration_list") + + +def test_accounts_add_unknown_provider(system): + adapter, sent = make_adapter() + + async def scenario(): + await adapter._handle_integration_accounts_add("nope", "req-1") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data["ok"] is False + # Add-result failures travel in "message" (types.ts has no error field). + assert "Unknown integration" in data["message"] + assert "error" not in data + assert data["requestId"] == "req-1" + + +def test_accounts_add_tolerates_none_accounts(system): + """add_account's failure tuple may carry accounts=None — never a crash.""" + adapter, sent = make_adapter() + + async def none_add(provider_id): + return False, "OAuth window closed", None + + system.add_account = none_add + + async def scenario(): + await adapter._handle_integration_accounts_add("gmail", "req-n") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data == { + "id": "gmail", + "requestId": "req-n", + "ok": False, + "message": "OAuth window closed", + "accounts": [], + } + + +# ── integration_apply_account_changes ──────────────────────────────────── + + +def test_apply_changes_success(system): + adapter, sent = make_adapter() + changes = { + "disconnect": [], + "primary": "b@y.com", + "aliases": {"a@x.com": None}, + "listen": {"b@y.com": False}, + } + asyncio.run( + adapter._handle_integration_apply_account_changes("gmail", "req-9", changes) + ) + (data,) = results_of(sent, "integration_apply_account_changes_result") + assert data == { + "id": "gmail", + "requestId": "req-9", + "ok": True, + "accounts": WIRE_TWO, + } + assert system.applied == [("gmail", changes)] + assert results_of(sent, "integration_list") + + +@pytest.mark.parametrize( + "error", [ValueError("primary not in set"), AccountResolutionError("no match")] +) +def test_apply_changes_failure_keeps_current_accounts(system, error): + adapter, sent = make_adapter() + system.apply_error = error + asyncio.run( + adapter._handle_integration_apply_account_changes("gmail", "req-9", {}) + ) + (data,) = results_of(sent, "integration_apply_account_changes_result") + assert data["ok"] is False + assert data["error"] == str(error) + assert data["requestId"] == "req-9" + # frontend keeps staged edits; payload carries the unchanged current list + assert data["accounts"] == WIRE_TWO + assert not results_of(sent, "integration_list") + + +def test_apply_changes_unknown_provider(system): + adapter, sent = make_adapter() + asyncio.run( + adapter._handle_integration_apply_account_changes("nope", "r", {}) + ) + (data,) = results_of(sent, "integration_apply_account_changes_result") + assert data["ok"] is False + assert "Unknown integration" in data["error"] + + +# ── failure payloads must never fabricate an empty account list ────────── +# +# CONTRACT (frontend): a present ``accounts`` array is authoritative — the +# Manage modal re-renders from it and PRUNES its staged (unsaved) edits +# against it. A failure payload whose current-list lookup also failed used +# to ship ``accounts: []``, which blanked the modal and silently discarded +# every staged edit (e.g. an alias mid-typing). The key must be OMITTED +# when the real list is unavailable, and still carried when it is. + + +def _raise(*_a, **_k): + raise RuntimeError("store unavailable") + + +def test_apply_changes_failure_omits_accounts_when_list_unavailable(system): + adapter, sent = make_adapter() + system.apply_error = ValueError("nickname clash") + system.list_accounts = _raise + asyncio.run( + adapter._handle_integration_apply_account_changes("gmail", "req-x", {}) + ) + (data,) = results_of(sent, "integration_apply_account_changes_result") + assert data["ok"] is False + assert data["error"] == "nickname clash" + assert "accounts" not in data + + +def test_accounts_add_exception_omits_accounts_when_list_unavailable(system): + adapter, sent = make_adapter() + + async def boom_add(provider_id): + raise RuntimeError("oauth transport died") + + system.add_account = boom_add + system.list_accounts = _raise + + async def scenario(): + await adapter._handle_integration_accounts_add("gmail", "req-y") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data["ok"] is False + assert data["message"] == "oauth transport died" + assert "accounts" not in data + + +def test_accounts_add_exception_keeps_real_accounts_when_available(system): + adapter, sent = make_adapter() + + async def boom_add(provider_id): + raise RuntimeError("oauth window closed") + + system.add_account = boom_add + + async def scenario(): + await adapter._handle_integration_accounts_add("gmail", "req-z") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_accounts_add_result") + assert data["ok"] is False + # The real (unchanged) list is still useful context and stays present. + assert data["accounts"] == WIRE_TWO + + +# ── integration_disconnect: system routing + legacy fallthrough ────────────── + + +def _patch_legacy_disconnect(monkeypatch, calls, result=(True, "Disconnected")): + async def fake_disconnect(integration_id, account_id=None): + calls.append((integration_id, account_id)) + return result + + monkeypatch.setattr(ba, "disconnect_integration", fake_disconnect) + + +def test_disconnect_targeted_v2_skips_legacy(system, monkeypatch): + adapter, sent = make_adapter() + legacy_calls: List[Tuple[str, Optional[str]]] = [] + _patch_legacy_disconnect(monkeypatch, legacy_calls) + + async def scenario(): + await adapter._handle_integration_disconnect("gmail", "school", "req-d1") + await drain_tasks() + + asyncio.run(scenario()) + assert system.removed == [("gmail", "b@y.com")] + assert legacy_calls == [] # targeted removal never touches legacy + (data,) = results_of(sent, "integration_disconnect_result") + assert data["success"] is True + assert data["requestId"] == "req-d1" + assert [a["identity"] for a in data["accounts"]] == ["a@x.com"] + assert results_of(sent, "integration_list") + + +def test_disconnect_targeted_v2_unknown_account(system, monkeypatch): + adapter, sent = make_adapter() + legacy_calls: List[Tuple[str, Optional[str]]] = [] + _patch_legacy_disconnect(monkeypatch, legacy_calls) + + async def scenario(): + await adapter._handle_integration_disconnect("gmail", "ghost", "req-d2") + await drain_tasks() + + asyncio.run(scenario()) + (data,) = results_of(sent, "integration_disconnect_result") + assert data["success"] is False + assert "ghost" in data["message"] + assert legacy_calls == [] + assert not results_of(sent, "integration_list") + + +def test_disconnect_all_v2_falls_through_to_legacy(system, monkeypatch): + adapter, sent = make_adapter() + legacy_calls: List[Tuple[str, Optional[str]]] = [] + _patch_legacy_disconnect(monkeypatch, legacy_calls) + + async def scenario(): + await adapter._handle_integration_disconnect("gmail", None, "req-d3") + await drain_tasks() + + asyncio.run(scenario()) + # every account removed, then legacy cleanup ran once + assert system.removed == [("gmail", "a@x.com"), ("gmail", "b@y.com")] + assert legacy_calls == [("gmail", None)] + (data,) = results_of(sent, "integration_disconnect_result") + assert data["success"] is True + assert data["requestId"] == "req-d3" + + +def test_disconnect_non_v2_unchanged(system, monkeypatch): + adapter, sent = make_adapter() + legacy_calls: List[Tuple[str, Optional[str]]] = [] + _patch_legacy_disconnect(monkeypatch, legacy_calls) + + async def scenario(): + await adapter._handle_integration_disconnect("jira", "acct-1", "req-d4") + await drain_tasks() + + asyncio.run(scenario()) + assert system.removed == [] + assert legacy_calls == [("jira", "acct-1")] + (data,) = results_of(sent, "integration_disconnect_result") + assert data["success"] is True + assert data["requestId"] == "req-d4" diff --git a/tests/integrations/test_youtube_provider.py b/tests/integrations/test_youtube_provider.py new file mode 100644 index 00000000..1bbad45f --- /dev/null +++ b/tests/integrations/test_youtube_provider.py @@ -0,0 +1,113 @@ +"""YouTube provider — conformance + one end-to-end wiring check. + +No network: the client API method is stubbed. What's real is the chain +execute() → resolve → bind → client method → shaped (lean) result. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.core.storage import FileCredentialStore +from craftos_integrations.core.system import IntegrationSystem +from craftos_integrations.providers.google_youtube import GoogleYoutubeProvider +from craftos_integrations.providers.google_youtube.provider import BoundGoogleYoutubeClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +GOOGLE_CRED = { + "access_token": "at-1", + "refresh_token": "rt-1", + "token_expiry": 1e12, # far future: no refresh during normal calls + "client_id": "cid", + "client_secret": "csec", + "email": "a@x.com", +} + + +class TestGoogleYoutubeConformance(ProviderConformance): + provider = GoogleYoutubeProvider() + credential_fixtures = [ + GOOGLE_CRED, + {"access_token": "at", "email": " User@X.com "}, # messy legacy shape + {"access_token": "at"}, # identity-less pre-multi-account shape → None + ] + + +@pytest.fixture +def system(tmp_path): + sys = IntegrationSystem( + store=FileCredentialStore(root=tmp_path), providers=[GoogleYoutubeProvider()] + ) + sys.store_credential("google_youtube", "a@x.com", dict(GOOGLE_CRED)) + sys.store_credential( + "google_youtube", + "b@y.com", + {**GOOGLE_CRED, "email": "b@y.com", "access_token": "at-b"}, + ) + sys.set_alias("google_youtube", "b@y.com", "creator") + return sys + + +def test_execute_runs_search_against_resolved_accounts_client(system, monkeypatch): + seen = [] + + def fake_search(self, query, max_results=25, type_filter="video"): + seen.append((self._cred.email, query, max_results, type_filter)) + return { + "ok": True, + "result": [ + { + "id": {"videoId": "vid-1"}, + "snippet": { + "title": "T", + "channelTitle": "C", + "publishedAt": "2026-01-01T00:00:00Z", + "description": "D", + }, + } + ], + } + + monkeypatch.setattr(BoundGoogleYoutubeClient, "search", fake_search) + + result = run( + system.execute( + "google_youtube", + "search_youtube", + {"query": "cats", "max_results": 3}, + account="creator", + ) + ) + # creator account's client, mapped args (type → type_filter, defaults) + assert seen == [("b@y.com", "cats", 3, "video")] + # lean shaping applied (no include_metadata) + assert result == { + "status": "success", + "result": [ + { + "videoId": "vid-1", + "title": "T", + "channelTitle": "C", + "publishedAt": "2026-01-01T00:00:00Z", + "description": "D", + } + ], + } + + raw = run( + system.execute( + "google_youtube", + "search_youtube", + {"query": "cats", "include_metadata": True}, + ) + ) + assert seen[-1] == ("a@x.com", "cats", 25, "video") # primary + defaults + assert raw["result"][0]["id"] == {"videoId": "vid-1"} # raw passthrough From 3606464a8eb4dd8bc41c032fd3a1ee99a4141e95 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Thu, 13 Aug 2026 18:07:09 +0100 Subject: [PATCH 2/2] integrations: multi-account for all 23 platforms via auth-layer bridge Legacy platforms get thin v2 providers (identity + login + bound client + listener); their existing actions stay and become account-aware centrally (contextvar hint + run_client routing + schema injection into 676 actions). whatsapp_web moves from a singleton Node bridge to per-identity bridges with QR sessions and a max_accounts cap; telegram_user logs in via two-phase phone->code(+2FA) token connect. Also fixes: fcntl broke the v2 system on Windows (msvcrt locking); list/info/metrics read legacy cred files so v2 connects showed disconnected; notion token connects could silently overwrite the first account. Adds manage_integration_account agent action and Living UI account param. 609 tests green. --- agent_core/core/impl/action/context.py | 41 ++ agent_core/core/impl/action/executor.py | 23 +- app/agent_base.py | 34 +- app/data/action/integrations/_helpers.py | 237 +++++-- .../action/integrations/account_bridge.py | 108 ++++ .../integrations/integration_management.py | 124 ++++ app/living_ui/integration_bridge.py | 33 +- app/ui_layer/adapters/browser_adapter.py | 82 ++- .../pages/Settings/IntegrationsSettings.tsx | 72 +-- .../store/slices/integrationsSettingsSlice.ts | 13 +- app/ui_layer/metrics/collector.py | 9 +- craftos_integrations/core/listeners.py | 3 +- craftos_integrations/core/storage.py | 38 +- .../integrations/telegram_user/__init__.py | 44 +- .../integrations/whatsapp_web/__init__.py | 312 +++++++--- .../whatsapp_web/_bridge_client.py | 439 ++++++++++++- craftos_integrations/providers/__init__.py | 33 + craftos_integrations/providers/_lark.py | 205 +++++++ craftos_integrations/providers/_shared.py | 32 + .../providers/discord/__init__.py | 3 + .../providers/discord/provider.py | 198 ++++++ .../providers/github/__init__.py | 5 + .../providers/github/provider.py | 187 ++++++ .../providers/jira/__init__.py | 5 + .../providers/jira/provider.py | 228 +++++++ .../providers/lark/__init__.py | 3 + .../providers/lark/provider.py | 62 ++ .../providers/lark_calendar/__init__.py | 3 + .../providers/lark_calendar/provider.py | 25 + .../providers/lark_drive/__init__.py | 3 + .../providers/lark_drive/provider.py | 30 + .../providers/line/__init__.py | 5 + .../providers/line/provider.py | 151 +++++ .../providers/stripe/__init__.py | 3 + .../providers/stripe/provider.py | 208 +++++++ .../providers/telegram_bot/__init__.py | 3 + .../providers/telegram_bot/provider.py | 192 ++++++ .../providers/telegram_user/__init__.py | 3 + .../providers/telegram_user/provider.py | 323 ++++++++++ .../providers/twitter/__init__.py | 5 + .../providers/twitter/provider.py | 242 ++++++++ .../providers/whatsapp_business/__init__.py | 3 + .../providers/whatsapp_business/provider.py | 193 ++++++ .../providers/whatsapp_web/__init__.py | 3 + .../providers/whatsapp_web/provider.py | 217 +++++++ tests/integrations/conformance.py | 17 +- .../integrations/test_discord_conformance.py | 140 +++++ tests/integrations/test_github_conformance.py | 162 +++++ tests/integrations/test_jira_conformance.py | 223 +++++++ tests/integrations/test_lark_conformance.py | 284 +++++++++ tests/integrations/test_line_conformance.py | 142 +++++ tests/integrations/test_management_actions.py | 50 +- tests/integrations/test_provider_listeners.py | 26 +- tests/integrations/test_storage.py | 5 + tests/integrations/test_stripe_conformance.py | 148 +++++ .../test_telegram_bot_conformance.py | 238 ++++++++ .../test_telegram_user_conformance.py | 471 ++++++++++++++ .../integrations/test_twitter_conformance.py | 230 +++++++ .../test_whatsapp_business_conformance.py | 150 +++++ .../test_whatsapp_web_conformance.py | 577 ++++++++++++++++++ .../integrations/test_ws_account_handlers.py | 59 +- 61 files changed, 6828 insertions(+), 279 deletions(-) create mode 100644 agent_core/core/impl/action/context.py create mode 100644 app/data/action/integrations/account_bridge.py create mode 100644 craftos_integrations/providers/_lark.py create mode 100644 craftos_integrations/providers/discord/__init__.py create mode 100644 craftos_integrations/providers/discord/provider.py create mode 100644 craftos_integrations/providers/github/__init__.py create mode 100644 craftos_integrations/providers/github/provider.py create mode 100644 craftos_integrations/providers/jira/__init__.py create mode 100644 craftos_integrations/providers/jira/provider.py create mode 100644 craftos_integrations/providers/lark/__init__.py create mode 100644 craftos_integrations/providers/lark/provider.py create mode 100644 craftos_integrations/providers/lark_calendar/__init__.py create mode 100644 craftos_integrations/providers/lark_calendar/provider.py create mode 100644 craftos_integrations/providers/lark_drive/__init__.py create mode 100644 craftos_integrations/providers/lark_drive/provider.py create mode 100644 craftos_integrations/providers/line/__init__.py create mode 100644 craftos_integrations/providers/line/provider.py create mode 100644 craftos_integrations/providers/stripe/__init__.py create mode 100644 craftos_integrations/providers/stripe/provider.py create mode 100644 craftos_integrations/providers/telegram_bot/__init__.py create mode 100644 craftos_integrations/providers/telegram_bot/provider.py create mode 100644 craftos_integrations/providers/telegram_user/__init__.py create mode 100644 craftos_integrations/providers/telegram_user/provider.py create mode 100644 craftos_integrations/providers/twitter/__init__.py create mode 100644 craftos_integrations/providers/twitter/provider.py create mode 100644 craftos_integrations/providers/whatsapp_business/__init__.py create mode 100644 craftos_integrations/providers/whatsapp_business/provider.py create mode 100644 craftos_integrations/providers/whatsapp_web/__init__.py create mode 100644 craftos_integrations/providers/whatsapp_web/provider.py create mode 100644 tests/integrations/test_discord_conformance.py create mode 100644 tests/integrations/test_github_conformance.py create mode 100644 tests/integrations/test_jira_conformance.py create mode 100644 tests/integrations/test_lark_conformance.py create mode 100644 tests/integrations/test_line_conformance.py create mode 100644 tests/integrations/test_stripe_conformance.py create mode 100644 tests/integrations/test_telegram_bot_conformance.py create mode 100644 tests/integrations/test_telegram_user_conformance.py create mode 100644 tests/integrations/test_twitter_conformance.py create mode 100644 tests/integrations/test_whatsapp_business_conformance.py create mode 100644 tests/integrations/test_whatsapp_web_conformance.py diff --git a/agent_core/core/impl/action/context.py b/agent_core/core/impl/action/context.py new file mode 100644 index 00000000..66f0cde0 --- /dev/null +++ b/agent_core/core/impl/action/context.py @@ -0,0 +1,41 @@ +"""Execution-scoped context for in-process actions. + +``current_input_data`` holds the full ``input_data`` dict of the action +currently executing in this context. It exists so cross-cutting helpers +deep inside an action's call tree (e.g. multi-account routing reading the +``account`` hint) can see routing keys without threading them through +every action function signature. + +Scope rules: + - Set only by the internal executors (``_atomic_action_internal*``), + reset in a ``finally`` — never leaks across actions. + - Sync actions run in a thread pool where the caller's context does NOT + propagate, so the executor wraps the call and sets the var inside the + worker thread (see ``run_with_input_context``). + - Sandboxed (subprocess) actions cannot see it at all — helpers must + treat a ``None`` value as "no context available". +""" + +from __future__ import annotations + +from contextvars import ContextVar +from typing import Any, Callable, Dict, Optional + +current_input_data: ContextVar[Optional[Dict[str, Any]]] = ContextVar( + "current_input_data", default=None +) + + +def run_with_input_context( + function_to_call: Callable[[dict], dict], input_data: dict +) -> dict: + """Call a sync action with ``current_input_data`` set for its duration. + + Used as the thread-pool target: the worker thread has its own context, + so the var must be set (and reset) inside the thread, not the caller. + """ + token = current_input_data.set(input_data) + try: + return function_to_call(input_data) + finally: + current_input_data.reset(token) diff --git a/agent_core/core/impl/action/executor.py b/agent_core/core/impl/action/executor.py index 60888898..5b735dfd 100644 --- a/agent_core/core/impl/action/executor.py +++ b/agent_core/core/impl/action/executor.py @@ -571,7 +571,9 @@ def _atomic_action_internal( "The action_code string did not define a callable Python function." ) - execution_result = function_to_call(input_data) + from agent_core.core.impl.action.context import run_with_input_context + + execution_result = run_with_input_context(function_to_call, input_data) return execution_result except Exception as e: @@ -618,16 +620,29 @@ async def _atomic_action_internal_async( "The action_code string did not define a callable Python function." ) + from agent_core.core.impl.action.context import ( + current_input_data, + run_with_input_context, + ) + # Check if the function is async (coroutine function) if inspect.iscoroutinefunction(function_to_call): logger.debug(f"[ASYNC] Action '{action_name}' is async, awaiting directly") - execution_result = await function_to_call(input_data) + ctx_token = current_input_data.set(input_data) + try: + execution_result = await function_to_call(input_data) + finally: + current_input_data.reset(ctx_token) else: - # Sync function - run in thread pool to avoid blocking + # Sync function - run in thread pool to avoid blocking. The + # worker thread doesn't inherit this context, so the wrapper + # sets current_input_data inside the thread. logger.debug( f"[SYNC] Action '{action_name}' is sync, running in thread pool" ) - thread_future = THREAD_POOL.submit(function_to_call, input_data) + thread_future = THREAD_POOL.submit( + run_with_input_context, function_to_call, input_data + ) try: execution_result = await asyncio.wrap_future(thread_future) except asyncio.CancelledError: diff --git a/app/agent_base.py b/app/agent_base.py index ba93dfbb..c0593ed1 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -246,6 +246,19 @@ def __init__( self.db_interface = self._build_db_interface( data_dir=data_dir, chroma_path=chroma_path ) + # Multi-account bridge: legacy actions of bridged platforms get the + # ``account`` input injected post-discovery (schemas are read live + # from the registry at prompt build, so this must run before the + # first turn). Never fatal — a failure just means those actions + # keep their pre-multi-account schemas this run. + try: + from app.data.action.integrations.account_bridge import ( + inject_account_schemas, + ) + + inject_account_schemas() + except Exception as e: + logger.warning(f"[ACCOUNT_BRIDGE] schema injection failed: {e}") # LLM + prompt plumbing (may be deferred if API key not yet configured) self.llm = LLMInterface( @@ -3365,13 +3378,24 @@ async def _initialize_external_libraries(self) -> None: "openai_api_key": os.environ.get("OPENAI_API_KEY", ""), }, ) - # gmail/outlook/slack listening is owned by the ListenerManager - # (multi-account fan-out); the legacy manager must not double-listen. - # The other multi-account providers have no listeners, and the remaining legacy - # integrations keep legacy listening. + # Every platform with a v2 provider (full port or auth-layer bridge) + # gets its listening from the ListenerManager's per-account fan-out; + # the legacy manager must not double-listen on any of them. Derived + # from the registry so newly bridged platforms are excluded + # automatically. Remaining legacy integrations keep legacy listening. + try: + from app.integrations import get_system + + v2_platform_ids = [p.id for p in get_system().providers()] + except Exception as e: + logger.warning( + f"[EXT LIBS] v2 registry unavailable, falling back to static " + f"listener exclusions: {e}" + ) + v2_platform_ids = ["gmail", "outlook", "slack"] self._external_comms = await initialize_manager( on_message=self._handle_external_event, - exclude_platforms=["gmail", "outlook", "slack"], + exclude_platforms=v2_platform_ids, ) logger.info("[EXT LIBS] External integrations configured + manager started") diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index b371f43d..2c1d90bf 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -211,6 +211,76 @@ def pick_result(res: Dict[str, Any], keys) -> Dict[str, Any]: return res +def _account_hint() -> Optional[str]: + """The ``account`` value of the action currently executing, if any. + + Read from the executor's execution context (never threaded through + action signatures — legacy actions don't declare ``account``; the + schema is injected centrally by ``account_bridge``). Returns None + outside an action context (e.g. sandboxed subprocess actions, direct + calls from host code) — callers fall back to the primary account. + """ + try: + from agent_core.core.impl.action.context import current_input_data + + data = current_input_data.get() + hint = (data or {}).get("account") + if isinstance(hint, str) and hint.strip(): + return hint.strip() + except Exception: + pass + return None + + +def _bridge_client_or_error(integration: str): + """Account-aware client resolution for bridged multi-account platforms. + + Returns ``(client, error_dict, handled)``: + - ``handled=False`` → the platform has no v2 provider; caller takes + the legacy singleton path unchanged. + - ``handled=True`` → the v2 system owns this platform: ``client`` is + bound to the resolved account (the ``account`` hint from the + executing action, or the primary), or ``error_dict`` explains the + failure in self-correcting terms. + + An explicit ``account`` hint on a NON-bridged platform is a loud + error, not a silent primary fallback — silently sending from the + wrong account is the one failure mode this whole system exists to + prevent. + """ + from craftos_integrations.contracts import AccountResolutionError + + hint = _account_hint() + system = system_for(integration) + if system is None: + if hint: + return None, { + "status": "error", + "message": ( + f"{integration} does not support account selection yet — " + f"retry without the 'account' parameter." + ), + }, True + return None, None, False + try: + # list_accounts (not resolve) first: it runs the one-time legacy + # credential migration and gives a friendlier no-accounts message. + if not system.list_accounts(integration): + return None, { + "status": "error", + "message": _no_cred_message(integration), + }, True + identity = system.resolve(integration, hint) + return system.client_for(integration, identity), None, True + except AccountResolutionError as e: + return None, {"status": "error", "message": str(e)}, True + except Exception as e: + return None, { + "status": "error", + "message": f"{integration} account resolution failed: {e}", + }, True + + async def run_client( integration: str, method_name: str, @@ -226,11 +296,15 @@ async def run_client( """ from craftos_integrations import get_client - client = get_client(integration) - if client is None: - return {"status": "error", "message": f"Unknown integration: {integration}"} - if not client.has_credentials(): - return {"status": "error", "message": _no_cred_message(integration)} + client, err, handled = _bridge_client_or_error(integration) + if err: + return err + if not handled: + client = get_client(integration) + if client is None: + return {"status": "error", "message": f"Unknown integration: {integration}"} + if not client.has_credentials(): + return {"status": "error", "message": _no_cred_message(integration)} try: method = getattr(client, method_name, None) if method is None: @@ -273,11 +347,15 @@ def run_client_sync( """Sync flavor of ``run_client`` for sync actions calling sync methods.""" from craftos_integrations import get_client - client = get_client(integration) - if client is None: - return {"status": "error", "message": f"Unknown integration: {integration}"} - if not client.has_credentials(): - return {"status": "error", "message": _no_cred_message(integration)} + client, err, handled = _bridge_client_or_error(integration) + if err: + return err + if not handled: + client = get_client(integration) + if client is None: + return {"status": "error", "message": f"Unknown integration: {integration}"} + if not client.has_credentials(): + return {"status": "error", "message": _no_cred_message(integration)} try: method = getattr(client, method_name, None) if method is None: @@ -329,6 +407,11 @@ def my_action(input_data): """ from craftos_integrations import get_client + client, err, handled = _bridge_client_or_error(integration) + if err: + return None, err + if handled: + return client, None client = get_client(integration) if client is None: return None, { @@ -413,36 +496,42 @@ def v2_display_name(system, integration_id: str) -> str: return getattr(provider, "display_name", None) or integration_id -def list_integrations_merged() -> list: +async def list_integrations_merged_async() -> list: """Metadata + connection status for every integration, with multi-account provider ids sourcing their connection state and accounts from the IntegrationSystem instead of the legacy credential files. Legacy integrations keep the legacy ``handler.status()`` path unchanged. - """ - import asyncio as _asyncio + v2 entries carry ``accounts`` in the ManagedAccount wire shape + ({identity, alias, isPrimary, listen}); legacy entries keep the + status-parsed ``{display, id}`` shape. + """ from craftos_integrations import get_integration_info, get_metadata, list_all - async def _gather(): - out = [] - for name in list_all(): - system = system_for(name) - if system is not None: - info = get_metadata(name) - if info is None: - continue - infos = system.list_accounts(name) - info["accounts"] = accounts_payload(infos) - info["connected"] = bool(infos) - else: - info = await get_integration_info(name) - if info: - out.append(info) - return out + out = [] + for name in list_all(): + system = system_for(name) + if system is not None: + info = get_metadata(name) + if info is None: + continue + infos = system.list_accounts(name) + info["accounts"] = accounts_payload(infos) + info["connected"] = bool(infos) + else: + info = await get_integration_info(name) + if info: + out.append(info) + return out + + +def list_integrations_merged() -> list: + """Sync wrapper for action/handler contexts with no running event loop.""" + import asyncio as _asyncio loop = _asyncio.new_event_loop() try: - return loop.run_until_complete(_gather()) + return loop.run_until_complete(list_integrations_merged_async()) finally: loop.close() @@ -477,9 +566,11 @@ def _v2_verify_slack_token(credentials: Dict[str, str]): def _v2_verify_notion_token(credentials: Dict[str, str]): """Same verification the legacy NotionHandler.login() runs: ``GET - /users/me`` with the integration token; same credential dict shape - ({"token": ...} — token-only, so it lands under the LEGACY sentinel - identity until an OAuth re-auth upgrades it, per plan §7).""" + /users/me`` with the integration token; same credential dict shape, + plus the bot user id captured as ``bot_id`` so ``identity_of`` gets a + stable account key. (Without it the credential landed under the + LEGACY sentinel and a second token connect silently overwrote the + first account.)""" from dataclasses import asdict from craftos_integrations.integrations.notion import ( @@ -498,6 +589,14 @@ def _v2_verify_notion_token(credentials: Dict[str, str]): return False, f"Notion auth failed: {data['error']}", None ws_name = data.get("bot", {}).get("workspace_name", "default") credential = asdict(NotionCredential(token=token)) + # The bot user id is workspace-scoped and stable — one integration + # token = one workspace = one account. + bot_id = data.get("id") + if isinstance(bot_id, str) and bot_id.strip(): + credential["bot_id"] = bot_id.strip() + ws_id = data.get("bot", {}).get("workspace_id") + if isinstance(ws_id, str) and ws_id.strip(): + credential["workspace_id"] = ws_id.strip() return True, f"Notion connected: {ws_name}", credential @@ -551,7 +650,13 @@ def system_connect_token(system, integration_id: str, credentials: Dict[str, str through the integration system (``store_credential``) — never through the legacy single-account save. Returns (success, message). """ - verifier = _V2_TOKEN_VERIFIERS.get(integration_id) + # Providers may carry their own verifier (the bridge-provider pattern — + # keeps each platform's connect logic in its provider package); the + # central table covers the three providers that predate it. + provider_obj = system.registry.get(integration_id) + verifier = getattr(provider_obj, "verify_token", None) or _V2_TOKEN_VERIFIERS.get( + integration_id + ) if verifier is None: # Mirrors legacy IntegrationHandler.connect_token for field-less # (OAuth-only) integrations. @@ -567,10 +672,19 @@ def system_connect_token(system, integration_id: str, credentials: Dict[str, str if not ok or not credential: return False, message - from craftos_integrations.contracts import LEGACY_IDENTITY - provider = system.registry.get(integration_id) - identity = provider.identity_of(credential) or LEGACY_IDENTITY + identity = provider.identity_of(credential) + if not identity: + # Refuse rather than store under the LEGACY sentinel: a second + # identity-less connect would land on the same sentinel key and + # silently REPLACE the first account's credential. The sentinel + # exists only for pre-multi-account files migrating in. + return False, ( + f"Could not determine which account this " + f"{v2_display_name(system, integration_id)} token belongs to — " + f"connect was aborted so an existing account can't be " + f"overwritten. Re-check the token and try again." + ) system.store_credential(integration_id, identity, credential) # Slack has a listener; reconcile so a fresh token starts listening # immediately (no-op when no manager is attached / no listener exists). @@ -578,6 +692,51 @@ def system_connect_token(system, integration_id: str, credentials: Dict[str, str return True, message +def platform_teardown_accounts(integration_id: str, identities) -> None: + """Platform-specific post-removal cleanup the core can't do. + + whatsapp_web accounts own a live Node/Chromium bridge and a per-account + session dir; core ``remove_account`` only deletes the AccountSet entry. + Best-effort, never raises; async teardown is scheduled on the running + loop when there is one, else run inline. + """ + identities = [i for i in (identities or []) if i] + if integration_id != "whatsapp_web" or not identities: + return + import asyncio as _asyncio + + try: + from craftos_integrations.providers.whatsapp_web import teardown_account + except Exception: + return + + from craftos_integrations.logger import get_logger + + _log = get_logger(__name__) + + async def _run() -> None: + for identity in identities: + try: + await teardown_account(identity) + except Exception as e: + _log.warning( + f"[INTEGRATIONS] whatsapp_web teardown for '{identity}' failed: {e}" + ) + + try: + loop = _asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is not None: + loop.create_task(_run()) + else: + loop = _asyncio.new_event_loop() + try: + loop.run_until_complete(_run()) + finally: + loop.close() + + def system_disconnect(system, integration_id: str, account_id=None): """Disconnect a multi-account provider through the IntegrationSystem. @@ -599,17 +758,21 @@ def system_disconnect(system, integration_id: str, account_id=None): if account_id: try: identity = system.remove_account(integration_id, account_id) + platform_teardown_accounts(integration_id, [identity]) return True, f"Removed account '{identity}' from {integration_id}." except Exception as e: return False, str(e) removed = [] + removed_identities = [] for info in system.list_accounts(integration_id): try: system.remove_account(integration_id, info.identity) removed.append(info.alias or info.identity) + removed_identities.append(info.identity) except Exception: pass + platform_teardown_accounts(integration_id, removed_identities) legacy_success, legacy_message = False, "" try: diff --git a/app/data/action/integrations/account_bridge.py b/app/data/action/integrations/account_bridge.py new file mode 100644 index 00000000..11f51030 --- /dev/null +++ b/app/data/action/integrations/account_bridge.py @@ -0,0 +1,108 @@ +"""Account-awareness bridge for legacy integration actions. + +Bridged platforms keep their hand-written action files unchanged; the two +halves of account selection are handled centrally: + + - schema side (HERE): ``inject_account_schemas()`` adds the same + ``account`` input property the craftbot_adapter injects for generated + v2 actions, to every registered action whose source file lives under + a bridged platform's directory. Called once by the host right after + action discovery (see ``AgentBase.__init__``). + - execution side: ``_helpers._bridge_client_or_error`` reads the hint + from the executor's input-data context and resolves it through the + IntegrationSystem — no per-action code. + +``BRIDGED_ACTION_DIRS`` maps an action directory name under +``app/data/action/integrations/`` to the display label used in the +injected description. Add a directory here when its platform(s) get a +v2 provider. The ``whatsapp`` directory intentionally stays out until +whatsapp_web is bridged (wave 3): whatsapp_business shares the +directory, and advertising ``account`` on whatsapp_web actions before +its provider exists would only produce resolution errors. +""" + +from __future__ import annotations + +import os +from typing import Dict + +from agent_core.core.action_framework.registry import ActionRegistry + +from craftos_integrations.logger import get_logger + +logger = get_logger(__name__) + +BRIDGED_ACTION_DIRS: Dict[str, str] = { + "stripe": "Stripe", + "github": "GitHub", + "jira": "Jira", + "line": "LINE", + # Wave 2. The telegram dir also hosts telegram_user actions (wave 3): + # a hint on those errors loudly and self-correctingly until it's + # bridged. + "discord": "Discord", + "lark": "Lark", + "lark_calendar": "Lark Calendar", + "lark_drive": "Lark Drive", + "telegram": "Telegram", + "twitter": "Twitter/X", +} + +_MARKER = os.sep + "integrations" + os.sep + + +def _account_schema(label: str) -> Dict[str, str]: + # Keep wording in lockstep with craftbot_adapter._account_schema — + # the model sees both and must treat them identically. + return { + "type": "string", + "description": ( + f"Optional {label} account to act as: an identity, the user's " + f"nickname for the account (e.g. 'work'), or any unique " + f"fragment of either. OMIT to use the primary account. Always " + f"set this when the user names an account in any form." + ), + "example": "", + } + + +def _dir_for(handler) -> str | None: + """The integrations// an action's source file lives under, if any.""" + try: + filename = handler.__code__.co_filename + except AttributeError: + return None + marker_at = filename.rfind(_MARKER) + if marker_at == -1: + return None + rest = filename[marker_at + len(_MARKER):] + return rest.split(os.sep, 1)[0] if os.sep in rest else None + + +def inject_account_schemas() -> int: + """Add the ``account`` input to every bridged platform's actions. + + Idempotent (setdefault semantics); returns the number of actions + touched. Runs against the live registry, so it must be called after + ``load_actions_from_directories`` and before the first prompt build. + """ + injected = 0 + registry = ActionRegistry() + # _registry: {name: {platform_key: RegisteredAction}} — no public + # iterator exists; the registry is in-repo and this read is the same + # one list_all_actions_as_json performs. + for impls in registry._registry.values(): + for registered in impls.values(): + label = BRIDGED_ACTION_DIRS.get(_dir_for(registered.handler) or "") + if label is None: + continue + schema = registered.metadata.input_schema + if isinstance(schema, dict) and "account" not in schema: + schema["account"] = _account_schema(label) + injected += 1 + if injected: + logger.info( + f"[ACCOUNT_BRIDGE] Injected 'account' input into {injected} " + f"legacy actions across {sorted(BRIDGED_ACTION_DIRS)}" + ) + return injected diff --git a/app/data/action/integrations/integration_management.py b/app/data/action/integrations/integration_management.py index 4d545d64..15ff68d6 100644 --- a/app/data/action/integrations/integration_management.py +++ b/app/data/action/integrations/integration_management.py @@ -700,3 +700,127 @@ def disconnect_integration(input_data: dict) -> dict: } except Exception as e: return {"status": "error", "message": f"Disconnect failed: {str(e)}"} + + +@action( + name="manage_integration_account", + description=( + "Manage a connected integration account: set it as the primary " + "(default) account, give it a nickname/alias, or turn inbound " + "listening on/off for it. Use when the user says things like 'make " + "my work Gmail the default', 'call this account job-search', or " + "'stop listening on my second Slack'." + ), + default=True, + action_sets=["core"], + parallelizable=False, + input_schema={ + "integration_id": { + "type": "string", + "description": "The integration the account belongs to.", + "example": "gmail", + }, + "account": { + "type": "string", + "description": ( + "Which account: an identity (email/id), the user's alias for " + "it, or any unique fragment of either." + ), + "example": "work", + }, + "operation": { + "type": "string", + "description": "One of: set_primary | set_alias | set_listening", + "example": "set_primary", + }, + "value": { + "type": "string", + "description": ( + "For set_alias: the new alias (empty clears it). For " + "set_listening: 'true' or 'false'. Ignored for set_primary." + ), + "example": "", + }, + }, + output_schema={ + "status": {"type": "string", "example": "success"}, + "message": {"type": "string", "description": "Human-readable result."}, + "accounts": { + "type": "array", + "description": "The integration's accounts after the change.", + }, + }, + test_payload={ + "integration_id": "gmail", + "account": "work", + "operation": "set_primary", + "simulated_mode": True, + }, +) +def manage_integration_account(input_data: dict) -> dict: + if input_data.get("simulated_mode"): + return {"status": "success", "message": "Simulated mode"} + + from app.data.action.integrations._helpers import ( + accounts_payload, + normalize_integration_id, + system_for, + ) + + integration_id = normalize_integration_id( + (input_data.get("integration_id") or "").strip().lower() + ) + account = (input_data.get("account") or "").strip() or None + operation = (input_data.get("operation") or "").strip().lower() + value = (input_data.get("value") or "").strip() + + if not integration_id: + return {"status": "error", "message": "integration_id is required."} + if operation not in ("set_primary", "set_alias", "set_listening"): + return { + "status": "error", + "message": ( + f"Unknown operation {operation!r}. Use set_primary, " + f"set_alias, or set_listening." + ), + } + + system = system_for(integration_id) + if system is None: + return { + "status": "error", + "message": f"Unknown integration: {integration_id}", + } + + try: + if operation == "set_primary": + identity = system.set_primary(integration_id, account) + message = f"'{identity}' is now the primary {integration_id} account." + elif operation == "set_alias": + identity = system.set_alias(integration_id, account, value or None) + message = ( + f"Alias for '{identity}' set to '{value}'." + if value + else f"Alias for '{identity}' cleared." + ) + else: # set_listening + if value.lower() not in ("true", "false"): + return { + "status": "error", + "message": "set_listening needs value 'true' or 'false'.", + } + on = value.lower() == "true" + identity = system.set_listening(integration_id, account, on) + message = ( + f"Listening {'enabled' if on else 'disabled'} for " + f"'{identity}' on {integration_id}." + ) + return { + "status": "success", + "message": message, + "accounts": accounts_payload(system.list_accounts(integration_id)), + } + except Exception as e: + # AccountResolutionError messages already enumerate the valid + # accounts, so the model can self-correct on a bad hint. + return {"status": "error", "message": str(e)} diff --git a/app/living_ui/integration_bridge.py b/app/living_ui/integration_bridge.py index 51b1355d..736fc554 100644 --- a/app/living_ui/integration_bridge.py +++ b/app/living_ui/integration_bridge.py @@ -159,6 +159,9 @@ async def _handle_proxy(self, request: web.Request) -> web.Response: url = data.get("url", "") extra_headers = data.get("headers") or {} body = data.get("body") + # Optional multi-account selector: identity, alias, or unique + # fragment (same resolution as agent actions). Omitted = primary. + account = (data.get("account") or "").strip() or None if not integration or not url: return web.json_response( @@ -199,11 +202,15 @@ async def _handle_proxy(self, request: web.Request) -> web.Response: url = resolved # Get auth headers from platform client - auth_headers = self._get_auth_headers(integration) + auth_headers = self._get_auth_headers(integration, account) if auth_headers is None: + detail = f" (account {account!r} not found?)" if account else "" return web.json_response( { - "error": f"Integration '{integration}' not connected (no credentials)" + "error": ( + f"Integration '{integration}' not connected " + f"(no credentials){detail}" + ) }, status=424, ) @@ -773,23 +780,25 @@ def _resolve_destination(self, integration: str, url: str) -> tuple: return True, raw return False, f"host {host!r} is not one of {', '.join(allowed)}" - def _client_for_platform(self, platform_id: str): + def _client_for_platform(self, platform_id: str, account: Optional[str] = None): """Credentialed client for a platform, or None. - multi-account provider ids get the PRIMARY account's client from the - IntegrationSystem (the bound client subclasses the legacy client, so - the header-extraction below works unchanged); everything else keeps - the legacy single-account client. + Multi-account provider ids resolve ``account`` (identity / alias / + unique fragment, None = primary) through the IntegrationSystem — + the bound client subclasses the legacy client, so the + header-extraction below works unchanged. Platforms without a v2 + provider keep the legacy single-account client. """ from app.data.action.integrations._helpers import system_for system = system_for(platform_id) if system is not None: try: - identity = system.resolve(platform_id, None) + identity = system.resolve(platform_id, account) return system.client_for(platform_id, identity) except Exception: - # Not connected (AccountResolutionError) or build failure. + # Not connected / bad account hint (AccountResolutionError) + # or build failure. return None from craftos_integrations import get_client @@ -799,14 +808,16 @@ def _client_for_platform(self, platform_id: str): return None return client - def _get_auth_headers(self, platform_id: str) -> Optional[dict]: + def _get_auth_headers( + self, platform_id: str, account: Optional[str] = None + ) -> Optional[dict]: """ Get authentication headers from a platform client. Returns: Dict of auth headers, or None if credentials unavailable. """ - client = self._client_for_platform(platform_id) + client = self._client_for_platform(platform_id, account) if client is None: return None diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 515aac18..4a314fe2 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -85,8 +85,6 @@ get_skill_template, remove_skill, # Integration settings - list_integrations, - get_integration_info, connect_integration_token, connect_integration_oauth, connect_integration_interactive, @@ -6341,9 +6339,19 @@ async def _handle_skill_dirs(self) -> None: # ===================== async def _handle_integration_list(self) -> None: - """Get list of all integrations with status.""" + """Get list of all integrations with status. + + Uses the v2-merged list: multi-account providers source ``connected`` + and ``accounts`` from the IntegrationSystem (the legacy credential + file is never written by v2 connects, so the legacy status path + reports them as disconnected — issue seen with youtube/notion). + """ try: - integrations = list_integrations() + from app.data.action.integrations._helpers import ( + list_integrations_merged_async, + ) + + integrations = await list_integrations_merged_async() # Calculate stats total = len(integrations) connected = sum(1 for i in integrations if i.get("connected", False)) @@ -6438,18 +6446,20 @@ def _with_accounts( return data async def _handle_integration_info(self, integration_id: str) -> None: - """Get detailed info about an integration.""" + """Get detailed info about an integration. + + Metadata comes from the legacy handler (still the metadata source); + connection state and accounts come from the IntegrationSystem — + every integration is multi-account now, so the old + ``handler.status()`` text-scraping path is gone. A missing + top-level ``accounts`` key tells the frontend the account list + couldn't be loaded (it renders a reload hint, never fake rows). + """ try: - info = get_integration_info(integration_id) + from craftos_integrations import get_metadata + + info = get_metadata(integration_id) if info: - # For providers known to the integrations system, attach the - # multi-account view as a TOP-LEVEL ``accounts`` key — the - # frontend reads ``data.accounts`` (see IntegrationsSettings's - # ``integration_info`` handler and ManagedAccount in types.ts) - # to decide between AccountsManager and the legacy modal body. - # ``info["accounts"]`` (inside ``data.integration``) keeps the - # legacy status-parsed ``{display, id}`` shape untouched so the - # legacy fallback rows can never receive v2-shaped objects. managed_accounts: Optional[List[Dict[str, Any]]] = None try: system = self._system_for(integration_id) @@ -6460,8 +6470,10 @@ async def _handle_integration_info(self, integration_id: str) -> None: except Exception as e: logger.error( f"[INTEGRATIONS] v2 accounts for {integration_id} " - f"unavailable, Manage modal degrades to legacy view: {e!r}" + f"unavailable, Manage modal shows reload hint: {e!r}" ) + info["connected"] = bool(managed_accounts) + info["accounts"] = managed_accounts or [] data: Dict[str, Any] = { "success": True, "id": integration_id, @@ -6887,6 +6899,22 @@ async def _handle_integration_apply_account_changes( accounts = await asyncio.to_thread( system.apply_account_changes, integration_id, changes or {} ) + # Batched disconnects need the platform-specific teardown too + # (whatsapp_web: stop the account's bridge, delete its + # session dir) — core removal only edits the AccountSet. + try: + from app.data.action.integrations._helpers import ( + platform_teardown_accounts, + ) + + platform_teardown_accounts( + integration_id, (changes or {}).get("disconnect") or [] + ) + except Exception as e: + logger.warning( + f"[INTEGRATIONS] platform teardown after batched " + f"disconnect failed for {integration_id}: {e!r}" + ) await self._broadcast( { "type": "integration_apply_account_changes_result", @@ -7307,13 +7335,35 @@ async def _handle_whatsapp_check_status(self, session_id: str) -> None: """Check WhatsApp session status.""" try: result = await check_whatsapp_session_status(session_id) + # On connect, store the account into the AccountSet — the QR flow + # itself can't (craftos_integrations never imports the host); the + # v2 ListenerManager then picks the account up via reconcile. + if result.get("connected") and result.get("credential"): + try: + from app.integrations import get_system + + system = get_system() + identity = system.store_credential( + "whatsapp_web", + result.get("identity"), + result["credential"], + ) + system.reconcile_listeners() + logger.info( + f"[INTEGRATIONS] whatsapp_web account '{identity}' " + f"stored via QR session {session_id}" + ) + except Exception as e: + logger.error( + f"[INTEGRATIONS] storing whatsapp_web QR account " + f"failed (session {session_id}): {e!r}" + ) await self._broadcast( { "type": "whatsapp_status_result", "data": result, } ) - # If connected, refresh the integrations list (listener is started by check_whatsapp_session_status) if result.get("connected"): await self._handle_integration_list() except Exception as e: 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 68023308..c539aa56 100644 --- a/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Settings/IntegrationsSettings.tsx @@ -1110,8 +1110,19 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // reconnect), so the request is never dropped behind a connection guard. // The spinner is cleared ONLY by the matching result broadcast — OAuth can // take minutes and we use no wall-clock timers. + // Only OAuth-capable integrations ('oauth'/'both') have the backend + // add-account flow. For everything else — token entry, interactive/QR, + // token_with_interactive — adding an account IS the regular Connect + // modal (token connect is additive per identity; whatsapp's QR + // auto-start lives in handleOpenConnect), so reuse it. const handleAddAccount = () => { if (!managingIntegration) return + if (managingIntegration.auth_type !== 'oauth' && managingIntegration.auth_type !== 'both') { + const target = managingIntegration + setManagingIntegration(null) + handleOpenConnect(target) + return + } const requestId = crypto.randomUUID() pendingAddRef.current.set(requestId, managingIntegration.id) setAddingAccountFor(managingIntegration.id) @@ -1182,34 +1193,19 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool // Slow integrations show a "working…" overlay during disconnect so the // user gets visible feedback during the bridge teardown (which can take - // 20–30 seconds for WhatsApp Web). Add other slow integrations here. + // 20–30 seconds per WhatsApp Web account). Add other slow integrations here. const SLOW_DISCONNECT_IDS = new Set(['whatsapp_web']) - const handleDisconnect = (accountId?: string) => { - if (!managingIntegration) return - const targetId = managingIntegration.id - const targetName = managingIntegration.name - - // Optimistic UI update — mark this integration as disconnected immediately - // so the user gets instant feedback in the integrations list. Some - // integrations (WhatsApp Web) take 20+ seconds to tear down their bridge - // cleanly, and the ``integration_list`` broadcast only fires after that - // completes. The backend's authoritative ``integration_list`` will - // overwrite this when it arrives. If the disconnect fails, - // ``integration_disconnect_result`` shows a toast and the next refresh - // restores the real state. - dispatch(setDisconnected(targetId)) - closeManageModal() - - // Slow disconnects: show a blocking overlay until the result arrives. - if (SLOW_DISCONNECT_IDS.has(targetId)) { - setPendingOp({ kind: 'disconnect', id: targetId, label: targetName }) + // Disconnect ALL accounts of an integration (list-row Power button). + // Optimistic: the list flips immediately; the authoritative + // ``integration_list`` broadcast overwrites it when teardown finishes, + // and ``integration_disconnect_result`` clears the slow-op overlay. + const handleDisconnectAll = (integration: Integration) => { + dispatch(setDisconnected(integration.id)) + if (SLOW_DISCONNECT_IDS.has(integration.id)) { + setPendingOp({ kind: 'disconnect', id: integration.id, label: integration.name }) } - - send('integration_disconnect', { - id: targetId, - account_id: accountId, - }) + send('integration_disconnect', { id: integration.id }) } const filteredIntegrations = integrations @@ -1317,7 +1313,7 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool confirmText: 'Disconnect', variant: 'danger', }, () => { - send('integration_disconnect', { id: integration.id }) + handleDisconnectAll(integration) }) }} icon={} @@ -1672,23 +1668,15 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool }} onSave={handleSaveAccountChanges} /> - ) : managingIntegration.accounts.length === 0 ? ( -

No accounts connected

) : ( -
- {managingIntegration.accounts.map(account => ( -
- {account.display} - -
- ))} -
+ /* Every integration is multi-account now, so a missing + accounts payload means the backend couldn't load them + (see the degrade log in _handle_integration_info) — + not a legacy integration. */ +

+ Couldn't load accounts — close and reopen Manage, or check + the backend logs. +

)} {/* Configure — schema-driven form, only shown for integrations whose handler declared ``config_class`` + ``config_fields``. 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..0bb2ba6d 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/integrationsSettingsSlice.ts @@ -13,6 +13,17 @@ export interface IntegrationAccount { id: string } +// Multi-account (v2) wire shape — integrations backed by the +// IntegrationSystem carry this in ``integration_list`` instead of the +// status-parsed IntegrationAccount. The list UI only reads ``.length``; +// the Manage modal gets its own copy via ``integration_info``. +export interface ManagedListAccount { + identity: string + alias: string | null + isPrimary: boolean + listen: boolean +} + // Schema for a single config input rendered by the Configure section in // the Manage modal. Sourced from the backend handler's ``config_fields``. export interface ConfigField { @@ -30,7 +41,7 @@ export interface Integration { description: string auth_type: 'oauth' | 'token' | 'both' | 'interactive' | 'token_with_interactive' connected: boolean - accounts: IntegrationAccount[] + accounts: IntegrationAccount[] | ManagedListAccount[] fields: IntegrationField[] icon?: string has_config?: boolean diff --git a/app/ui_layer/metrics/collector.py b/app/ui_layer/metrics/collector.py index 8d200857..d17dd98a 100644 --- a/app/ui_layer/metrics/collector.py +++ b/app/ui_layer/metrics/collector.py @@ -913,9 +913,14 @@ def _get_skill_metrics(self) -> SkillMetrics: def _get_integration_metrics(self) -> IntegrationMetrics: """Get integration metrics.""" try: - from craftos_integrations import list_integrations_sync + # v2-merged list: connected state comes from the IntegrationSystem's + # AccountSets (the legacy status path reads credential files that + # v2 connects never write, so its counts were wrong). + from app.data.action.integrations._helpers import ( + list_integrations_merged, + ) - integrations_data = list_integrations_sync() + integrations_data = list_integrations_merged() integrations = [] connected = 0 diff --git a/craftos_integrations/core/listeners.py b/craftos_integrations/core/listeners.py index bb9b050b..dc615123 100644 --- a/craftos_integrations/core/listeners.py +++ b/craftos_integrations/core/listeners.py @@ -113,7 +113,8 @@ def _write(self, provider_id: str, data: Dict[str, Any]) -> None: tmp = path.with_suffix(f"{path.suffix}.{uuid.uuid4().hex}.tmp") try: with open(tmp, "w", encoding="utf-8") as f: - os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) + if hasattr(os, "fchmod"): # POSIX only; Windows ACLs don't map + os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) diff --git a/craftos_integrations/core/storage.py b/craftos_integrations/core/storage.py index 31465aca..efc14553 100644 --- a/craftos_integrations/core/storage.py +++ b/craftos_integrations/core/storage.py @@ -13,7 +13,8 @@ - ``replace`` is atomic (tmp file + os.replace) — a crash mid-write can never leave a torn document; the previous version survives. - ``locked`` serializes read-modify-write cycles across processes via - fcntl.flock on the sidecar (the sidecar never gets replaced, so the + fcntl.flock (POSIX) or msvcrt.locking (Windows) on the sidecar (the + sidecar never gets replaced, so the lock's inode is stable — locking the data file itself would race with os.replace swapping inodes underneath the lock holder). - Unparseable documents are quarantined loudly, never silently treated @@ -23,7 +24,6 @@ from __future__ import annotations -import fcntl import json import os import stat @@ -31,6 +31,33 @@ from pathlib import Path from typing import Any, Dict, Iterator, Mapping, Optional +if os.name == "nt": + import msvcrt + + def _lock_exclusive(f) -> None: + # msvcrt.locking locks a byte range at the current file position, and + # LK_LOCK gives up after ~10s — loop for flock-like blocking semantics. + while True: + try: + f.seek(0) + msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1) + return + except OSError: + continue + + def _lock_release(f) -> None: + f.seek(0) + msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1) + +else: + import fcntl + + def _lock_exclusive(f) -> None: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + + def _lock_release(f) -> None: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + from ..config import ConfigStore from ..logger import get_logger @@ -88,7 +115,8 @@ def replace(self, provider_id: str, data: Dict[str, Any]) -> None: path = self._path(provider_id) tmp = path.with_suffix(path.suffix + ".tmp") with open(tmp, "w", encoding="utf-8") as f: - os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) + if hasattr(os, "fchmod"): # POSIX only; Windows ACLs don't map + os.fchmod(f.fileno(), stat.S_IRUSR | stat.S_IWUSR) json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) @@ -104,11 +132,11 @@ def delete(self, provider_id: str) -> None: def locked(self, provider_id: str) -> Iterator[None]: lock_path = self._dir() / f".{provider_id}.accounts.lock" with open(lock_path, "a+") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + _lock_exclusive(lock_file) try: yield finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + _lock_release(lock_file) def has_document(self, provider_id: str) -> bool: return self._path(provider_id).exists() diff --git a/craftos_integrations/integrations/telegram_user/__init__.py b/craftos_integrations/integrations/telegram_user/__init__.py index eedbd775..678ffd10 100644 --- a/craftos_integrations/integrations/telegram_user/__init__.py +++ b/craftos_integrations/integrations/telegram_user/__init__.py @@ -78,17 +78,45 @@ class TelegramUserHandler(IntegrationHandler): spec = TELEGRAM_USER display_name = "Telegram (User)" description = "MTProto user account" - auth_type = "interactive" + # Two-phase token connect: submit #1 (phone only) sends the login code + # and reports back; submit #2 (phone + code [+ 2FA password]) completes. + # The CLI `/telegram_user login` subcommand flow is unchanged. + auth_type = "token" icon = "telegram" connect_help = [ - "Open my.telegram.org and log in with your Telegram phone number", - "Click 'API development tools'", - "Fill the form (any app name/short name works) and submit", - "Copy the 'api_id' (number) and 'api_hash' (long hex string)", - "Set them as TELEGRAM_API_ID and TELEGRAM_API_HASH in CraftBot config", - "Then click Connect - you'll be prompted for your phone + login code", + "One-time app credentials: open my.telegram.org, log in, click " + "'API development tools', submit the form (any app name works)", + "Set the api_id and api_hash as TELEGRAM_API_ID and " + "TELEGRAM_API_HASH in CraftBot config (they are NOT entered below)", + "Connect step 1: enter your phone number only (international " + "format, e.g. +923001234567) and submit - a login code is sent " + "to your Telegram app", + "Connect step 2: submit again with the same phone number AND the " + "code filled in (add your 2FA password if your account has one)", + ] + # `code` and `password` stay empty on the first submit — the label + # "(optional)" / "(optional…" placeholder mark them non-required for + # the connect flow's missing-field check. + fields: List = [ + { + "key": "phone_number", + "label": "Phone Number", + "placeholder": "+923001234567", + "password": False, + }, + { + "key": "code", + "label": "Login Code (optional)", + "placeholder": "(optional) leave empty on first submit", + "password": False, + }, + { + "key": "password", + "label": "2FA Password (optional)", + "placeholder": "(optional) only if two-step verification is on", + "password": True, + }, ] - fields: List = [] config_class = TelegramUserConfig config_fields = [ diff --git a/craftos_integrations/integrations/whatsapp_web/__init__.py b/craftos_integrations/integrations/whatsapp_web/__init__.py index 5c4fc0c4..f63a378a 100644 --- a/craftos_integrations/integrations/whatsapp_web/__init__.py +++ b/craftos_integrations/integrations/whatsapp_web/__init__.py @@ -13,6 +13,7 @@ import os import sys import tempfile +import uuid import webbrowser from dataclasses import dataclass from datetime import datetime, timezone @@ -54,6 +55,11 @@ class WhatsAppWebConfig: # wants WhatsApp to act as a personal command channel only. self_messages_only: bool = False + # RAM guard for multi-account: every connected WhatsApp account runs + # its own Node bridge with a headless Chromium (~300-500 MB each). + # Starting a QR login beyond this cap is refused with a clear error. + max_accounts: int = 2 + WHATSAPP_WEB = IntegrationSpec( name="whatsapp_web", @@ -89,6 +95,13 @@ class WhatsAppWebHandler(IntegrationHandler): "help": "Only forward messages you send to yourself (the WhatsApp self-chat). " "Drops incoming DMs and group messages before they reach the agent.", }, + { + "key": "max_accounts", + "label": "Max accounts", + "type": "number", + "help": "Maximum WhatsApp accounts connected at once. Each account " + "runs its own headless browser (~300-500 MB RAM).", + }, ] icon = "whatsapp" fields: List = [] @@ -191,11 +204,28 @@ async def login(self, args: List[str]) -> Tuple[bool, str]: async def logout(self, args: List[str]) -> Tuple[bool, str]: if not has_credential(self.spec.cred_file): return False, "No WhatsApp credentials found." - remove_credential(self.spec.cred_file) + # Resolve the bridge BEFORE removing the credential: the + # legacy-path lookup derives the identity (and therefore the + # auth dir) from whatsapp_web.json — once that file is gone it + # would resolve to the wrong (default) dir. + identity = None + bridge = None try: - from ._bridge_client import get_whatsapp_bridge + from ._bridge_client import ( + drop_whatsapp_bridge, + get_whatsapp_bridge, + normalize_wa_identity, + ) + cred = load_credential(self.spec.cred_file, WhatsAppWebCredential) + identity = normalize_wa_identity(cred.owner_phone if cred else None) bridge = get_whatsapp_bridge() + except Exception: + pass + remove_credential(self.spec.cred_file) + try: + if bridge is None: + raise RuntimeError("whatsapp bridge unavailable") # ``logout()`` (not ``stop()``) — calls wwebjs's ``client.logout()`` # which invalidates the session server-side and wipes the LocalAuth # data on disk. Without this, the next connect would silently @@ -205,17 +235,14 @@ async def logout(self, args: List[str]) -> Tuple[bool, str]: await bridge.logout() else: # Bridge isn't running but LocalAuth data may still exist - # from a previous session — wipe it directly. + # from a previous session — wipe this account's own auth + # dir directly (never the shared multi-account root). import shutil from pathlib import Path - from ...config import ConfigStore - shutil.rmtree( - Path(ConfigStore.project_root) - / ".credentials" - / "whatsapp_wwebjs_auth", - ignore_errors=True, - ) + shutil.rmtree(Path(bridge.auth_dir), ignore_errors=True) + if identity: + drop_whatsapp_bridge(identity) from ...manager import get_external_comms_manager manager = get_external_comms_manager() @@ -301,6 +328,14 @@ def _get_bridge(self): self._bridge = get_whatsapp_bridge() return self._bridge + def _store_updated_credential(self, updated: WhatsAppWebCredential) -> None: + """Persist refreshed owner info captured from the bridge's ready + event. Bound multi-account clients (the v2 provider binding) + override this to route through the account store instead of the + legacy whatsapp_web.json.""" + save_credential(self.spec.cred_file, updated) + self._cred = updated + async def connect(self) -> None: bridge = self._get_bridge() if not bridge.is_running: @@ -753,8 +788,7 @@ async def start_listening(self, callback) -> None: owner_phone=bridge.owner_phone or cred.owner_phone, owner_name=bridge.owner_name or cred.owner_name, ) - save_credential(self.spec.cred_file, updated) - self._cred = updated + self._store_updated_credential(updated) self._listening = True self._connected = True @@ -962,16 +996,127 @@ def _is_mention_for_me(self, text: str) -> bool: # ════════════════════════════════════════════════════════════════════════ # QR-session helpers — for non-blocking UIs that poll # ════════════════════════════════════════════════════════════════════════ +# +# Multi-account flow: every ``start_qr_session`` gets a real uuid session +# id and a fresh *pending* bridge (own Node process, own temp auth dir), +# so concurrent QR logins never collide. When the scan completes, the +# identity is read from the bridge's ready event, the pending bridge is +# re-keyed to that identity (``promote_pending_bridge``), and +# ``check_qr_session_status`` returns ``status="connected"`` **with the +# identity and the full credential dict** — the HOST stores the account +# via the IntegrationSystem (this package must not import from app/, so +# it cannot write the AccountSet itself). +# +# Legacy-json compatibility: the legacy single-account whatsapp_web.json +# is still written for the FIRST account only (when no such file exists +# yet) so the pre-wiring host path and the core's legacy-file migration +# keep working; later accounts never touch it. + +_qr_sessions: Dict[str, Any] = {} # session_id -> pending WhatsAppBridge + + +def _write_legacy_credential_if_first( + identity: str, owner_phone: str, owner_name: str +) -> bool: + """Mirror the FIRST connected account into the legacy whatsapp_web.json + (zero-cost interim compatibility); never overwrite it for later + accounts — that was exactly the single-account overwrite bug class.""" + if has_credential(WHATSAPP_WEB.cred_file): + return False + save_credential( + WHATSAPP_WEB.cred_file, + WhatsAppWebCredential( + session_id=identity, + owner_phone=owner_phone, + owner_name=owner_name, + ), + ) + return True + + +async def _complete_qr_session(session_id: str, bridge: Any) -> Dict[str, Any]: + """A pending bridge reached ``ready``: capture identity + owner info, + promote the bridge to its identity key, and hand the credential back + for the host to store.""" + from ._bridge_client import ( + discard_pending_bridge, + normalize_wa_identity, + promote_pending_bridge, + ) + + owner_phone = bridge.owner_phone or "" + owner_name = bridge.owner_name or "" + wid = getattr(bridge, "wid", "") or "" + identity = normalize_wa_identity(wid or owner_phone) + + _qr_sessions.pop(session_id, None) + + if identity is None: + # Connected but no usable identity — should not happen (the ready + # event always carries the wid); don't leave a nameless Chromium + # running. + await discard_pending_bridge(session_id) + return { + "success": False, + "status": "error", + "connected": False, + "message": ( + "WhatsApp connected but did not report a phone number/wid. " + "Please try again." + ), + } + + await promote_pending_bridge(session_id, identity) -_qr_sessions: Dict[str, Any] = {} + credential = { + "session_id": identity, + "owner_phone": owner_phone, + "owner_name": owner_name, + "wid": wid, + } + + if _write_legacy_credential_if_first(identity, owner_phone, owner_name): + # First account, legacy path still wired: best-effort listener + # start exactly as before. Later accounts are started by the v2 + # host wiring after it stores the credential. + try: + from ...manager import get_external_comms_manager + + manager = get_external_comms_manager() + if manager: + await manager.start_platform(WHATSAPP_WEB.platform_id) + except Exception: + pass + + display = owner_phone or owner_name or identity + return { + "success": True, + "status": "connected", + "connected": True, + "session_id": session_id, + "identity": identity, + "owner_phone": owner_phone, + "owner_name": owner_name, + "credential": credential, + "message": f"WhatsApp connected: +{display}", + } async def start_qr_session() -> Dict[str, Any]: - """Start the bridge and return either ``qr_ready`` (with QR data URL) or - ``connected`` (already authenticated). Caller polls - ``check_qr_session_status(session_id)`` until ``connected``.""" + """Start a fresh pending login bridge and return either ``qr_ready`` + (with QR data URL and a uuid ``session_id``) or — should the fresh + session somehow already be authenticated — ``connected`` (with + ``identity`` + ``credential`` for the host to store). Caller polls + ``check_qr_session_status(session_id)`` until ``connected``. + + Refused with a clear error when the ``max_accounts`` cap is reached + (each account costs a headless Chromium, ~300-500 MB RAM).""" try: - from ._bridge_client import get_whatsapp_bridge + from ._bridge_client import ( + BridgeCapacityError, + create_pending_bridge, + discard_pending_bridge, + ) except ImportError: return { "success": False, @@ -979,31 +1124,20 @@ async def start_qr_session() -> Dict[str, Any]: "message": "WhatsApp bridge not available. Ensure Node.js >= 18 is installed.", } + session_id = uuid.uuid4().hex try: - bridge = get_whatsapp_bridge() - if not bridge.is_running: - await bridge.start() + bridge = create_pending_bridge(session_id) + except BridgeCapacityError as e: + return {"success": False, "status": "error", "message": str(e)} + + try: + await bridge.start() event_type, event_data = await bridge.wait_for_qr_or_ready(timeout=60.0) if event_type == "ready": - owner_phone = bridge.owner_phone or "" - owner_name = bridge.owner_name or "" - save_credential( - WHATSAPP_WEB.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) - display = owner_phone or owner_name or "connected" - return { - "success": True, - "session_id": "bridge", - "qr_code": "", - "status": "connected", - "message": f"WhatsApp already connected: +{display}", - } + # A pending dir is always fresh, so this is belt-and-braces — + # but if it happens, finish the login properly. + return await _complete_qr_session(session_id, bridge) if event_type == "qr": qr_data = (event_data or {}).get("qr_data_url", "") @@ -1026,7 +1160,7 @@ async def start_qr_session() -> Dict[str, Any]: logger.warning(f"Failed to generate QR image: {e}") if not qr_data: - await bridge.stop() + await discard_pending_bridge(session_id) return { "success": False, "status": "error", @@ -1035,7 +1169,6 @@ async def start_qr_session() -> Dict[str, Any]: if qr_data and not qr_data.startswith("data:"): qr_data = f"data:image/png;base64,{qr_data}" - session_id = "bridge" _qr_sessions[session_id] = bridge return { "success": True, @@ -1045,7 +1178,7 @@ async def start_qr_session() -> Dict[str, Any]: "message": "Scan the QR code with your WhatsApp mobile app", } - await bridge.stop() + await discard_pending_bridge(session_id) return { "success": False, "status": "error", @@ -1053,6 +1186,12 @@ async def start_qr_session() -> Dict[str, Any]: } except Exception as e: logger.error(f"Failed to start WhatsApp QR session: {e}") + try: + from ._bridge_client import discard_pending_bridge + + await discard_pending_bridge(session_id) + except Exception: + pass return { "success": False, "status": "error", @@ -1061,8 +1200,15 @@ async def start_qr_session() -> Dict[str, Any]: async def check_qr_session_status(session_id: str) -> Dict[str, Any]: - """Poll a started QR session. On ``connected`` it saves the credential - and starts the platform listener if a manager is running.""" + """Poll a started QR session. + + On ``connected`` the result carries everything the host needs to + store the account: ``identity`` (normalized owner phone/wid) and + ``credential`` (the full dict — session_id, owner_phone, owner_name, + wid). This function does NOT write the AccountSet itself (layering: + craftos_integrations never imports from app/); the host does that via + the IntegrationSystem. Only the legacy first-account json mirror is + written here (see ``_write_legacy_credential_if_first``).""" bridge = _qr_sessions.get(session_id) if bridge is None: return { @@ -1074,47 +1220,15 @@ async def check_qr_session_status(session_id: str) -> Dict[str, Any]: try: if bridge.is_ready: + return await _complete_qr_session(session_id, bridge) + elif not bridge.is_running: + _qr_sessions.pop(session_id, None) try: - owner_phone = bridge.owner_phone or "" - owner_name = bridge.owner_name or "" - save_credential( - WHATSAPP_WEB.cred_file, - WhatsAppWebCredential( - session_id="bridge", - owner_phone=owner_phone, - owner_name=owner_name, - ), - ) - del _qr_sessions[session_id] - - # Best-effort: start the listener if a manager is running. - try: - from ...manager import get_external_comms_manager - - manager = get_external_comms_manager() - if manager: - await manager.start_platform(WHATSAPP_WEB.platform_id) - except Exception: - pass + from ._bridge_client import discard_pending_bridge - display = owner_phone or owner_name or "connected" - return { - "success": True, - "status": "connected", - "connected": True, - "message": f"WhatsApp connected: +{display}", - } - except Exception as e: - logger.error(f"Failed to store WhatsApp credential: {e}") - return { - "success": False, - "status": "error", - "connected": False, - "message": f"Connected but failed to save: {e}", - } - elif not bridge.is_running: - if session_id in _qr_sessions: - del _qr_sessions[session_id] + await discard_pending_bridge(session_id) + except Exception: + pass return { "success": False, "status": "error", @@ -1139,15 +1253,29 @@ async def check_qr_session_status(session_id: str) -> Dict[str, Any]: def cancel_qr_session(session_id: str) -> Dict[str, Any]: + """Cancel a pending QR login: stop its bridge AND delete its temp auth + dir (via ``discard_pending_bridge``). Safe for unknown/finished ids.""" bridge = _qr_sessions.pop(session_id, None) - if bridge is not None: + if bridge is None: + return {"success": True, "message": "Session not found or already cancelled."} + + async def _cleanup() -> None: try: - loop = asyncio.get_event_loop() - if loop.is_running(): - asyncio.ensure_future(bridge.stop()) - else: - loop.run_until_complete(bridge.stop()) - except Exception: - pass - return {"success": True, "message": "Session cancelled."} - return {"success": True, "message": "Session not found or already cancelled."} + from ._bridge_client import discard_pending_bridge + + await discard_pending_bridge(session_id) + except Exception as e: + logger.warning(f"Failed to clean up WhatsApp QR session: {e}") + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + try: + if loop is not None: + asyncio.ensure_future(_cleanup()) + else: + asyncio.run(_cleanup()) + except Exception: + pass + return {"success": True, "message": "Session cancelled."} diff --git a/craftos_integrations/integrations/whatsapp_web/_bridge_client.py b/craftos_integrations/integrations/whatsapp_web/_bridge_client.py index ac9bcfa7..990f1082 100644 --- a/craftos_integrations/integrations/whatsapp_web/_bridge_client.py +++ b/craftos_integrations/integrations/whatsapp_web/_bridge_client.py @@ -3,6 +3,20 @@ Manages the Node.js subprocess lifecycle and provides an async API for sending commands and receiving events via stdin/stdout JSON lines. + +Multi-account model (legacy-to-v2 migration plan §5): one +``WhatsAppBridge`` — one Node subprocess driving one headless Chromium — +per connected WhatsApp account. Instances live in a module registry +keyed by the normalized account identity (see ``normalize_wa_identity``) +and each gets its own LocalAuth directory +``.credentials/whatsapp_wwebjs_auth//`` so Chromium profile +locks, session data, and logout cleanup are account-scoped. ``bridge.js`` +already takes the auth dir as argv — the Node side needs no changes. + +Pending logins (QR scan in progress, identity unknown until the +``ready`` event reports the wid) run under a temporary key — the QR +session id — with a fresh ``pending-/`` dir, then get +re-keyed to the identity via ``promote_pending_bridge``. """ from __future__ import annotations @@ -27,7 +41,16 @@ class WhatsAppBridge: - def __init__(self, auth_dir: Optional[str] = None): + def __init__(self, auth_dir: str, legacy_guard: bool = False): + """``auth_dir`` is this instance's private LocalAuth directory — + always account-scoped (``whatsapp_wwebjs_auth//`` or a + ``pending-/`` dir), never the shared root. + + ``legacy_guard`` is set only for bridges resolved through the + legacy single-account path (``get_whatsapp_bridge()`` with no + identity): it enables the whatsapp_web.json orphan-wipe check, + which is meaningless for v2 accounts (their lifecycle is the + AccountSet + ``teardown_account``, not the legacy json).""" self._process: Optional[asyncio.subprocess.Process] = None self._reader_task: Optional[asyncio.Task] = None self._stderr_task: Optional[asyncio.Task] = None @@ -38,13 +61,8 @@ def __init__(self, auth_dir: Optional[str] = None): self._owner_phone = "" self._owner_name = "" self._wid = "" - - if auth_dir: - self._auth_dir = auth_dir - else: - self._auth_dir = str( - ConfigStore.project_root / ".credentials" / "whatsapp_wwebjs_auth" - ) + self._auth_dir = auth_dir + self._legacy_guard = legacy_guard @property def is_running(self) -> bool: @@ -66,6 +84,15 @@ def owner_phone(self) -> str: def owner_name(self) -> str: return self._owner_name + @property + def wid(self) -> str: + """Full WhatsApp id from the ready event (e.g. ``123...:12@c.us``).""" + return self._wid + + @property + def auth_dir(self) -> str: + return self._auth_dir + def set_event_callback(self, callback: Optional[EventCallback]) -> None: self._event_callback = callback @@ -176,7 +203,16 @@ def _wipe_orphan_localauth_if_disconnected(self) -> None: but the logout RPC didn't finish wiping the session before reconnect. Force-wipe the auth dir so the next connect demands a fresh QR instead of silently restoring the stale session. + + LEGACY-ONLY: applies only to bridges resolved through the legacy + single-account path (``legacy_guard``). For v2 multi-account + bridges the legacy whatsapp_web.json says nothing about whether + THIS account is connected — using it here would wipe account #2's + session because account #1's legacy file was migrated away. v2 + cleanup happens via ``teardown_account``. """ + if not self._legacy_guard: + return import shutil cred_path = ( @@ -716,11 +752,386 @@ def _handle_event(self, event: str, data: Dict[str, Any]) -> None: asyncio.ensure_future(self._event_callback(event, data)) -_bridge_instance: Optional[WhatsAppBridge] = None +# ════════════════════════════════════════════════════════════════════════ +# Identity normalization — THE one rule, used by the provider, the QR +# flow, and the registry alike +# ════════════════════════════════════════════════════════════════════════ + + +def normalize_wa_identity(value: Any) -> Optional[str]: + """Normalize a WhatsApp phone/wid to the canonical account identity. + + ``14155552671:12@c.us`` (wid with device suffix), ``14155552671@c.us``, + ``+1 (415) 555-2671`` and ``14155552671`` all collapse to + ``14155552671``: strip the ``@c.us`` domain, strip the ``:NN`` device + suffix, keep digits only, strip leading zeros (the ``00`` + international-prefix ambiguity — same rationale as telegram_user). + Returns None for anything that yields no digits. Already lowercase by + construction (digits), satisfying the conformance identity rules. + """ + if value is None: + return None + text = str(value).strip().lower() + if not text: + return None + text = text.split("@", 1)[0] # wid domain: 14155552671@c.us + text = text.split(":", 1)[0] # device suffix: 14155552671:12 + digits = "".join(ch for ch in text if ch.isdigit()).lstrip("0") + return digits or None + + +# ════════════════════════════════════════════════════════════════════════ +# Per-account bridge registry +# ════════════════════════════════════════════════════════════════════════ + +_PENDING_DIR_PREFIX = "pending-" +# Legacy CLI login path only: no identity known and no legacy credential +# to derive one from — the bridge lives under this key/dir until the +# credential exists, then the dir is adopted into the identity dir on the +# next resolution (see _adopt_default_dir). +_DEFAULT_IDENTITY_KEY = "default" + +_bridges: Dict[str, WhatsAppBridge] = {} +_pending_keys: set = set() # session ids currently registered as pending +_layout_migrated = False + + +class BridgeCapacityError(RuntimeError): + """Raised when starting another bridge would exceed ``max_accounts``.""" + + +def _auth_root() -> Path: + return Path(ConfigStore.project_root) / ".credentials" / "whatsapp_wwebjs_auth" + + +def _identity_auth_dir(identity: str) -> Path: + return _auth_root() / identity + + +def _pending_auth_dir(session_id: str) -> Path: + return _auth_root() / f"{_PENDING_DIR_PREFIX}{session_id}" + + +def _legacy_owner_identity() -> Optional[str]: + """Normalized identity from the legacy single-account + ``whatsapp_web.json``, or None if it doesn't exist / has no phone.""" + try: + from ...credentials_store import load_credential + from . import WHATSAPP_WEB, WhatsAppWebCredential + + cred = load_credential(WHATSAPP_WEB.cred_file, WhatsAppWebCredential) + except Exception: + return None + if cred is None: + return None + return normalize_wa_identity(cred.owner_phone) + +def max_whatsapp_accounts() -> int: + """The ``max_accounts`` knob from whatsapp_web_config.json (default 2). -def get_whatsapp_bridge() -> WhatsAppBridge: - global _bridge_instance - if _bridge_instance is None: - _bridge_instance = WhatsAppBridge() - return _bridge_instance + A RAM guard, not a hard platform limit: every connected account runs + its own headless Chromium (~300–500 MB).""" + try: + from ...credentials_store import load_config + from . import WhatsAppWebConfig, _whatsapp_web_config_file + + cfg = ( + load_config(_whatsapp_web_config_file(), WhatsAppWebConfig) + or WhatsAppWebConfig() + ) + value = int(getattr(cfg, "max_accounts", 2)) + except Exception: + return 2 + return max(1, value) + + +def _account_slots_used() -> int: + """Connected-account count for cap enforcement: identity auth dirs on + disk (robust across restarts — a connected account always has one) + unioned with registered non-pending bridges, plus pending logins.""" + identities = {key for key in _bridges if key not in _pending_keys} + root = _auth_root() + try: + if root.exists(): + for child in root.iterdir(): + if child.is_dir() and child.name.isdigit(): + identities.add(child.name) + except OSError: + pass + return len(identities) + len(_pending_keys) + + +def _ensure_layout_migrated() -> None: + """One-time move of the OLD single-account layout + (``whatsapp_wwebjs_auth/session/`` directly under the root) into the + per-identity layout (``whatsapp_wwebjs_auth//session/``), + using the identity from the legacy whatsapp_web.json. If no legacy + credential exists we can't name the account — leave the old layout in + place and log (a fresh QR login will simply use a new identity dir). + """ + global _layout_migrated + if _layout_migrated: + return + _layout_migrated = True + + root = _auth_root() + old_session = root / "session" + if not old_session.exists(): + return + + identity = _legacy_owner_identity() + if not identity: + logger.info( + f"[WA-Bridge] old single-account auth layout found at {root} but " + "no legacy whatsapp_web.json to derive an identity from — " + "leaving it in place" + ) + return + + target = _identity_auth_dir(identity) + if target.exists(): + logger.warning( + f"[WA-Bridge] both the old auth layout and {target} exist — " + "keeping the identity dir, leaving the old layout untouched" + ) + return + + import shutil + + target.mkdir(parents=True, exist_ok=True) + moved = 0 + for child in list(root.iterdir()): + name = child.name + # Only old-layout content: never touch identity dirs (all-digit + # names), pending dirs, or the target itself. + if child == target or name.isdigit() or name.startswith(_PENDING_DIR_PREFIX): + continue + try: + shutil.move(str(child), str(target / name)) + moved += 1 + except OSError as e: + logger.warning(f"[WA-Bridge] migration could not move {child}: {e}") + logger.info( + f"[WA-Bridge] migrated old single-account auth layout into {target} " + f"({moved} entrie(s)) for identity {identity}" + ) + + +def _adopt_default_dir(identity: str) -> None: + """Legacy CLI login quirk: a login that started with no credential ran + under the ``default`` dir; once the credential names the identity, + move that session into the identity dir so the next start doesn't + demand a fresh QR. Skipped while a live bridge holds the dir.""" + default_dir = _identity_auth_dir(_DEFAULT_IDENTITY_KEY) + target = _identity_auth_dir(identity) + if target.exists() or not (default_dir / "session").exists(): + return + stale = _bridges.get(_DEFAULT_IDENTITY_KEY) + if stale is not None: + if stale.is_running: + return # Chromium holds the dir — can't move it out from under it. + _bridges.pop(_DEFAULT_IDENTITY_KEY, None) + import shutil + + try: + shutil.move(str(default_dir), str(target)) + logger.info(f"[WA-Bridge] adopted default auth dir as {target}") + except OSError as e: + logger.warning(f"[WA-Bridge] could not adopt default auth dir: {e}") + + +def get_whatsapp_bridge(identity: Optional[str] = None) -> WhatsAppBridge: + """The per-account bridge for ``identity`` (any phone/wid spelling — + normalized here), creating it (stopped) on first use. + + ``identity=None`` is the legacy single-account path (CLI handler, + unbound legacy client): the identity is resolved from the legacy + whatsapp_web.json, falling back to a ``default`` slot when no + credential exists yet. v2 callers always pass an identity. + """ + _ensure_layout_migrated() + legacy_guard = False + if identity is None: + legacy_guard = True + resolved = _legacy_owner_identity() + if resolved is None: + resolved = _DEFAULT_IDENTITY_KEY + else: + _adopt_default_dir(resolved) + key = resolved + else: + normalized = normalize_wa_identity(identity) + if normalized is None: + raise ValueError(f"invalid whatsapp identity: {identity!r}") + key = normalized + + bridge = _bridges.get(key) + if bridge is None: + bridge = WhatsAppBridge( + auth_dir=str(_identity_auth_dir(key)), legacy_guard=legacy_guard + ) + _bridges[key] = bridge + return bridge + + +def peek_whatsapp_bridge(identity: str) -> Optional[WhatsAppBridge]: + """Registry lookup without creating: the bridge for ``identity`` if one + has been created this process, else None.""" + normalized = normalize_wa_identity(identity) + if normalized is None: + return None + return _bridges.get(normalized) + + +def drop_whatsapp_bridge(identity: str) -> Optional[WhatsAppBridge]: + """Remove ``identity``'s bridge from the registry WITHOUT stopping it — + the caller owns shutdown. Returns the removed bridge (or None). For + full account removal (stop + server logout + auth-dir delete) use + ``teardown_account`` instead.""" + normalized = normalize_wa_identity(identity) + if normalized is None: + return None + return _bridges.pop(normalized, None) + + +def create_pending_bridge(session_id: str) -> WhatsAppBridge: + """A fresh bridge for a QR login in progress, registered under the QR + ``session_id`` with its own ``pending-/`` auth dir (so + concurrent QR sessions never share Chromium state). Raises + ``BridgeCapacityError`` when the ``max_accounts`` cap is reached.""" + _ensure_layout_migrated() + existing = _bridges.get(session_id) + if existing is not None: + return existing + limit = max_whatsapp_accounts() + used = _account_slots_used() + if used >= limit: + raise BridgeCapacityError( + f"WhatsApp account limit reached ({used}/{limit}). Every connected " + "account runs its own headless Chromium browser (~300-500 MB RAM). " + "Disconnect an account first, or raise 'max_accounts' in the " + "WhatsApp integration settings if this machine has RAM to spare." + ) + bridge = WhatsAppBridge(auth_dir=str(_pending_auth_dir(session_id))) + _bridges[session_id] = bridge + _pending_keys.add(session_id) + return bridge + + +async def discard_pending_bridge(session_id: str) -> None: + """Cancel/cleanup a pending QR login: stop its bridge (tight-timeout + abandon — the session is being thrown away) and delete its temp dir.""" + _pending_keys.discard(session_id) + bridge = _bridges.pop(session_id, None) + if bridge is not None and bridge.is_running: + try: + await bridge.abandon() + except Exception as e: + logger.warning(f"[WA-Bridge] pending-bridge abandon failed: {e}") + await _rmtree_with_retry(_pending_auth_dir(session_id)) + + +async def promote_pending_bridge(session_id: str, identity: str) -> WhatsAppBridge: + """Re-key a connected pending-login bridge to its account identity. + + The pending Node/Chromium is STOPPED first — Windows cannot rename a + profile dir under a live browser — then the fresh auth dir is moved to + ``/`` and a stopped bridge is registered under the identity. + The next ``start()`` (host listener wiring) restores the session from + LocalAuth without a new QR scan. + + Re-login of an already-connected account: the FRESH session wins — the + old bridge is stopped/dropped and its auth dir replaced. (The fresh + scan is the one the user just performed; the old LocalAuth may be the + very stale state that forced the re-login.) + """ + normalized = normalize_wa_identity(identity) + if normalized is None: + raise ValueError(f"invalid whatsapp identity: {identity!r}") + + _pending_keys.discard(session_id) + pending = _bridges.pop(session_id, None) + if pending is None: + raise KeyError(f"no pending whatsapp bridge for session {session_id}") + if pending.is_running: + try: + await pending.stop() + except Exception as e: + logger.warning(f"[WA-Bridge] pending-bridge stop before promote: {e}") + + previous = _bridges.pop(normalized, None) + if previous is not None and previous.is_running: + try: + await previous.stop() + except Exception as e: + logger.warning(f"[WA-Bridge] old bridge stop during re-login: {e}") + + target = _identity_auth_dir(normalized) + if target.exists(): + await _rmtree_with_retry(target) + + src = _pending_auth_dir(session_id) + if src.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + await _move_with_retry(src, target) + + bridge = WhatsAppBridge(auth_dir=str(target)) + _bridges[normalized] = bridge + return bridge + + +async def teardown_account(identity: str) -> None: + """Host hook for account removal: stop and forget ``identity``'s bridge + and delete its LocalAuth dir. A server-side logout is attempted first + (mirrors the legacy disconnect semantics — without it the next QR + login could silently restore the old session). Safe to call for an + identity with no live bridge; idempotent.""" + normalized = normalize_wa_identity(identity) + if normalized is None: + return + _ensure_layout_migrated() + bridge = _bridges.pop(normalized, None) + if bridge is not None: + try: + # logout() invalidates server-side and rmtree's its own dir; + # on a non-running bridge it degrades to just the dir wipe. + await bridge.logout() + except Exception as e: + logger.warning(f"[WA-Bridge] teardown logout for {normalized}: {e}") + await _rmtree_with_retry(_identity_auth_dir(normalized)) + + +async def _rmtree_with_retry(path: Path, attempts: int = 5) -> None: + """Windows: Chromium file locks linger briefly after process exit.""" + import shutil + + for i in range(attempts): + if not path.exists(): + return + shutil.rmtree(path, ignore_errors=(i == attempts - 1)) + if not path.exists(): + return + await asyncio.sleep(0.4) + + +async def _move_with_retry(src: Path, dst: Path, attempts: int = 5) -> None: + import shutil + + last_error: Optional[Exception] = None + for _ in range(attempts): + try: + shutil.move(str(src), str(dst)) + return + except OSError as e: + last_error = e + await asyncio.sleep(0.4) + raise RuntimeError(f"could not move {src} to {dst}: {last_error}") + + +def _reset_bridge_registry_for_tests() -> None: + """Test hook: forget all bridges and re-arm the layout migration.""" + global _layout_migrated + _bridges.clear() + _pending_keys.clear() + _layout_migrated = False diff --git a/craftos_integrations/providers/__init__.py b/craftos_integrations/providers/__init__.py index 9dca0625..059dbffc 100644 --- a/craftos_integrations/providers/__init__.py +++ b/craftos_integrations/providers/__init__.py @@ -17,18 +17,32 @@ def default_providers() -> List[Provider]: + from .discord import DiscordProvider + from .github import GitHubProvider from .gmail import GmailProvider from .google_calendar import GoogleCalendarProvider from .google_docs import GoogleDocsProvider from .google_drive import GoogleDriveProvider from .google_youtube import GoogleYoutubeProvider from .hubspot import HubSpotProvider + from .jira import JiraProvider + from .lark import LarkProvider + from .lark_calendar import LarkCalendarProvider + from .lark_drive import LarkDriveProvider + from .line import LineProvider from .linkedin import LinkedInProvider from .notion import NotionProvider from .outlook import OutlookProvider from .slack import SlackProvider + from .stripe import StripeProvider + from .telegram_bot import TelegramBotProvider + from .telegram_user import TelegramUserProvider + from .twitter import TwitterProvider + from .whatsapp_business import WhatsAppBusinessProvider + from .whatsapp_web import WhatsAppWebProvider return [ + # Full ports — operations generated from the provider. GmailProvider(), GoogleCalendarProvider(), GoogleDocsProvider(), @@ -39,4 +53,23 @@ def default_providers() -> List[Provider]: NotionProvider(), OutlookProvider(), SlackProvider(), + # Auth-layer bridges — multi-account storage/UI/listeners; the + # legacy action surface stays, made account-aware centrally + # (see app/data/action/integrations/account_bridge.py). + # Wave 1: + GitHubProvider(), + JiraProvider(), + LineProvider(), + StripeProvider(), + WhatsAppBusinessProvider(), + # Wave 2 (lark siblings share family="lark" aliases): + DiscordProvider(), + LarkProvider(), + LarkCalendarProvider(), + LarkDriveProvider(), + TelegramBotProvider(), + TwitterProvider(), + # Wave 3 — interactive logins (QR / phone+code): + TelegramUserProvider(), + WhatsAppWebProvider(), ] diff --git a/craftos_integrations/providers/_lark.py b/craftos_integrations/providers/_lark.py new file mode 100644 index 00000000..f2b153c7 --- /dev/null +++ b/craftos_integrations/providers/_lark.py @@ -0,0 +1,205 @@ +"""Lark family provider base — shared by lark / lark_calendar / lark_drive. + +Auth-layer bridge port (wave 2) of the legacy Lark integrations. Like the +Google family (``_google.py``), the three Lark services are sibling +provider ids that share one conceptual account — a Lark Custom App +(App ID + App Secret) — so ``family = "lark"`` lets the core sync aliases +across siblings (``core/accounts.py sync_family_aliases``). + +Bridge pattern (see stripe/github providers for the wave-1 rationale): +``operations()`` is empty and ``guidance()`` blank — the legacy Lark +action surface stays in place; only the credential plumbing is replaced. + +Token-only: a Lark Custom App has no per-user OAuth here (auth is the +app's own tenant_access_token minted from App ID + Secret), so +``oauth_spec()`` raises NotImplementedError and there is no ``run_login``. + +Tenant-token refresh — the one disk write the binding must intercept: +the legacy clients cache a ~2h ``tenant_access_token`` in the credential +and refresh it via ``_lark_common.ensure_token``, which writes the +refreshed credential back to the single-account ``lark*.json`` file +(cross-wiring secondaries). The binding's ``_load()`` pre-refreshes the +bound credential through ``persist`` whenever the token is within +``_REFRESH_MARGIN`` seconds of expiry, so every legacy ``ensure_token`` +call site (``make_headers`` plus lark_drive's direct upload/download +calls) sees a fresh token, takes its cache-hit branch, and its +``save_credential`` is never reached. +""" + +from __future__ import annotations + +import time +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ..contracts import OAuthSpec, Operation +from ..integrations._lark_common import LarkCredential, validate_and_mint_token +from ..logger import get_logger +from ._shared import LegacyListenerAdapter + +logger = get_logger(__name__) + +LARK_FAMILY = "lark" + +_CRED_FIELDS = {f.name for f in fields(LarkCredential)} + +# The binding refreshes when within this many seconds of expiry. MUST stay +# wider than the legacy ``ensure_token``'s 60s threshold: when the bound +# credential reaches a legacy call site, the legacy freshness check +# ``token_expires_at > now + 60`` must hold, so the legacy save branch +# (which writes the single-account credential file) is never entered. +_REFRESH_MARGIN = 120.0 + + +class LarkClientBinding: + """Overrides a legacy Lark client's disk plumbing: credential is + injected per account, tenant-token refresh persists through the core. + MRO puts this before the legacy client: + + class BoundLarkClient(LarkClientBinding, LarkClient): pass + + Works unchanged for all three legacy clients (lark / lark_calendar / + lark_drive) because they share ``LarkCredential`` and the same + ``has_credentials``/``_load``/``_headers`` plumbing shape. + """ + + _cred: Optional[LarkCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = LarkCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> LarkCredential: + """Bound credential, guaranteed token-fresh (see module docstring: + pre-refreshing here is what keeps the legacy ``ensure_token`` from + ever writing the legacy credential file).""" + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + self._refresh_token_if_needed(self._cred) + return self._cred + + def _refresh_token_if_needed(self, cred: LarkCredential) -> None: + now = time.time() + if cred.tenant_access_token and cred.token_expires_at > now + _REFRESH_MARGIN: + return + token, expires_at, err = validate_and_mint_token(cred.app_id, cred.app_secret) + if err: + raise RuntimeError(f"Lark token refresh failed: {err}") + cred.tenant_access_token = token or "" + cred.token_expires_at = expires_at + # In-memory + core persist ONLY — never the legacy lark*.json file. + self._persist(asdict(cred)) + + +class LarkProviderBase: + """Subclasses set: id, display_name, client_cls (bound class). + + All three Lark providers are bridges, so operations()/guidance() are + concrete (empty) here, unlike the Google base. + """ + + id: str = "" + display_name: str = "" + client_cls: type = None # LarkClientBinding subclass + family = LARK_FAMILY + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """The Custom App's ``app_id`` (cli_…), lowercased — one Lark app + = one account across the whole family. None for junk shapes.""" + try: + app_id = credential.get("app_id") + except AttributeError: + return None + if isinstance(app_id, str) and app_id.strip(): + return app_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError(f"{self.id} is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Out-of-band tenant-token refresh (listener wake-up etc.); + operations normally refresh inline via the binding's ``_load``. + Returns the updated credential dict only when a refresh actually + happened; None when the cached token is still fresh or the app + credentials no longer mint.""" + holder: Dict[str, Any] = {} + client = self.build_client(credential, holder.update) + try: + client._load() + except RuntimeError as e: + logger.warning(f"[LARK] out-of-band refresh failed: {e}") + return None + return holder or None + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification every legacy Lark handler's login() runs: + mint a tenant_access_token from App ID + Secret via + ``validate_and_mint_token``. Same field keys as the handlers' + ``fields``: ``app_id`` + ``app_secret`` — identity (app_id) is in + the fields by construction, but still validated against the API. + + Returns (ok, message, credential); credential is the asdict of + ``LarkCredential`` with the freshly minted token cached. + """ + app_id = (credentials.get("app_id") or "").strip() + app_secret = (credentials.get("app_secret") or "").strip() + if not app_id: + return False, "Missing Lark App ID (app_id).", None + if not app_secret: + return False, "Missing Lark App Secret (app_secret).", None + + token, expires_at, err = validate_and_mint_token(app_id, app_secret) + if err: + return False, err, None + + credential = asdict( + LarkCredential( + app_id=app_id, + app_secret=app_secret, + tenant_access_token=token or "", + token_expires_at=expires_at, + ) + ) + return True, f"{self.display_name} connected: {app_id}", credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy Lark actions stay in place + + def guidance(self) -> str: + return "" # bridge provider — the legacy action surface has its own docs + + # Whether this sibling's platform has an inbound listen loop — a static + # property of the platform, not of a client instance (the lark + # messaging client's lark-oapi WebSocket loop; calendar and drive are + # request-response only). LarkProvider overrides to True. + has_listener: bool = False + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> Optional[LegacyListenerAdapter]: + if self.has_listener: + return LegacyListenerAdapter(client, emit) + return None diff --git a/craftos_integrations/providers/_shared.py b/craftos_integrations/providers/_shared.py index 1f6653f4..f28f2604 100644 --- a/craftos_integrations/providers/_shared.py +++ b/craftos_integrations/providers/_shared.py @@ -61,6 +61,38 @@ async def _callback(msg: Any) -> None: return _callback +class LegacyListenerAdapter: + """Generic ``Listener`` over a bound legacy client's own listen loop. + + For bridge providers (auth-layer-only ports): the account-bound client + IS a legacy ``BasePlatformClient`` subclass, so its battle-tested + ``start_listening``/``stop_listening`` loop is reused verbatim — + events are converted per message by ``emit_callback``. No cursor: the + legacy loops keep watermarks in memory and run their own catch-up on + start, exactly as they did under ExternalCommsManager. Providers + needing restart-safe cursors get a hand-written listener instead + (see slack/listener.py for the pattern). + """ + + def __init__(self, client: Any, emit: EmitFn) -> None: + self._client = client + self._emit = emit + + async def start(self) -> None: + # The supervisor re-invokes start() after every clean cycle; the + # legacy loops were started exactly once by ExternalCommsManager and + # may not guard against double-starts — spawn only when not running. + if getattr(self._client, "is_listening", False): + return + await self._client.start_listening(emit_callback(self._emit)) + + async def stop(self) -> None: + await self._client.stop_listening() + + def cursor(self) -> Optional[Dict[str, Any]]: + return None + + def read_guidance(package_file: str) -> str: """Load GUIDANCE.md sitting next to a provider module.""" from pathlib import Path diff --git a/craftos_integrations/providers/discord/__init__.py b/craftos_integrations/providers/discord/__init__.py new file mode 100644 index 00000000..8ade42b0 --- /dev/null +++ b/craftos_integrations/providers/discord/__init__.py @@ -0,0 +1,3 @@ +from .provider import DiscordProvider + +__all__ = ["DiscordProvider"] diff --git a/craftos_integrations/providers/discord/provider.py b/craftos_integrations/providers/discord/provider.py new file mode 100644 index 00000000..cdd2f6a3 --- /dev/null +++ b/craftos_integrations/providers/discord/provider.py @@ -0,0 +1,198 @@ +"""Discord bridge provider — auth-layer-only port of the legacy client. + +Bridge pattern (see stripe/provider.py and github/provider.py): the +battle-tested legacy ``DiscordClient`` keeps its entire API surface (bot +REST, user-account REST, gateway listener, lazy voice); only the +credential plumbing is overridden by a small binding mixin so the +credential is injected per account and never read from the legacy +``discord.json``. ``operations()`` is empty and ``guidance()`` blank — +the legacy action functions remain the tool surface; account routing +happens centrally in the host adapter. + +Discord is token-only (a bot token per Discord application): +``oauth_spec()`` raises NotImplementedError and there is no +``run_login``. Bot tokens do not expire → ``refresh()`` returns None. + +One account = one bot application; identity is the bot's Discord user +id (snowflake) captured from ``GET /users/@me`` at verify time and +stored as ``bot_id`` — the same field the legacy handler.login() saved. + +The credential dataclass also carries an optional ``user_token`` (a +user-account token driving the ``user_*`` client methods). The legacy +handler's ``fields`` only expose ``bot_token``, but ``verify_token`` +passes an optional ``user_token`` through unverified so a credential +built with one keeps working — verification itself is bot-token-based, +exactly like the legacy login. + +Known limitations carried over from the legacy module (NOT refactored +here): +* The listener filter config (``discord_config.json`` — mention_only + + self/third-party allowlists) is loaded from a single global file + inside ``_handle_message_create``, so every account shares one filter + configuration. Listening itself is safe per-instance: all gateway + state (_ws, _ws_task, _heartbeat_task, _last_sequence, _bot_user_id, + _role_name_cache) lives on the client instance. +* Voice: ``_discord_voice.DiscordVoiceManager`` is cached per client + instance (``self._voice_mgr``) and built from the bound bot token, so + two accounts get two managers — but each manager starts a full + discord.py bot gateway session in addition to the raw listen gateway, + and the OpenAI TTS key comes from the process-global + ``ConfigStore.extras``. Left as-is per the bridge scope. +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.discord import ( + DISCORD_API_BASE, + DiscordClient, + DiscordCredential, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(DiscordCredential)} + + +class DiscordClientBinding: + """Overrides DiscordClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundDiscordClient(DiscordClientBinding, DiscordClient): pass + + No token refresh — Discord bot tokens are non-expiring — and the + legacy client never writes the credential file outside handler.login, + so ``_persist`` is never called (kept so the build_client contract is + uniform across providers). + """ + + _cred: Optional[DiscordCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = DiscordCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> DiscordCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundDiscordClient(DiscordClientBinding, DiscordClient): + """DiscordClient with per-account credential binding (see DiscordClientBinding).""" + + +class DiscordProvider: + id = "discord" + family = None # standalone — no cross-provider alias sharing + display_name = "Discord" + client_cls = BoundDiscordClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """The bot's Discord user id (snowflake, stored as ``bot_id``), + stripped/lowercased. None for pre-bridge credentials saved before + the id was captured and for junk shapes — never raises.""" + try: + bot_id = credential.get("bot_id") + except AttributeError: + return None + if isinstance(bot_id, str) and bot_id.strip(): + return bot_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + # Deliberate: no Discord OAuth2 flow — each account is a bot + # application token pasted from the Developer Portal, exactly as + # the legacy handler worked. + raise NotImplementedError("discord is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # Discord bot tokens are non-expiring + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy DiscordHandler.login() runs: + ``GET /users/@me`` with the ``Bot`` token; same handler ``fields`` + key (``bot_token``). The bot's ``id``/``username`` are captured as + ``bot_id``/``bot_username`` so ``identity_of`` resolves the + account immediately. + + An optional ``user_token`` (the credential dataclass's second + token, driving the ``user_*`` client methods) is passed through + unverified — the legacy handler never verified it either. + """ + token = (credentials.get("bot_token") or "").strip() + if not token: + return ( + False, + "A Discord bot token is required. Create one at: " + "https://discord.com/developers/applications", + None, + ) + user_token = (credentials.get("user_token") or "").strip() + + result = http_request( + "GET", + f"{DISCORD_API_BASE}/users/@me", + headers={"Authorization": f"Bot {token}"}, + expected=(200,), + ) + if "error" in result: + return False, f"Invalid Discord bot token: {result['error']}", None + data = result.get("result") or {} + + credential = asdict( + DiscordCredential( + bot_token=token, + user_token=user_token, + bot_id=str(data.get("id") or ""), + bot_username=data.get("username") or "", + ) + ) + return ( + True, + f"Discord bot connected: {data.get('username')} ({data.get('id')})", + credential, + ) + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy Discord actions stay in place + + def guidance(self) -> str: + return "" # bridge provider — the legacy action surface has its own docs + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """Gateway listener — the legacy client's own websocket loop + (Discord Gateway v10: identify with the bot token, heartbeat, + MESSAGE_CREATE → PlatformMessage), reused verbatim via the generic + adapter. All gateway state is per-instance so two accounts can + listen concurrently; the shared piece is the global + ``discord_config.json`` filter config (see module docstring). No + restart-safe cursor, same as under the legacy manager.""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/github/__init__.py b/craftos_integrations/providers/github/__init__.py new file mode 100644 index 00000000..205f33de --- /dev/null +++ b/craftos_integrations/providers/github/__init__.py @@ -0,0 +1,5 @@ +"""GitHub bridge provider package.""" + +from .provider import GitHubProvider + +__all__ = ["GitHubProvider"] diff --git a/craftos_integrations/providers/github/provider.py b/craftos_integrations/providers/github/provider.py new file mode 100644 index 00000000..8e7cb851 --- /dev/null +++ b/craftos_integrations/providers/github/provider.py @@ -0,0 +1,187 @@ +"""GitHub bridge provider — auth-layer-only port of the legacy client. + +Bridge pattern (see slack/provider.py for the full binding rationale): +the battle-tested legacy ``GitHubClient`` keeps its entire API surface; +only the credential plumbing is overridden by a small binding mixin so +the credential is injected per account and never read from the legacy +``github.json``. ``operations()`` is empty and ``guidance()`` blank — +the legacy action functions remain the tool surface; account routing +happens centrally in the host adapter. + +GitHub is token-only (personal access tokens): ``oauth_spec()`` raises +NotImplementedError (the conformance suite's explicit token-only +declaration) and there is no ``run_login``. PATs do not auto-refresh, so +``refresh()`` returns None. + +One account = one GitHub **user**; identity is the GitHub username +(``login``), lowercased — GitHub usernames are case-insensitive. + +The one legacy disk write the binding must intercept: the client's +``start_listening`` backfills ``cred.username`` from ``GET /user`` when +it differs and saves the credential file (legacy module ~line 284). The +binding pre-syncs the username through ``persist`` instead, so the +legacy save never fires and the update lands on the right account entry. +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.github import GITHUB_API, GitHubClient, GitHubCredential +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(GitHubCredential)} + + +class GitHubClientBinding: + """Overrides GitHubClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundGitHubClient(GitHubClientBinding, GitHubClient): pass + + No token refresh — PATs are non-rotating — but ``_persist`` IS used: + the legacy ``start_listening`` backfills the stored username from the + API and would write ``github.json`` (cross-wiring secondaries), so + the binding routes that one update through ``persist`` instead. + """ + + _cred: Optional[GitHubCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = GitHubCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> GitHubCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + async def start_listening(self, callback) -> None: + """Pre-sync the username so the legacy save never fires. + + The legacy ``start_listening`` calls ``GET /user`` and, when the + stored ``username`` differs from the live login, writes the + credential to the legacy single-account file. Doing the same + check here first — persisting through ``self._persist`` — leaves + the legacy branch (``cred.username != username``) false, so its + ``save_credential`` is never reached. Costs one extra cheap + ``GET /user`` at listener start; keeps the poll loop unforked. + """ + if not self._listening: + me = await self.get_authenticated_user() + if "error" not in me: + username = me.get("result", {}).get("login", "") or "" + cred = self._load() + if username and cred.username != username: + cred.username = username + self._persist(asdict(cred)) + await super().start_listening(callback) + + +class BoundGitHubClient(GitHubClientBinding, GitHubClient): + """GitHubClient with per-account credential binding (see GitHubClientBinding).""" + + +class GitHubProvider: + id = "github" + display_name = "GitHub" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundGitHubClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """GitHub username (``login``), lowercased. None for raw-token + credentials saved before the username was captured.""" + try: + username = credential.get("username") + except AttributeError: + return None + if isinstance(username, str) and username.strip(): + return username.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("github is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # personal access tokens do not auto-refresh + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy GitHubHandler.login() runs: + ``GET /user`` with the PAT; same ``fields`` key (``access_token``). + The API's ``login`` is stored as ``username`` so ``identity_of`` + resolves the account immediately. + """ + token = (credentials.get("access_token") or "").strip() + if not token: + return ( + False, + "A GitHub personal access token is required. " + "Generate one at: https://github.com/settings/tokens", + None, + ) + + result = http_request( + "GET", + f"{GITHUB_API}/user", + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + expected=(200,), + ) + if "error" in result: + return False, f"GitHub auth failed: {result['error']}", None + data = result["result"] + + credential = asdict( + GitHubCredential( + access_token=token, + username=data.get("login", ""), + ) + ) + return ( + True, + f"GitHub connected as @{data.get('login')} ({data.get('name', '')})", + credential, + ) + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy action functions stay the surface + + def guidance(self) -> str: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """Notification poll listener — the legacy client's own + ``start_listening`` loop (``GET /notifications`` every 15s with + If-Modified-Since + in-memory seen-id dedup), reused verbatim via + the generic adapter. No restart-safe cursor, same as under the + legacy manager.""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/jira/__init__.py b/craftos_integrations/providers/jira/__init__.py new file mode 100644 index 00000000..1df4853e --- /dev/null +++ b/craftos_integrations/providers/jira/__init__.py @@ -0,0 +1,5 @@ +"""Jira provider package — auth-layer bridge (see provider.py).""" + +from .provider import JiraProvider + +__all__ = ["JiraProvider"] diff --git a/craftos_integrations/providers/jira/provider.py b/craftos_integrations/providers/jira/provider.py new file mode 100644 index 00000000..c01854a9 --- /dev/null +++ b/craftos_integrations/providers/jira/provider.py @@ -0,0 +1,228 @@ +"""Jira provider — auth-layer bridge over the legacy ``JiraClient``. + +Bridge port: the legacy Jira actions keep calling the legacy client's API +surface, and only account routing moves to the integration system. So +``operations()`` is empty and ``guidance()`` is "" — this provider exists +for identity, credential storage, token verification, and the listener. + +Jira API tokens are Basic-auth (email:token) and never expire, so there +is no refresh path (``refresh()`` returns None) and no OAuth flow +(``oauth_spec`` raises NotImplementedError — the explicit token-only +declaration the conformance suite recognizes). + +One account = one (user, site) pair: the same person on two Jira sites is +two accounts, so identity is ``@``. +""" + +from __future__ import annotations + +import base64 +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +import httpx + +from ...contracts import OAuthSpec, Operation +from ...integrations.jira import JiraClient, JiraCredential +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(JiraCredential)} + + +def _clean_domain(raw: str) -> str: + """Mirror the legacy JiraHandler.login() domain normalization: + strip scheme + trailing slash, and default bare names to + ``.atlassian.net``.""" + domain = (raw or "").strip().rstrip("/") + if domain.startswith("https://"): + domain = domain[len("https://") :] + if domain.startswith("http://"): + domain = domain[len("http://") :] + domain = domain.split("/", 1)[0] + if domain and "." not in domain: + domain = f"{domain}.atlassian.net" + return domain + + +class JiraClientBinding: + """Overrides JiraClient's disk plumbing: credential is injected per + account, never read from ``spec.cred_file`` (single-account, would + cross-wire secondaries). MRO puts this before the legacy client: + + class BoundJiraClient(JiraClientBinding, JiraClient): pass + + No token refresh — Jira API tokens are non-expiring, so ``_persist`` + is never called (kept so the build_client contract is uniform across + providers). + """ + + _cred: Optional[JiraCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = JiraCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> JiraCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundJiraClient(JiraClientBinding, JiraClient): + """JiraClient with per-account credential binding (see JiraClientBinding).""" + + +class JiraProvider: + id = "jira" + display_name = "Jira" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundJiraClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """``@``, lowercased. + + Both halves are required: the same person on two Jira sites is two + accounts, and two people on one site are two accounts. The host + comes from ``domain`` (Basic-auth shape) or ``site_url`` (OAuth + shape), scheme stripped. None when either half is missing — the + core stores such credentials under LEGACY_IDENTITY. + """ + if not isinstance(credential, dict): + return None + user = None + for key in ("email", "account_id", "accountId"): + value = credential.get(key) + if isinstance(value, str) and value.strip(): + user = value.strip().lower() + break + if user is None: + return None + host = None + for key in ("domain", "site_url"): + value = credential.get(key) + if isinstance(value, str) and value.strip(): + host = _clean_domain(value).lower() + if host: + break + host = None + if host is None: + return None + return f"{user}@{host}" + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("jira is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # Jira API tokens are non-expiring + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy JiraHandler.login() runs: + normalize the domain, then Basic-auth ``GET /rest/api/3/myself`` + (falling back to v2); same credential keys as the handler's + ``fields`` (domain, email, api_token). The verified user's + ``account_id`` is captured alongside — identity already comes + from email+domain, but the account id is the API-stable user key. + """ + clean_domain = _clean_domain(credentials.get("domain") or "") + email = (credentials.get("email") or "").strip() + api_token = (credentials.get("api_token") or "").strip() + if not clean_domain or not email or not api_token: + return ( + False, + "Jira needs a domain (e.g. mycompany.atlassian.net), your " + "account email, and an API token from " + "https://id.atlassian.com/manage-profile/security/api-tokens", + None, + ) + + raw_auth = base64.b64encode(f"{email}:{api_token}".encode()).decode() + auth_headers = { + "Authorization": f"Basic {raw_auth}", + "Accept": "application/json", + } + + data = None + last_status = 0 + for api_ver in ("3", "2"): + url = f"https://{clean_domain}/rest/api/{api_ver}/myself" + try: + r = httpx.get( + url, headers=auth_headers, timeout=15, follow_redirects=True + ) + except httpx.ConnectError: + return ( + False, + f"Cannot connect to https://{clean_domain} - check the domain name.", + None, + ) + except Exception as e: + return False, f"Jira connection error: {e}", None + if r.status_code == 200: + data = r.json() + break + last_status = r.status_code + + if data is None: + hints = [f"Tried: https://{clean_domain}/rest/api/3/myself"] + if last_status == 401: + hints.append( + "Ensure you are using an API token, not your account password." + ) + hints.append( + "The email must match your Atlassian account email exactly." + ) + elif last_status == 403: + hints.append( + "Your account may not have REST API access. Check Jira permissions." + ) + elif last_status == 404: + hints.append( + f"Domain '{clean_domain}' not reachable or has no REST API." + ) + hint_str = "\n".join(f" - {h}" for h in hints) + return False, f"Jira auth failed (HTTP {last_status}).\n{hint_str}", None + + credential = asdict( + JiraCredential(domain=clean_domain, email=email, api_token=api_token) + ) + account_id = data.get("accountId") + if isinstance(account_id, str) and account_id.strip(): + credential["account_id"] = account_id.strip() + display_name = data.get("displayName", email) + return True, f"Jira connected as {display_name} ({clean_domain})", credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy jira actions keep the surface + + def guidance(self) -> str: + return "" # bridge provider — no v2 operations to guide + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> Optional[LegacyListenerAdapter]: + """Issue-update poll loop re-used verbatim from the legacy client + (``supports_listening`` is True); no cursor — the loop keeps its + watermark in memory and catches up on start.""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/lark/__init__.py b/craftos_integrations/providers/lark/__init__.py new file mode 100644 index 00000000..c1ee13d7 --- /dev/null +++ b/craftos_integrations/providers/lark/__init__.py @@ -0,0 +1,3 @@ +from .provider import LarkProvider + +__all__ = ["LarkProvider"] diff --git a/craftos_integrations/providers/lark/provider.py b/craftos_integrations/providers/lark/provider.py new file mode 100644 index 00000000..7b64c48c --- /dev/null +++ b/craftos_integrations/providers/lark/provider.py @@ -0,0 +1,62 @@ +"""Lark (messaging) bridge provider — auth-layer port of ``LarkClient``. + +Family member of ``_lark.LarkProviderBase`` (family="lark"): shares one +Custom App account (app_id identity) with lark_calendar / lark_drive. + +Listener: the legacy client's lark-oapi persistent-connection WebSocket +loop (``supports_listening = True``) is reused verbatim via +``LegacyListenerAdapter`` — the WS authenticates with app_id/app_secret +from the bound credential, so no extra plumbing is needed. + +verify_token adds the legacy ``LarkHandler.login()`` extra: a best-effort +``GET /bot/v3/info`` to capture ``bot_name``/``bot_open_id`` (the latter +is what the dispatch loop uses to drop the bot's own echoed messages). +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Tuple + +from ...helpers import request as http_request +from ...integrations._lark_common import LARK_API_BASE +from ...integrations.lark import LarkClient +from .._lark import LarkClientBinding, LarkProviderBase + + +class BoundLarkClient(LarkClientBinding, LarkClient): + """LarkClient with per-account credential binding (see LarkClientBinding).""" + + +class LarkProvider(LarkProviderBase): + id = "lark" + display_name = "Lark" + client_cls = BoundLarkClient + has_listener = True # lark-oapi WebSocket loop on the messaging client + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Family-base mint + the messaging-only bot-info fetch, mirroring + the legacy handler: falls back gracefully if the bot capability + isn't enabled yet on the app.""" + ok, msg, credential = super().verify_token(credentials) + if not ok or credential is None: + return ok, msg, credential + + bot_name = "" + bot_open_id = "" + info = http_request( + "GET", + f"{LARK_API_BASE}/bot/v3/info", + headers={"Authorization": f"Bearer {credential['tenant_access_token']}"}, + expected=(200,), + ) + if "error" not in info: + bot = info.get("result", {}).get("bot", {}) + bot_name = bot.get("app_name", "") + bot_open_id = bot.get("open_id", "") + credential["bot_name"] = bot_name + credential["bot_open_id"] = bot_open_id + + label = bot_name or credential["app_id"] + return True, f"Lark connected: {label}", credential diff --git a/craftos_integrations/providers/lark_calendar/__init__.py b/craftos_integrations/providers/lark_calendar/__init__.py new file mode 100644 index 00000000..bec0eb9b --- /dev/null +++ b/craftos_integrations/providers/lark_calendar/__init__.py @@ -0,0 +1,3 @@ +from .provider import LarkCalendarProvider + +__all__ = ["LarkCalendarProvider"] diff --git a/craftos_integrations/providers/lark_calendar/provider.py b/craftos_integrations/providers/lark_calendar/provider.py new file mode 100644 index 00000000..0ccf3e16 --- /dev/null +++ b/craftos_integrations/providers/lark_calendar/provider.py @@ -0,0 +1,25 @@ +"""Lark Calendar bridge provider — auth-layer port of ``LarkCalendarClient``. + +Family member of ``_lark.LarkProviderBase`` (family="lark"): shares one +Custom App account (app_id identity) with lark / lark_drive. Everything — +identity, token-only oauth_spec, verify_token (mint tenant_access_token +from app_id + app_secret, the handler's exact fields), binding-routed +token refresh — comes from the family base. Calendar has no inbound +events (``supports_listening = False``), so ``make_listener`` resolves +to None via the base's dynamic check. +""" + +from __future__ import annotations + +from ...integrations.lark_calendar import LarkCalendarClient +from .._lark import LarkClientBinding, LarkProviderBase + + +class BoundLarkCalendarClient(LarkClientBinding, LarkCalendarClient): + """LarkCalendarClient with per-account credential binding.""" + + +class LarkCalendarProvider(LarkProviderBase): + id = "lark_calendar" + display_name = "Lark Calendar" + client_cls = BoundLarkCalendarClient diff --git a/craftos_integrations/providers/lark_drive/__init__.py b/craftos_integrations/providers/lark_drive/__init__.py new file mode 100644 index 00000000..a0671dd3 --- /dev/null +++ b/craftos_integrations/providers/lark_drive/__init__.py @@ -0,0 +1,3 @@ +from .provider import LarkDriveProvider + +__all__ = ["LarkDriveProvider"] diff --git a/craftos_integrations/providers/lark_drive/provider.py b/craftos_integrations/providers/lark_drive/provider.py new file mode 100644 index 00000000..5e31ca72 --- /dev/null +++ b/craftos_integrations/providers/lark_drive/provider.py @@ -0,0 +1,30 @@ +"""Lark Drive bridge provider — auth-layer port of ``LarkDriveClient``. + +Family member of ``_lark.LarkProviderBase`` (family="lark"): shares one +Custom App account (app_id identity) with lark / lark_calendar. + +Note on the drive client's direct ``ensure_token(self._load(), ...)`` +call sites (upload/download paths that need a bare bearer token without +the JSON content-type): the binding's ``_load()`` pre-refreshes the bound +credential through ``persist`` with a margin wider than the legacy 60s +check, so those legacy ``ensure_token`` calls always cache-hit and never +write ``lark_drive.json`` (see ``_lark._REFRESH_MARGIN``). + +Drive has no inbound events (``supports_listening = False``), so +``make_listener`` resolves to None via the base's dynamic check. +""" + +from __future__ import annotations + +from ...integrations.lark_drive import LarkDriveClient +from .._lark import LarkClientBinding, LarkProviderBase + + +class BoundLarkDriveClient(LarkClientBinding, LarkDriveClient): + """LarkDriveClient with per-account credential binding.""" + + +class LarkDriveProvider(LarkProviderBase): + id = "lark_drive" + display_name = "Lark Drive" + client_cls = BoundLarkDriveClient diff --git a/craftos_integrations/providers/line/__init__.py b/craftos_integrations/providers/line/__init__.py new file mode 100644 index 00000000..fa0018b5 --- /dev/null +++ b/craftos_integrations/providers/line/__init__.py @@ -0,0 +1,5 @@ +"""LINE provider package (auth-layer bridge — see provider.py).""" + +from .provider import LineProvider + +__all__ = ["LineProvider"] diff --git a/craftos_integrations/providers/line/provider.py b/craftos_integrations/providers/line/provider.py new file mode 100644 index 00000000..750c3b27 --- /dev/null +++ b/craftos_integrations/providers/line/provider.py @@ -0,0 +1,151 @@ +"""LINE provider — an auth-layer bridge port. + +Bridge pattern (see slack/provider.py for the full binding rationale): +the battle-tested legacy ``LineClient`` API surface is reused unchanged, +with only its credential plumbing overridden by a small binding mixin — +the credential is injected per account by ``build_client`` and never read +from ``spec.cred_file`` (which is single-account and would cross-wire +secondaries). Operations and guidance stay with the legacy action layer +(``operations()`` returns ``[]``); only account routing is centralized. + +LINE is token-only: credentials come from the LINE Developers console +(channel access token + channel secret), so ``oauth_spec()`` raises +NotImplementedError and connect goes through ``verify_token`` — the same +``GET /v2/bot/info`` check the legacy ``LineHandler.login()`` runs, which +also captures the bot's ``userId`` as the stable account identity. + +Long-lived channel access tokens do not expire on a refresh schedule, so +``refresh()`` returns None. LINE delivers inbound messages via webhooks +only (no long-poll; ``LineClient.supports_listening`` is False), so +``make_listener`` returns None — no inbound events from a desktop agent. +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.line import LINE_API_BASE, LineClient, LineCredential + +_CRED_FIELDS = {f.name for f in fields(LineCredential)} + + +class LineClientBinding: + """Overrides LineClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundLineClient(LineClientBinding, LineClient): pass + + No token refresh — long-lived channel access tokens don't rotate, so + ``_persist`` is never called (kept so the build_client contract is + uniform across providers). + """ + + _cred: Optional[LineCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = LineCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> LineCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundLineClient(LineClientBinding, LineClient): + """LineClient with per-account credential binding (see LineClientBinding).""" + + +class LineProvider: + id = "line" + display_name = "LINE" + family = None # standalone — no cross-provider alias sharing + client_cls = BoundLineClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """The bot's LINE user id (captured at verify time), lowercased. + None for credentials saved before identity capture existed.""" + bot_user_id = credential.get("bot_user_id") + if isinstance(bot_user_id, str) and bot_user_id.strip(): + return bot_user_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("line is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # long-lived channel access tokens are non-expiring + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy ``LineHandler.login()`` runs: + ``GET /v2/bot/info`` with the channel access token; same credential + dict shape, with the bot's ``userId`` captured as ``bot_user_id`` + so ``identity_of`` gets a stable account key. + + Input keys mirror the handler's ``fields``: ``channel_access_token`` + (required) and ``channel_secret`` (optional — webhook signature + verification only, not needed for send). + """ + token = (credentials.get("channel_access_token") or "").strip() + secret = (credentials.get("channel_secret") or "").strip() + if not token: + return False, "Channel access token is required.", None + + result = http_request( + "GET", + f"{LINE_API_BASE}/info", + headers={"Authorization": f"Bearer {token}"}, + expected=(200,), + ) + if "error" in result: + return False, f"Invalid channel access token: {result['error']}", None + info = result.get("result") or {} + + credential = asdict( + LineCredential( + channel_access_token=token, + channel_secret=secret, + bot_user_id=info.get("userId", ""), + bot_display_name=info.get("displayName", ""), + ) + ) + label = info.get("displayName") or info.get("userId") or "bot" + return True, f"LINE connected: {label}", credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy actions remain the operation surface + + def guidance(self) -> str: + return "" # bridge provider — legacy action docs remain the guidance + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> None: + """LINE is webhook-push only — the legacy client has no listen loop + (``supports_listening`` is False), so there are no inbound events.""" + return None diff --git a/craftos_integrations/providers/stripe/__init__.py b/craftos_integrations/providers/stripe/__init__.py new file mode 100644 index 00000000..8b3f858a --- /dev/null +++ b/craftos_integrations/providers/stripe/__init__.py @@ -0,0 +1,3 @@ +from .provider import StripeProvider + +__all__ = ["StripeProvider"] diff --git a/craftos_integrations/providers/stripe/provider.py b/craftos_integrations/providers/stripe/provider.py new file mode 100644 index 00000000..8074dfc5 --- /dev/null +++ b/craftos_integrations/providers/stripe/provider.py @@ -0,0 +1,208 @@ +"""Stripe provider — auth-layer bridge over the legacy ``StripeClient``. + +Bridge port: the v2 provider handles accounts/credentials only — +``operations()`` returns [] and ``guidance()`` returns "" because the +legacy Stripe action surface stays in place; account routing happens +centrally. The binding mixin below replaces the legacy client's disk +credential plumbing with the injected per-account credential, exactly +like ``SlackClientBinding``. + +Stripe is token-only (a Restricted/Secret API key per merchant account — +no OAuth; see the legacy module's rationale for skipping Stripe Connect), +so ``oauth_spec()`` raises NotImplementedError and there is no +``run_login``. Keys never expire → ``refresh()`` returns None. + +One account = one Stripe merchant account; identity is the ``acct_...`` +id captured from ``GET /v1/account`` at verify time (lowercased). +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.stripe import ( + STRIPE_API, + STRIPE_API_VERSION, + StripeClient, + StripeCredential, + _classify_key, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(StripeCredential)} + + +class StripeClientBinding: + """Overrides StripeClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundStripeClient(StripeClientBinding, StripeClient): pass + + No token refresh — Stripe API keys are non-expiring, so ``_persist`` + is never called (kept so the build_client contract is uniform across + providers). + """ + + _cred: Optional[StripeCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = StripeCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> StripeCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundStripeClient(StripeClientBinding, StripeClient): + """StripeClient with per-account credential binding (see StripeClientBinding).""" + + +class StripeProvider: + id = "stripe" + family = None # standalone — no cross-provider alias sharing + display_name = "Stripe" + client_cls = BoundStripeClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Stripe account id (``acct_...``), lowercased. None for + restricted-key credentials whose scope couldn't read /v1/account + (stored without an account id) and for pre-bridge junk shapes.""" + try: + account_id = credential.get("account_id") + except AttributeError: + return None + if isinstance(account_id, str) and account_id.strip(): + return account_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + # Deliberate: no Stripe Connect OAuth (see the legacy module's + # platform-risk rationale). Each user brings their own API key. + raise NotImplementedError("stripe is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # Stripe API keys are non-expiring + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy StripeHandler.login() runs: prefix + check + ``GET /v1/account`` with the key (falling back to + ``GET /v1/balance`` for restricted keys that can't read the + account). Expects the legacy handler's field key: ``api_key``. + + Returns (ok, message, credential). The credential is the asdict + of ``StripeCredential`` — which carries ``account_id`` (the + ``acct_...`` id from /v1/account) so ``identity_of`` works. + """ + token = (credentials.get("api_key") or "").strip() + if not token: + return False, "Missing Stripe API key (api_key).", None + if token.startswith("pk_"): + return ( + False, + "That's a publishable key (pk_…). Publishable keys are for " + "client-side code and won't authenticate server-side requests. " + "Paste a secret (sk_…) or restricted (rk_…) key instead.", + None, + ) + if not (token.startswith("sk_") or token.startswith("rk_")): + return ( + False, + "Invalid Stripe key. Expected sk_live_…, sk_test_…, rk_live_…, " + "or rk_test_….", + None, + ) + + livemode, kind = _classify_key(token) + headers = { + "Authorization": f"Bearer {token}", + "Stripe-Version": STRIPE_API_VERSION, + } + account_id = "" + business_name = "" + + acct = http_request( + "GET", + f"{STRIPE_API}/account", + headers=headers, + expected=(200,), + ) + if "error" not in acct: + data = acct.get("result") or {} + account_id = data.get("id") or "" + business_name = ( + data.get("business_profile", {}).get("name") + or data.get("settings", {}).get("dashboard", {}).get("display_name") + or data.get("email") + or "" + ) + else: + # Restricted keys may lack the 'account read' scope; every + # authenticated key can reach /v1/balance. + balance = http_request( + "GET", + f"{STRIPE_API}/balance", + headers=headers, + expected=(200,), + ) + if "error" in balance: + return False, f"Stripe auth failed: {balance['error']}", None + # /balance succeeded — key is valid but has no account_id + # (identity_of returns None; core stores as legacy account). + + credential = asdict( + StripeCredential( + api_key=token, + account_id=account_id, + business_name=business_name, + livemode=livemode, + key_kind=kind, + ) + ) + label = business_name or account_id or "Stripe account" + mode = "live mode" if livemode else "TEST MODE" + kind_label = "restricted key" if kind == "restricted" else "secret key" + return True, f"Stripe connected: {label} ({mode}, {kind_label})", credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy Stripe actions stay in place + + def guidance(self) -> str: + return "" # bridge provider — the legacy action surface has its own docs + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> Optional[LegacyListenerAdapter]: + """Stripe's legacy client is request-response only + (``supports_listening`` is the BasePlatformClient default False), + so there is nothing to listen to — checked dynamically so a future + legacy listen loop gets bridged automatically.""" + if getattr(client, "supports_listening", False): + return LegacyListenerAdapter(client, emit) + return None diff --git a/craftos_integrations/providers/telegram_bot/__init__.py b/craftos_integrations/providers/telegram_bot/__init__.py new file mode 100644 index 00000000..a9931b8b --- /dev/null +++ b/craftos_integrations/providers/telegram_bot/__init__.py @@ -0,0 +1,3 @@ +from .provider import TelegramBotProvider + +__all__ = ["TelegramBotProvider"] diff --git a/craftos_integrations/providers/telegram_bot/provider.py b/craftos_integrations/providers/telegram_bot/provider.py new file mode 100644 index 00000000..08901d93 --- /dev/null +++ b/craftos_integrations/providers/telegram_bot/provider.py @@ -0,0 +1,192 @@ +"""Telegram Bot bridge provider — auth-layer-only port of the legacy client. + +Bridge pattern (see slack/provider.py for the full binding rationale): +the battle-tested legacy ``TelegramBotClient`` keeps its entire API +surface; only the credential plumbing is overridden by a small binding +mixin so the credential is injected per account and never read from the +legacy ``telegram_bot.json``. ``operations()`` is empty and +``guidance()`` blank — the legacy action functions remain the tool +surface; account routing happens centrally in the host adapter. + +Telegram bots are token-only (a BotFather token per bot): +``oauth_spec()`` raises NotImplementedError (the conformance suite's +explicit token-only declaration) and there is no ``run_login``. Bot +tokens do not rotate, so ``refresh()`` returns None. + +One account = one **bot**; identity is the bot's numeric id from +``getMe`` (Telegram's stable identifier — the username can be changed +via BotFather, the id cannot). The legacy ``TelegramBotCredential`` has +no id field, so ``verify_token`` stores it under a new ``bot_id`` key +alongside the legacy fields; the binding filters it out before +constructing the legacy dataclass, so the legacy client never sees it. + +Two legacy disk touchpoints the binding must neutralize: + +* ``has_credentials`` — reads ``telegram_bot.json`` and, worse, + auto-SAVES shared-bot credentials from ConfigStore env as a side + effect. The binding's override answers purely from the injected + credential, so that write never fires for bound clients. +* ``_load`` — falls back to ``load_credential`` from disk when + ``_cred`` is None. The binding raises instead. + +Listener state is safely per-instance: ``_poll_offset``, ``_bot_info``, +``_catchup_done``, ``_poll_task``, and ``_listening`` all live on the +client instance — no module-level offset or singleton session, so two +concurrently listening bot accounts never fight. The one shared bit is +the module-level *config* file (``telegram_bot_config.json``, the +``self_messages_only`` knob) read inside ``_process_update`` — a global +read-only preference applied to every bot account alike, not offset +state, so it is left as-is. +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...integrations.telegram_bot import ( + TELEGRAM_API_BASE, + TelegramBotClient, + TelegramBotCredential, + _telegram_call_sync, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(TelegramBotCredential)} + + +class TelegramBotClientBinding: + """Overrides TelegramBotClient's disk plumbing: credential is + injected per account. MRO puts this before the legacy client: + + class BoundTelegramBotClient(TelegramBotClientBinding, TelegramBotClient): pass + + No token refresh — bot tokens are non-rotating — so ``_persist`` is + never called (kept so the build_client contract is uniform across + providers). ``has_credentials`` MUST be overridden here: the legacy + version reads the credential file and auto-saves shared-bot env + credentials to disk as a side effect. + """ + + _cred: Optional[TelegramBotCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + # Filters to legacy dataclass fields — drops the provider-level + # ``bot_id`` identity key the legacy client doesn't know about. + self._cred = TelegramBotCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> TelegramBotCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundTelegramBotClient(TelegramBotClientBinding, TelegramBotClient): + """TelegramBotClient with per-account credential binding (see TelegramBotClientBinding).""" + + +class TelegramBotProvider: + id = "telegram_bot" + family = None # standalone — no cross-provider alias sharing + display_name = "Telegram Bot" + client_cls = BoundTelegramBotClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """The bot's numeric id (``bot_id``, captured from getMe at + verify time), as a string. None for pre-bridge credentials saved + without it (legacy ``telegram_bot.json`` has only token + + username) and for junk shapes. Tolerates an int-typed id from a + hand-edited or json-roundtripped credential.""" + try: + bot_id = credential.get("bot_id") + except AttributeError: + return None + if isinstance(bot_id, bool): # bool is an int subclass — junk here + return None + if isinstance(bot_id, int): + return str(bot_id) + if isinstance(bot_id, str) and bot_id.strip(): + return bot_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("telegram_bot is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # BotFather tokens do not rotate + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy TelegramBotHandler.login() runs: + ``GET /bot/getMe``; same ``fields`` key (``bot_token``). + The bot's numeric ``id`` is stored as ``bot_id`` (plus the + username as ``bot_username``) so ``identity_of`` resolves the + account immediately. + """ + token = (credentials.get("bot_token") or "").strip() + if not token: + return ( + False, + "A Telegram bot token is required. " + "Get one from @BotFather on Telegram.", + None, + ) + + data = _telegram_call_sync(f"{TELEGRAM_API_BASE}/bot{token}/getMe") + if "error" in data: + return False, f"Invalid bot token: {data['error']}", None + info = data.get("result") or {} + + credential = asdict( + TelegramBotCredential( + bot_token=token, + bot_username=info.get("username", ""), + ) + ) + bot_id = info.get("id") + credential["bot_id"] = str(bot_id) if bot_id is not None else "" + return ( + True, + f"Telegram bot connected: @{info.get('username')} ({bot_id})", + credential, + ) + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy action functions stay the surface + + def guidance(self) -> str: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """Long-poll listener — the legacy client's own ``getUpdates`` + loop (30s long poll with per-instance ``_poll_offset`` watermark + and a catch-up drain on start), reused verbatim via the generic + adapter. The offset lives on the bound client instance, so each + account's listener keeps its own watermark. No restart-safe + cursor, same as under the legacy manager.""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/telegram_user/__init__.py b/craftos_integrations/providers/telegram_user/__init__.py new file mode 100644 index 00000000..2c90dc49 --- /dev/null +++ b/craftos_integrations/providers/telegram_user/__init__.py @@ -0,0 +1,3 @@ +from .provider import TelegramUserProvider + +__all__ = ["TelegramUserProvider"] diff --git a/craftos_integrations/providers/telegram_user/provider.py b/craftos_integrations/providers/telegram_user/provider.py new file mode 100644 index 00000000..66cc9fb5 --- /dev/null +++ b/craftos_integrations/providers/telegram_user/provider.py @@ -0,0 +1,323 @@ +"""Telegram User (MTProto) bridge provider — auth-layer-only port of the +legacy client. + +Bridge pattern (see telegram_bot/provider.py for the binding rationale): +the battle-tested legacy ``TelegramUserClient`` keeps its entire API +surface; only the credential plumbing is overridden by a small binding +mixin so the credential is injected per account and never read from the +legacy ``telegram_user.json``. ``operations()`` is empty and +``guidance()`` blank — the legacy action functions remain the tool +surface; account routing happens centrally in the host adapter. + +Auth is Telegram's phone-login (no OAuth): ``oauth_spec()`` raises +NotImplementedError and there is no ``run_login``. The handler's UI +``fields`` (phone_number / code / password) drive a **two-phase** +``verify_token``: + +* Phase 1 — phone only, no code: send the login code via the same + ``start_auth`` helper the CLI ``/telegram_user login`` step 1 uses, + park ``phone_code_hash`` + the partial session in the SAME module-level + ``_pending_telegram_auth`` dict the CLI flow uses (one pending flow per + phone, shared deliberately so either surface can finish what the other + started), and return ``(False, "code sent — submit again…", None)``. + ``system_connect_token`` surfaces a False message to the connect UI, + which is how this phase talks to the user. +* Phase 2 — phone + code (+ optional 2FA password): complete auth via + ``complete_auth`` exactly like CLI step 2, build the credential dict + (legacy dataclass fields + the provider-level ``telegram_user_id``), + clear the pending entry, return (True, message, credential). Error + branches mirror the CLI mapping: invalid code keeps the pending entry + (retry with a corrected code), expired code clears it, 2FA-needed keeps + it and asks for the password field. + +The entire session state is the Telethon ``StringSession`` string inside +the credential — no session files on disk — so ``refresh()`` returns +None (sessions don't expire on a timer; a revoked session surfaces as +``session_expired`` from the legacy client and needs a re-login). + +One account = one **phone number**. ``identity_of`` normalizes the phone +to digits only with leading zeros stripped: ``+92 300 1234567``, +``923001234567`` and ``0092-300-1234567`` all collapse to +``923001234567`` (Telegram logins use international format, so the +digits are country code + subscriber number; stripping leading zeros +removes the ``00`` international-prefix ambiguity). When the phone is +missing (e.g. a QR-login credential), the stored ``telegram_user_id`` +is the fallback identity. + +Legacy disk touchpoints the binding neutralizes: ``has_credentials`` +(reads ``telegram_user.json``) and ``_load`` (falls back to +``load_credential`` from disk) — both answer purely from the injected +credential. Everything else is already per-instance: ``_live_client``, +``_live_loop``, ``_send_queue``, ``_my_user_id`` and ``_agent_sent_ids`` +all live on the client, and each listener builds its own Telethon +``TelegramClient`` from its own ``StringSession`` — no module-level +Telethon state, so two concurrently listening accounts never collide. +The one shared bit is the module-level *config* file +(``telegram_user_config.json``, the ``self_messages_only`` knob) read +inside ``_handle_event`` — a global read-only preference applied to +every account alike, left as-is (same call as telegram_bot). +""" + +from __future__ import annotations + +import asyncio +import re +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Tuple + +from ...config import ConfigStore +from ...contracts import OAuthSpec, Operation +from ...integrations.telegram_user import ( + TelegramUserClient, + TelegramUserCredential, + _pending_telegram_auth, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(TelegramUserCredential)} + +_NON_DIGITS = re.compile(r"\D+") + + +def _run_coro(coro: Coroutine[Any, Any, Any]) -> Any: + """Run an async auth helper from the sync ``verify_token`` contract. + + ``system_connect_token`` calls verifiers synchronously (the browser + adapter already hops to a worker thread via ``asyncio.to_thread``), + so there is normally no running loop here and ``asyncio.run`` is + correct. If a caller ever invokes us on a loop thread, fall back to + a throwaway thread so we never deadlock the running loop. + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + return pool.submit(asyncio.run, coro).result() + + +class TelegramUserClientBinding: + """Overrides TelegramUserClient's disk plumbing: credential is + injected per account. MRO puts this before the legacy client: + + class BoundTelegramUserClient(TelegramUserClientBinding, TelegramUserClient): pass + + No refresh — the StringSession doesn't rotate — so ``_persist`` is + never called (kept so the build_client contract is uniform). + """ + + _cred: Optional[TelegramUserCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + # Filters to legacy dataclass fields — drops the provider-level + # ``telegram_user_id`` identity key the legacy client doesn't + # know about. + self._cred = TelegramUserCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> TelegramUserCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundTelegramUserClient(TelegramUserClientBinding, TelegramUserClient): + """TelegramUserClient with per-account credential binding (see TelegramUserClientBinding).""" + + +class TelegramUserProvider: + id = "telegram_user" + family = None # standalone — no cross-provider alias sharing + display_name = "Telegram (User)" + client_cls = BoundTelegramUserClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Normalized phone number: digits only, leading zeros stripped + (collapses ``+92…`` / ``0092…`` / spacing-and-dash variants of + the same international number to one key). Falls back to the + stored ``telegram_user_id`` for phone-less credentials (QR + logins). None for junk shapes — never raises.""" + try: + phone = credential.get("phone_number") + except AttributeError: + return None + if isinstance(phone, str): + digits = _NON_DIGITS.sub("", phone).lstrip("0") + if digits: + return digits + user_id = credential.get("telegram_user_id") + if isinstance(user_id, bool): # bool is an int subclass — junk here + return None + if isinstance(user_id, int): + return str(user_id) + if isinstance(user_id, str) and user_id.strip(): + return user_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("telegram_user uses phone login") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # StringSessions don't expire on a timer + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Two-phase phone login over the handler's UI fields + (``phone_number`` / ``code`` / ``password``) — same machinery + and pending-state dict as the CLI ``_login_phone`` flow.""" + phone = (credentials.get("phone_number") or "").strip() + if not phone: + return ( + False, + "A phone number is required (international format, " + "e.g. +923001234567).", + None, + ) + + api_id_str = ConfigStore.get_oauth("TELEGRAM_API_ID") + api_hash = ConfigStore.get_oauth("TELEGRAM_API_HASH") + if not api_id_str or not api_hash: + return ( + False, + "Not configured. Set TELEGRAM_API_ID and TELEGRAM_API_HASH.\n" + "Get them from https://my.telegram.org → API development tools.", + None, + ) + try: + api_id = int(api_id_str) + except ValueError: + return False, "TELEGRAM_API_ID must be a number.", None + + from ...integrations.telegram_user import _telegram_mtproto as helpers + + code = (credentials.get("code") or "").strip() + + # ── Phase 1 — phone only: send the login code ──────────────── + if not code: + result = _run_coro( + helpers.start_auth(api_id=api_id, api_hash=api_hash, phone_number=phone) + ) + if "error" in result: + return False, f"Failed to send code: {result['error']}", None + _pending_telegram_auth[phone] = { + "phone_code_hash": result["result"]["phone_code_hash"], + "session_string": result["result"]["session_string"], + } + return ( + False, + f"Verification code sent to {phone} — check your Telegram " + "app, then submit again with the code filled in.", + None, + ) + + # ── Phase 2 — phone + code (+ optional 2FA password) ───────── + pending = _pending_telegram_auth.get(phone) + if not pending: + return ( + False, + f"No pending login for {phone}. Submit again with the code " + "field empty to request a new code.", + None, + ) + + password = (credentials.get("password") or "").strip() or None + result = _run_coro( + helpers.complete_auth( + api_id=api_id, + api_hash=api_hash, + phone_number=phone, + code=code, + phone_code_hash=pending["phone_code_hash"], + password=password, + pending_session_string=pending["session_string"], + ) + ) + + if "error" in result: + details = result.get("details", {}) + # Same branch → message mapping as the CLI flow; pending + # state is kept for retries, cleared only where the CLI + # clears it (expiry — the code_hash is dead). + if details.get("status") == "2fa_required": + return ( + False, + "2FA enabled. Submit again with the code and your " + "2FA password filled in.", + None, + ) + if details.get("status") == "invalid_code": + return False, "Invalid verification code. Try again.", None + if details.get("status") == "code_expired": + _pending_telegram_auth.pop(phone, None) + return ( + False, + "Code expired. Submit again with the code field empty " + "to request a new one.", + None, + ) + return False, f"Auth failed: {result['error']}", None + + auth = result["result"] + _pending_telegram_auth.pop(phone, None) + + credential = asdict( + TelegramUserCredential( + session_string=auth["session_string"], + api_id=str(api_id), + api_hash=api_hash, + phone_number=auth.get("phone") or phone, + ) + ) + # Provider-level identity fallback — filtered out by the binding + # before the legacy dataclass is constructed. + user_id = auth.get("user_id") + credential["telegram_user_id"] = str(user_id) if user_id is not None else "" + + account_name = ( + f"{auth.get('first_name', '')} {auth.get('last_name', '')}".strip() + ) + username = f" (@{auth['username']})" if auth.get("username") else "" + return True, f"Telegram user connected: {account_name}{username}", credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy action functions stay the surface + + def guidance(self) -> str: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """The legacy client's own Telethon event listener + (``events.NewMessage`` + ``catch_up`` on start), reused verbatim + via the generic adapter. Each bound client builds its own + ``TelegramClient`` from its own ``StringSession``, and all + listener state (_live_client, _send_queue, _my_user_id, + _agent_sent_ids) is instance-level — per-account listeners are + fully independent. No restart-safe cursor, same as under the + legacy manager (Telethon's catch_up covers the gap).""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/twitter/__init__.py b/craftos_integrations/providers/twitter/__init__.py new file mode 100644 index 00000000..cc354331 --- /dev/null +++ b/craftos_integrations/providers/twitter/__init__.py @@ -0,0 +1,5 @@ +"""Twitter/X bridge provider package.""" + +from .provider import TwitterProvider + +__all__ = ["TwitterProvider"] diff --git a/craftos_integrations/providers/twitter/provider.py b/craftos_integrations/providers/twitter/provider.py new file mode 100644 index 00000000..7deccd93 --- /dev/null +++ b/craftos_integrations/providers/twitter/provider.py @@ -0,0 +1,242 @@ +"""Twitter/X bridge provider — auth-layer-only port of the legacy client. + +Bridge pattern (see slack/provider.py for the full binding rationale): +the battle-tested legacy ``TwitterClient`` keeps its entire API surface; +only the credential plumbing is overridden by a small binding mixin so +the credential is injected per account and never read from the legacy +``twitter.json``. ``operations()`` is empty and ``guidance()`` blank — +the legacy action functions remain the tool surface; account routing +happens centrally in the host adapter. + +Twitter is token-only in this integration (OAuth 1.0a user context: +consumer key/secret + access token/secret pasted from the developer +portal — no browser OAuth dance), so ``oauth_spec()`` raises +NotImplementedError and there is no ``run_login``. OAuth 1.0a user +tokens do not expire → ``refresh()`` returns None. + +One account = one Twitter/X **user**; identity is the numeric user id +from ``GET /2/users/me`` (stable across handle renames), falling back to +the username for pre-bridge credentials saved without one. Lowercased. + +Per-instance state audit (two listening accounts): the legacy poll +watermarks ``_since_id``/``_seen_ids`` live on the client instance +(set in ``__init__``), so bound clients never fight over them. The only +shared state is the ``twitter_config.json`` watch-tag file — deliberate +shared *config* (every account filters mentions by the same tag), not +per-account listen state, so it is left alone. + +The one legacy disk write the binding must intercept: the client's +``start_listening`` backfills ``cred.user_id``/``cred.username`` from +``GET /2/users/me`` when they differ and saves the legacy credential +file (legacy module ~line 340). The binding pre-syncs both fields +through ``persist`` instead, so the legacy save never fires and the +update lands on the right account entry. +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.twitter import ( + TWITTER_API, + TwitterClient, + TwitterCredential, + _oauth1_header, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(TwitterCredential)} + +# Same field keys the legacy TwitterHandler.fields declares. +_REQUIRED_KEYS = ("api_key", "api_secret", "access_token", "access_token_secret") + + +class TwitterClientBinding: + """Overrides TwitterClient's disk plumbing: credential is injected per + account. MRO puts this before the legacy client: + + class BoundTwitterClient(TwitterClientBinding, TwitterClient): pass + + No token refresh — OAuth 1.0a user tokens are non-expiring — but + ``_persist`` IS used: the legacy ``start_listening`` backfills the + stored user_id/username from the API and would write ``twitter.json`` + (cross-wiring secondaries), so the binding routes that one update + through ``persist`` instead. + """ + + _cred: Optional[TwitterCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = TwitterCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> TwitterCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + async def start_listening(self, callback) -> None: + """Pre-sync user_id/username so the legacy save never fires. + + The legacy ``start_listening`` calls ``GET /2/users/me`` and, when + the stored ``user_id`` or ``username`` differs from the live + account, writes the credential to the legacy single-account file. + Doing the same check here first — persisting through + ``self._persist`` — leaves the legacy branch false, so its + ``save_credential`` is never reached. Costs one extra cheap + ``get_me`` at listener start; keeps the poll loop unforked. + """ + if not self._listening: + me = await self.get_me() + if "error" not in me: + data = me.get("result", {}) or {} + username = data.get("username", "") or "" + user_id = data.get("id", "") or "" + cred = self._load() + if (user_id and cred.user_id != user_id) or ( + username and cred.username != username + ): + cred.user_id = user_id or cred.user_id + cred.username = username or cred.username + self._persist(asdict(cred)) + await super().start_listening(callback) + + +class BoundTwitterClient(TwitterClientBinding, TwitterClient): + """TwitterClient with per-account credential binding (see TwitterClientBinding).""" + + +class TwitterProvider: + id = "twitter" + family = None # standalone — no cross-provider alias sharing + display_name = "Twitter/X" + client_cls = BoundTwitterClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Numeric user id from ``GET /2/users/me`` (stable across handle + renames), falling back to the username for older credentials + saved without one. Lowercased; None for pre-bridge junk shapes.""" + try: + user_id = credential.get("user_id") + username = credential.get("username") + except AttributeError: + return None + if isinstance(user_id, str) and user_id.strip(): + return user_id.strip().lower() + if isinstance(username, str) and username.strip(): + return username.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + # Deliberate: OAuth 1.0a keys are pasted from the developer portal + # (the legacy handler's token flow) — no browser OAuth dance. + raise NotImplementedError("twitter is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # OAuth 1.0a user tokens do not expire + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy TwitterHandler.login() runs: + ``GET /2/users/me`` signed with the legacy module's own OAuth 1.0a + helper; same ``fields`` keys (api_key, api_secret, access_token, + access_token_secret). The API's ``id``/``username`` are stored as + ``user_id``/``username`` so ``identity_of`` resolves immediately. + """ + values = {k: (credentials.get(k) or "").strip() for k in _REQUIRED_KEYS} + missing = [k for k in _REQUIRED_KEYS if not values[k]] + if missing: + return ( + False, + "Missing Twitter credentials: " + + ", ".join(missing) + + ". All four OAuth 1.0a values are required — get them from " + "developer.x.com → Dashboard → Keys and tokens.", + None, + ) + + url = f"{TWITTER_API}/users/me" + params = {"user.fields": "id,name,username"} + auth_hdr = _oauth1_header( + "GET", + url, + params, + values["api_key"], + values["api_secret"], + values["access_token"], + values["access_token_secret"], + ) + result = http_request( + "GET", + url, + headers={"Authorization": auth_hdr}, + params=params, + expected=(200,), + ) + if "error" in result: + return ( + False, + f"Twitter auth failed: {result['error']}. " + "Check your API credentials.\n" + "Get them from developer.x.com → Dashboard → Keys and tokens", + None, + ) + data = (result["result"] or {}).get("data", {}) + + credential = asdict( + TwitterCredential( + api_key=values["api_key"], + api_secret=values["api_secret"], + access_token=values["access_token"], + access_token_secret=values["access_token_secret"], + user_id=data.get("id", ""), + username=data.get("username", ""), + ) + ) + return ( + True, + f"Twitter/X connected as @{data.get('username')} ({data.get('name', '')})", + credential, + ) + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy action functions stay the surface + + def guidance(self) -> str: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """Mentions poll listener — the legacy client's own + ``start_listening`` loop (``GET /2/users/{id}/mentions`` every 30s + with since_id + in-memory seen-id dedup, optional watch-tag + filter), reused verbatim via the generic adapter. The watermarks + are instance attributes, so concurrent bound accounts don't + collide. No restart-safe cursor, same as under the legacy + manager.""" + return LegacyListenerAdapter(client, emit) diff --git a/craftos_integrations/providers/whatsapp_business/__init__.py b/craftos_integrations/providers/whatsapp_business/__init__.py new file mode 100644 index 00000000..5d36940c --- /dev/null +++ b/craftos_integrations/providers/whatsapp_business/__init__.py @@ -0,0 +1,3 @@ +from .provider import WhatsAppBusinessProvider + +__all__ = ["WhatsAppBusinessProvider"] diff --git a/craftos_integrations/providers/whatsapp_business/provider.py b/craftos_integrations/providers/whatsapp_business/provider.py new file mode 100644 index 00000000..dfef01bb --- /dev/null +++ b/craftos_integrations/providers/whatsapp_business/provider.py @@ -0,0 +1,193 @@ +"""WhatsApp Business provider — auth-layer bridge over the legacy +``WhatsAppBusinessClient``. + +Bridge port: the v2 provider handles accounts/credentials only — +``operations()`` returns [] and ``guidance()`` returns "" because the +legacy WhatsApp Business action surface stays in place; account routing +happens centrally. The binding mixin below replaces the legacy client's +disk credential plumbing with the injected per-account credential, +exactly like ``SlackClientBinding``/``StripeClientBinding``. + +WhatsApp Business is token-only (a Meta Graph API access token + phone +number id per WhatsApp Business number — the legacy handler's +``auth_type = "token"``), so ``oauth_spec()`` raises NotImplementedError +and there is no ``run_login``. The stored token is whatever the user +pasted (typically a long-lived System User token); the provider has no +refresh path → ``refresh()`` returns None. + +One account = one WhatsApp Business **phone number**; identity is the +``phone_number_id`` (lowercased — Graph ids are numeric strings, so this +is normalization symmetry with the other providers, not case folding). +""" + +from __future__ import annotations + +from dataclasses import asdict, fields +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from ...contracts import OAuthSpec, Operation +from ...helpers import request as http_request +from ...integrations.whatsapp_business import ( + GRAPH_API_BASE, + WhatsAppBusinessClient, + WhatsAppBusinessCredential, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(WhatsAppBusinessCredential)} + + +class WhatsAppBusinessClientBinding: + """Overrides WhatsAppBusinessClient's disk plumbing: credential is + injected per account. MRO puts this before the legacy client: + + class BoundWhatsAppBusinessClient( + WhatsAppBusinessClientBinding, WhatsAppBusinessClient + ): pass + + No token refresh — the provider stores the token the user pasted and + has no rotation path, so ``_persist`` is never called (kept so the + build_client contract is uniform across providers). + """ + + _cred: Optional[WhatsAppBusinessCredential] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + self._cred = WhatsAppBusinessCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + self._persist = persist + + def has_credentials(self) -> bool: + return self._cred is not None + + def _load(self) -> WhatsAppBusinessCredential: + if self._cred is None: + raise RuntimeError("client used before bind_credential()") + return self._cred + + +class BoundWhatsAppBusinessClient(WhatsAppBusinessClientBinding, WhatsAppBusinessClient): + """WhatsAppBusinessClient with per-account credential binding (see + WhatsAppBusinessClientBinding).""" + + +class WhatsAppBusinessProvider: + id = "whatsapp_business" + family = None # standalone — no cross-provider alias sharing + display_name = "WhatsApp Business" + client_cls = BoundWhatsAppBusinessClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Phone number id (each WhatsApp Business number is one account), + lowercased/stripped. None for junk shapes — never raises (this + runs during migration).""" + try: + phone_number_id = credential.get("phone_number_id") + except AttributeError: + return None + if isinstance(phone_number_id, str) and phone_number_id.strip(): + return phone_number_id.strip().lower() + return None + + def oauth_spec(self) -> OAuthSpec: + # Deliberate: no Meta Embedded Signup OAuth — the legacy handler is + # token-only; each user pastes their own Cloud API token + phone id. + raise NotImplementedError("whatsapp_business is token-only") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # pasted token; no provider-side refresh path + + def verify_token( + self, credentials: Dict[str, str] + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + """Same verification the legacy WhatsAppBusinessHandler.login() + runs: ``GET {GRAPH_API_BASE}/{phone_number_id}`` with the bearer + token. Expects the legacy handler's field keys: ``access_token`` + and ``phone_number_id``. + + phone_number_id is a UI field, so identity is present by + construction — but it is still validated against the Graph + response id, so a token/phone-id mix-up (valid token, wrong or + mistyped id) fails here instead of storing an account whose + identity doesn't match what the API serves. + + Returns (ok, message, credential). The credential is the asdict + of ``WhatsAppBusinessCredential`` — the same shape the legacy + login() saved. + """ + access_token = (credentials.get("access_token") or "").strip() + phone_number_id = (credentials.get("phone_number_id") or "").strip() + if not access_token: + return False, "Missing WhatsApp Business access token (access_token).", None + if not phone_number_id: + return False, "Missing WhatsApp Business phone number ID (phone_number_id).", None + + result = http_request( + "GET", + f"{GRAPH_API_BASE}/{phone_number_id}", + headers={"Authorization": f"Bearer {access_token}"}, + expected=(200,), + ) + if "error" in result: + return False, f"Invalid credentials: {result['error']}", None + + data = result.get("result") or {} + returned_id = str(data.get("id") or "").strip() + if returned_id and returned_id.lower() != phone_number_id.lower(): + return ( + False, + f"Phone Number ID mismatch: you entered {phone_number_id} but the " + f"API returned {returned_id}. Re-check the Phone Number ID on the " + "WhatsApp > API Setup page.", + None, + ) + + credential = asdict( + WhatsAppBusinessCredential( + access_token=access_token, + phone_number_id=phone_number_id, + ) + ) + display = data.get("display_phone_number") or "" + name = data.get("verified_name") or "" + label = " ".join(part for part in (name, display) if part) + suffix = f" — {label}" if label else "" + return ( + True, + f"WhatsApp Business connected (phone number ID: {phone_number_id}){suffix}", + credential, + ) + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy WhatsApp Business actions stay in place + + def guidance(self) -> str: + return "" # bridge provider — the legacy action surface has its own docs + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> Optional[LegacyListenerAdapter]: + """The Cloud API pushes inbound messages via webhooks; the legacy + client has no listen loop (``supports_listening`` is the + BasePlatformClient default False), so there is nothing to poll — + checked dynamically so a future legacy listen loop gets bridged + automatically.""" + if getattr(client, "supports_listening", False): + return LegacyListenerAdapter(client, emit) + return None diff --git a/craftos_integrations/providers/whatsapp_web/__init__.py b/craftos_integrations/providers/whatsapp_web/__init__.py new file mode 100644 index 00000000..bb78991b --- /dev/null +++ b/craftos_integrations/providers/whatsapp_web/__init__.py @@ -0,0 +1,3 @@ +from .provider import WhatsAppWebProvider, teardown_account + +__all__ = ["WhatsAppWebProvider", "teardown_account"] diff --git a/craftos_integrations/providers/whatsapp_web/provider.py b/craftos_integrations/providers/whatsapp_web/provider.py new file mode 100644 index 00000000..0e4dca66 --- /dev/null +++ b/craftos_integrations/providers/whatsapp_web/provider.py @@ -0,0 +1,217 @@ +"""WhatsApp Web bridge provider — auth-layer-only port of the legacy +client, with per-account Node bridges (wave 3 of the legacy-to-v2 plan). + +Bridge pattern (see telegram_bot/provider.py for the binding rationale): +the battle-tested legacy ``WhatsAppWebClient`` keeps its entire API +surface; the binding mixin injects the per-account credential and — the +whatsapp-specific part — binds the client to that account's OWN +``WhatsAppBridge`` from the registry in ``_bridge_client``. One account += one Node subprocess = one headless Chromium = one LocalAuth dir +(``whatsapp_wwebjs_auth//``); the old process-wide singleton +is gone. + +Auth is a QR scan, not a token and not OAuth: ``oauth_spec()`` raises +NotImplementedError and there is deliberately NO ``run_login`` and NO +``verify_token`` — the only connect path is the QR session flow in the +legacy module (``start_qr_session`` / ``check_qr_session_status``), +which the host drives and which returns the identity + full credential +dict on ``connected`` for the host to store via the IntegrationSystem +(this package cannot write the AccountSet itself — layering). + +One account = one **phone number**; identity is the normalized owner +wid/phone via ``normalize_wa_identity`` (digits of the wid without the +``:NN`` device suffix and ``@c.us`` domain — the ONE rule shared with +the QR flow and the bridge registry). The credential dict carries +``wid`` (preferred, it is WhatsApp's own id) and ``owner_phone`` (also +present in pre-bridge legacy credentials, so ``identity_of`` resolves +those too and the core's legacy-file migration lands on the right +identity instead of LEGACY_IDENTITY). + +Sessions live in wwebjs's LocalAuth dir, not in the credential — nothing +to rotate, so ``refresh()`` returns None. A revoked session surfaces as +a ``qr`` event on the next listener start (the legacy client tears down +and waits for a fresh login). + +Listener safety — how two accounts' events stay apart: each bound client +holds its own bridge instance, and a bridge fans events out to exactly +one callback (``set_event_callback``), wired to the owning client's +``_on_bridge_event`` inside the legacy ``start_listening``. All dedup / +echo-suppression state (``_seen_ids``, ``_agent_sent_ids``, +``_known_groups``, ``_message_callback``) is per client instance. The +one shared bit is the module-level *config* file (``self_messages_only``) +— a global read-only preference applied to every account alike, same as +telegram_bot/telegram_user. + +Legacy disk touchpoints the binding neutralizes: ``has_credentials`` / +``_load`` (read whatsapp_web.json) answer from the injected credential; +``_get_bridge`` resolves the registry by identity instead of the legacy +single-account lookup; ``_store_updated_credential`` (owner-info refresh +captured at the ready event) routes through ``persist`` into the account +entry instead of overwriting the legacy json. + +Account removal: the core's ``remove_account`` knows nothing about Node +processes, so the host must ALSO call ``teardown_account(identity)`` +(module-level here, or the provider method of the same name) on +disconnect — it stops that account's bridge, attempts a server-side +logout, deletes its LocalAuth dir, and forgets it in the registry. +""" + +from __future__ import annotations + +from dataclasses import fields +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from ...contracts import OAuthSpec, Operation +from ...integrations.whatsapp_web import WhatsAppWebClient, WhatsAppWebCredential +from ...integrations.whatsapp_web._bridge_client import ( + get_whatsapp_bridge, + normalize_wa_identity, +) +from ...integrations.whatsapp_web._bridge_client import ( + teardown_account as _teardown_account, +) +from .._shared import LegacyListenerAdapter + +_CRED_FIELDS = {f.name for f in fields(WhatsAppWebCredential)} + + +async def teardown_account(identity: str) -> None: + """Host hook for WhatsApp account removal (call on disconnect, after + the core's ``remove_account``): stops the account's Node bridge, + attempts a server-side logout, deletes its LocalAuth auth dir, and + drops it from the bridge registry. Idempotent; accepts any phone/wid + spelling.""" + await _teardown_account(identity) + + +class WhatsAppWebClientBinding: + """Overrides WhatsAppWebClient's disk + singleton plumbing: credential + injected per account, bridge resolved per identity. MRO puts this + before the legacy client: + + class BoundWhatsAppWebClient(WhatsAppWebClientBinding, WhatsAppWebClient): pass + + ``_load`` ignores the legacy ``self._cred`` attribute entirely (the + legacy ``start_listening`` nulls and reassigns it) and answers from + ``_bound_cred``, so the bound client never touches whatsapp_web.json. + """ + + _bound_cred: Optional[WhatsAppWebCredential] = None + _identity: Optional[str] = None + _raw_cred: Dict[str, Any] + _persist: Callable[[Dict[str, Any]], None] + + def bind_credential( + self, credential: Dict[str, Any], persist: Callable[[Dict[str, Any]], None] + ) -> None: + # Filters to legacy dataclass fields — drops the provider-level + # ``wid`` key the legacy client doesn't know about. + self._bound_cred = WhatsAppWebCredential( + **{k: v for k, v in credential.items() if k in _CRED_FIELDS} + ) + identity = normalize_wa_identity( + credential.get("wid") or credential.get("owner_phone") + ) + if identity is None: + raise ValueError( + "whatsapp_web credential has no owner phone/wid — cannot " + "resolve which account's bridge to bind" + ) + self._identity = identity + self._raw_cred = dict(credential) + self._persist = persist + + def has_credentials(self) -> bool: + return self._bound_cred is not None + + def _load(self) -> WhatsAppWebCredential: + if self._bound_cred is None: + raise RuntimeError("client used before bind_credential()") + return self._bound_cred + + def _get_bridge(self): + # Per-account bridge from the registry — NEVER the legacy + # single-account resolution. Cached on the instance like the + # legacy client does. + if self._bridge is None: + if self._identity is None: + raise RuntimeError("client used before bind_credential()") + self._bridge = get_whatsapp_bridge(self._identity) + return self._bridge + + def _store_updated_credential(self, updated: WhatsAppWebCredential) -> None: + # Owner info refreshed from the bridge's ready event → the + # account entry via persist, not the legacy whatsapp_web.json. + # ``wid`` (and any other provider-level keys) are preserved from + # the originally bound credential so the identity stays stable. + self._bound_cred = updated + self._raw_cred = { + **self._raw_cred, + "session_id": updated.session_id, + "owner_phone": updated.owner_phone, + "owner_name": updated.owner_name, + } + self._persist(dict(self._raw_cred)) + + +class BoundWhatsAppWebClient(WhatsAppWebClientBinding, WhatsAppWebClient): + """WhatsAppWebClient bound to one account's credential and bridge.""" + + +class WhatsAppWebProvider: + id = "whatsapp_web" + family = None # standalone — no cross-provider alias sharing + display_name = "WhatsApp" + client_cls = BoundWhatsAppWebClient + + def identity_of(self, credential: Dict[str, Any]) -> Optional[str]: + """Normalized owner wid/phone (``normalize_wa_identity`` — the one + rule). Prefers the ``wid`` captured by the QR flow (WhatsApp's + own id); falls back to ``owner_phone`` so legacy pre-bridge + credentials resolve too. None for junk shapes.""" + try: + wid = credential.get("wid") + phone = credential.get("owner_phone") + except AttributeError: + return None + return normalize_wa_identity(wid) or normalize_wa_identity(phone) + + def oauth_spec(self) -> OAuthSpec: + raise NotImplementedError("whatsapp_web uses QR login") + + def build_client( + self, + credential: Dict[str, Any], + persist: Callable[[Dict[str, Any]], None], + ) -> Any: + client = self.client_cls() + client.bind_credential(credential, persist) + return client + + async def refresh(self, credential: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return None # the session lives in LocalAuth on disk, not the credential + + def operations(self) -> List[Operation]: + return [] # bridge provider — legacy action functions stay the surface + + def guidance(self) -> str: + return "" + + def make_listener( + self, + client: Any, + cursor: Optional[Dict[str, Any]], + emit: Callable[[Dict[str, Any]], Awaitable[None]], + ) -> LegacyListenerAdapter: + """The legacy client's own bridge-event listen loop, reused + verbatim: ``start_listening`` starts (or reattaches to) THIS + account's bridge and wires its single event callback to this + client — per-account bridges mean two listening accounts never + share an event stream. No restart-safe cursor, same as under the + legacy manager (the bridge re-emits from WhatsApp's own sync).""" + return LegacyListenerAdapter(client, emit) + + async def teardown_account(self, identity: str) -> None: + """Provider-method spelling of the module-level hook (host may + hold only the provider instance).""" + await _teardown_account(identity) diff --git a/tests/integrations/conformance.py b/tests/integrations/conformance.py index 24843d87..49436f96 100644 --- a/tests/integrations/conformance.py +++ b/tests/integrations/conformance.py @@ -63,13 +63,26 @@ def test_identity_of_tolerates_junk(self): # ── oauth ──────────────────────────────────────────────────────────── + def _oauth_spec(self): + """Token-only providers (auth-layer bridge ports) have no OAuth at + all and raise NotImplementedError — an explicit declaration, like + ``has_chooser=False``, not an accident.""" + try: + return self.provider.oauth_spec() + except NotImplementedError: + return None + def test_oauth_spec_urls(self): - spec = self.provider.oauth_spec() + spec = self._oauth_spec() + if spec is None: + pytest.skip(f"{self.provider.id} is token-only — no OAuth spec") assert spec.authorize_url.startswith("https://") assert spec.token_url.startswith("https://") def test_missing_chooser_is_declared_and_documented(self): - spec = self.provider.oauth_spec() + spec = self._oauth_spec() + if spec is None: + pytest.skip(f"{self.provider.id} is token-only — no OAuth spec") if not spec.has_chooser: guidance = self.provider.guidance().lower() assert "account" in guidance, ( diff --git a/tests/integrations/test_discord_conformance.py b/tests/integrations/test_discord_conformance.py new file mode 100644 index 00000000..e5821d43 --- /dev/null +++ b/tests/integrations/test_discord_conformance.py @@ -0,0 +1,140 @@ +"""Discord bridge-provider conformance + binding/verify tests. + +No network: verify_token's HTTP is monkeypatched. What's real is +conformance, the credential binding, identity extraction, and the +token-verification flow mirroring the legacy DiscordHandler.login(). +""" + +from __future__ import annotations + +import craftos_integrations.providers.discord.provider as discord_mod +from craftos_integrations.providers.discord import DiscordProvider +from craftos_integrations.providers.discord.provider import BoundDiscordClient +from craftos_integrations.providers._shared import LegacyListenerAdapter + +from .conformance import ProviderConformance + +# Realistic SHAPE, fake values — asdict(DiscordCredential) as verify_token +# builds it after a successful GET /users/@me with the bot token. +DISCORD_CRED = { + "bot_token": "MTAwFakeBotTokenFakeBotToken.GfAkE.FakeSignatureFakeSignature", + "user_token": "", + "bot_id": "1234567890123456789", + "bot_username": "craftbot", +} + + +class TestDiscordConformance(ProviderConformance): + provider = DiscordProvider() + credential_fixtures = [ + DISCORD_CRED, # real post-verify shape (bot id captured) + # pre-bridge raw-token credential saved before the id was cached + {"bot_token": "MTAwOldToken.x.y", "bot_id": "", "bot_username": ""}, + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_bot_id(): + provider = DiscordProvider() + assert provider.identity_of(DISCORD_CRED) == "1234567890123456789" + assert provider.identity_of({"bot_id": " 987654321 "}) == "987654321" + assert provider.identity_of({"bot_token": "MTAwOld.x.y"}) is None + assert provider.identity_of({"bot_id": ""}) is None + assert provider.identity_of({"bot_id": " "}) is None + assert provider.identity_of({"bot_id": 123}) is None # non-str tolerated + + +def test_oauth_spec_declares_token_only(): + provider = DiscordProvider() + try: + provider.oauth_spec() + except NotImplementedError: + pass + else: + raise AssertionError("discord must declare token-only via NotImplementedError") + assert not hasattr(provider, "run_login") # no OAuth add-account flow + + +def test_binding_replaces_disk_plumbing(): + client = BoundDiscordClient() + client.bind_credential(dict(DISCORD_CRED, extra_junk_key="ignored"), lambda c: None) + assert client.has_credentials() + cred = client._load() + assert cred.bot_token == DISCORD_CRED["bot_token"] + assert cred.bot_id == DISCORD_CRED["bot_id"] + assert cred.bot_username == "craftbot" + + +def test_build_client_binds_credential(): + client = DiscordProvider().build_client(DISCORD_CRED, lambda c: None) + assert isinstance(client, BoundDiscordClient) + assert client._load().bot_token == DISCORD_CRED["bot_token"] + + +def test_bridge_surface_is_empty(): + provider = DiscordProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_make_listener_wraps_legacy_gateway_loop(): + async def emit(event): + pass + + provider = DiscordProvider() + client = provider.build_client(DISCORD_CRED, lambda c: None) + assert client.supports_listening # gateway websocket loop + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert hasattr(listener, "start") and hasattr(listener, "stop") + assert listener.cursor() is None # legacy loop keeps watermarks in memory + + +def test_verify_token_rejects_missing_token(): + provider = DiscordProvider() + ok, msg, cred = provider.verify_token({}) + assert not ok and cred is None + ok, msg, cred = provider.verify_token({"bot_token": " "}) + assert not ok and cred is None + + +def test_verify_token_success_captures_bot_id(monkeypatch): + def fake_request(method, url, **kwargs): + assert method == "GET" and url.endswith("/users/@me") + assert kwargs["headers"]["Authorization"] == "Bot MTAwFake.x.y" + return { + "ok": True, + "result": {"id": "424242424242", "username": "CraftBot", "bot": True}, + } + + monkeypatch.setattr(discord_mod, "http_request", fake_request) + provider = DiscordProvider() + ok, msg, cred = provider.verify_token({"bot_token": " MTAwFake.x.y "}) + assert ok, msg + assert cred["bot_token"] == "MTAwFake.x.y" + assert cred["bot_id"] == "424242424242" + assert cred["bot_username"] == "CraftBot" + assert cred["user_token"] == "" + assert "CraftBot" in msg + assert provider.identity_of(cred) == "424242424242" + + +def test_verify_token_passes_optional_user_token_through(monkeypatch): + def fake_request(method, url, **kwargs): + return {"ok": True, "result": {"id": "77", "username": "CraftBot"}} + + monkeypatch.setattr(discord_mod, "http_request", fake_request) + ok, msg, cred = DiscordProvider().verify_token( + {"bot_token": "MTAwFake.x.y", "user_token": " user_tok_123 "} + ) + assert ok, msg + assert cred["user_token"] == "user_tok_123" # stored, never verified + + +def test_verify_token_auth_failure(monkeypatch): + def fake_request(method, url, **kwargs): + return {"error": "HTTP 401", "details": "401 Unauthorized"} + + monkeypatch.setattr(discord_mod, "http_request", fake_request) + ok, msg, cred = DiscordProvider().verify_token({"bot_token": "MTAwBad.x.y"}) + assert not ok and cred is None and "Invalid Discord bot token" in msg diff --git a/tests/integrations/test_github_conformance.py b/tests/integrations/test_github_conformance.py new file mode 100644 index 00000000..9a458f6b --- /dev/null +++ b/tests/integrations/test_github_conformance.py @@ -0,0 +1,162 @@ +"""GitHub bridge provider — conformance + binding wiring. + +No network: HTTP and the legacy poll loop are stubbed. What's real is the +binding chain bind_credential → _load → _headers and the start_listening +username backfill routed through persist instead of the legacy file. +""" + +from __future__ import annotations + +import asyncio + +from craftos_integrations.integrations.github import GitHubClient +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.github import GitHubProvider +from craftos_integrations.providers.github.provider import BoundGitHubClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Real github.json shape after a legacy /github login (PAT + captured login). +GITHUB_CRED = { + "access_token": "ghp_abc123", + "username": "OctoCat", # mixed case: identity must lowercase it +} + +# Token saved before the username was captured — no identity → LEGACY_IDENTITY. +LEGACY_CRED = {"access_token": "ghp_abc123", "username": ""} + + +class TestGitHubConformance(ProviderConformance): + provider = GitHubProvider() + credential_fixtures = [ + GITHUB_CRED, + LEGACY_CRED, # identity-less shape → None + {}, # junk + ] + + +def test_identity_is_username_lowercased(): + provider = GitHubProvider() + assert provider.identity_of(GITHUB_CRED) == "octocat" + assert provider.identity_of({"username": " Hubber "}) == "hubber" + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + assert provider.identity_of({"username": 42}) is None # junk never raises + + +def test_token_only_no_oauth_no_run_login(): + provider = GitHubProvider() + try: + provider.oauth_spec() + raise AssertionError("oauth_spec must raise NotImplementedError") + except NotImplementedError: + pass + assert not hasattr(provider, "run_login") + + +def test_refresh_is_none_pats_do_not_rotate(): + assert run(GitHubProvider().refresh(dict(GITHUB_CRED))) is None + + +def test_bridge_surface_is_empty(): + provider = GitHubProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_binding_injects_credential_and_headers(): + provider = GitHubProvider() + client = provider.build_client( + {**GITHUB_CRED, "stray_key": "ignored"}, lambda c: None + ) + assert isinstance(client, BoundGitHubClient) + assert client.has_credentials() # no disk fallback + assert client._load().access_token == "ghp_abc123" + assert client._headers()["Authorization"] == "Bearer ghp_abc123" + + unbound = BoundGitHubClient() + assert not unbound.has_credentials() + + +def test_make_listener_wraps_the_legacy_poll_loop(): + provider = GitHubProvider() + client = provider.build_client(dict(GITHUB_CRED), lambda c: None) + + async def emit(event): + pass + + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert client.supports_listening + + +def test_start_listening_backfills_username_via_persist(monkeypatch): + """The legacy save_credential at ~line 284 (username backfill) must + never fire for a bound client — the update goes through persist.""" + persisted = [] + provider = GitHubProvider() + client = provider.build_client(dict(LEGACY_CRED), persisted.append) + + async def fake_user(self): + return {"ok": True, "result": {"login": "OctoCat", "id": 1}} + + started = [] + + async def fake_super_start(self, callback): + started.append(callback) + + monkeypatch.setattr(BoundGitHubClient, "get_authenticated_user", fake_user) + monkeypatch.setattr(GitHubClient, "start_listening", fake_super_start) + + async def callback(msg): + pass + + run(client.start_listening(callback)) + assert started == [callback] # delegated to the legacy loop + assert persisted == [{"access_token": "ghp_abc123", "username": "OctoCat"}] + assert client._load().username == "OctoCat" + + # Second start with a synced username: no further persist. + run(client.start_listening(callback)) + assert len(persisted) == 1 + + +def test_verify_token_mirrors_legacy_login(monkeypatch): + provider = GitHubProvider() + calls = [] + + def fake_request(method, url, headers=None, expected=None, **kwargs): + calls.append((method, url, headers)) + return {"ok": True, "result": {"login": "OctoCat", "name": "Octo Cat"}} + + monkeypatch.setattr( + "craftos_integrations.providers.github.provider.http_request", fake_request + ) + ok, message, credential = provider.verify_token({"access_token": " ghp_abc123 "}) + assert ok + assert "OctoCat" in message + assert credential == {"access_token": "ghp_abc123", "username": "OctoCat"} + assert provider.identity_of(credential) == "octocat" + method, url, headers = calls[0] + assert (method, url) == ("GET", "https://api.github.com/user") + assert headers["Authorization"] == "Bearer ghp_abc123" + + +def test_verify_token_failure_paths(monkeypatch): + provider = GitHubProvider() + + ok, message, credential = provider.verify_token({}) + assert not ok and credential is None + assert "github.com/settings/tokens" in message + + monkeypatch.setattr( + "craftos_integrations.providers.github.provider.http_request", + lambda *a, **k: {"error": "HTTP 401", "details": "Bad credentials"}, + ) + ok, message, credential = provider.verify_token({"access_token": "ghp_bad"}) + assert not ok and credential is None + assert "GitHub auth failed" in message diff --git a/tests/integrations/test_jira_conformance.py b/tests/integrations/test_jira_conformance.py new file mode 100644 index 00000000..ad83ca79 --- /dev/null +++ b/tests/integrations/test_jira_conformance.py @@ -0,0 +1,223 @@ +"""Jira bridge provider — conformance + wiring. + +Auth-layer bridge: no operations, no guidance, no OAuth. What's tested is +the identity scheme (user + site), the binding over the legacy client, the +token verifier (network stubbed), and the legacy-listener adapter. +""" + +from __future__ import annotations + +import asyncio + +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.jira import JiraProvider +from craftos_integrations.providers.jira import provider as jira_provider_module +from craftos_integrations.providers.jira.provider import BoundJiraClient + +from .conformance import ProviderConformance + +import pytest + + +def run(coro): + return asyncio.run(coro) + + +# Real token-connect shape (handler fields: domain, email, api_token). +# Mixed case on purpose: identity must lowercase both halves. +JIRA_CRED = { + "domain": "MyCompany.atlassian.net", + "email": "You@Example.com", + "api_token": "ATATT3xFfGF0-secret", +} + +JUNK_CRED = {"domain": 42, "email": None, "token": ["nope"]} + + +class TestJiraConformance(ProviderConformance): + provider = JiraProvider() + credential_fixtures = [ + JIRA_CRED, + JUNK_CRED, # malformed — identity_of must return None, never raise + {}, + ] + + +# ── identity: user AND site ────────────────────────────────────────────── + + +def test_identity_is_email_at_site_host_lowercased(): + provider = JiraProvider() + assert ( + provider.identity_of(JIRA_CRED) == "you@example.com@mycompany.atlassian.net" + ) + + +def test_identity_same_user_two_sites_is_two_accounts(): + provider = JiraProvider() + a = provider.identity_of({**JIRA_CRED, "domain": "site-a.atlassian.net"}) + b = provider.identity_of({**JIRA_CRED, "domain": "site-b.atlassian.net"}) + assert a != b and a and b + + +def test_identity_site_url_scheme_is_stripped(): + provider = JiraProvider() + # OAuth-shape credential: accountId + site_url with scheme and path. + cred = { + "accountId": "5B10AC8D", + "site_url": "https://MyCompany.atlassian.net/", + } + assert provider.identity_of(cred) == "5b10ac8d@mycompany.atlassian.net" + + +def test_identity_none_when_either_half_missing(): + provider = JiraProvider() + assert provider.identity_of({"email": "you@example.com"}) is None # no site + assert provider.identity_of({"domain": "x.atlassian.net"}) is None # no user + assert provider.identity_of(JUNK_CRED) is None + assert provider.identity_of({}) is None + + +# ── token-only: no OAuth, no refresh ───────────────────────────────────── + + +def test_oauth_spec_is_declared_token_only(): + with pytest.raises(NotImplementedError): + JiraProvider().oauth_spec() + + +def test_no_run_login(): + assert not hasattr(JiraProvider(), "run_login") + + +def test_refresh_is_none_tokens_do_not_expire(): + assert run(JiraProvider().refresh(dict(JIRA_CRED))) is None + + +# ── bridge surface ─────────────────────────────────────────────────────── + + +def test_bridge_has_no_operations_and_no_guidance(): + provider = JiraProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +# ── binding ────────────────────────────────────────────────────────────── + + +def test_binding_injects_credential_and_ignores_extra_keys(): + provider = JiraProvider() + persisted = [] + client = provider.build_client( + {**JIRA_CRED, "account_id": "5B10AC8D", "not_a_field": "x"}, + persisted.append, + ) + assert isinstance(client, BoundJiraClient) + assert client.has_credentials() + cred = client._load() + assert cred.domain == "MyCompany.atlassian.net" + assert cred.email == "You@Example.com" + assert cred.api_token == "ATATT3xFfGF0-secret" + assert persisted == [] # no refresh path — persist never called + + +def test_unbound_client_never_falls_back_to_disk(): + client = BoundJiraClient() + assert not client.has_credentials() + with pytest.raises(RuntimeError): + client._load() + + +# ── verify_token (network stubbed) ─────────────────────────────────────── + + +class _FakeResponse: + def __init__(self, status_code, payload=None, text=""): + self.status_code = status_code + self._payload = payload or {} + self.text = text + + def json(self): + return self._payload + + +def test_verify_token_success_mirrors_legacy_login(monkeypatch): + calls = [] + + def fake_get(url, headers=None, timeout=None, follow_redirects=None): + calls.append((url, headers)) + return _FakeResponse( + 200, + { + "accountId": "5B10AC8D", + "displayName": "Ahmad A", + "emailAddress": "you@example.com", + }, + ) + + monkeypatch.setattr(jira_provider_module.httpx, "get", fake_get) + + provider = JiraProvider() + ok, message, credential = provider.verify_token( + { + "domain": "https://MyCompany.atlassian.net/", + "email": " You@Example.com ", + "api_token": " ATATT3xFfGF0-secret ", + } + ) + assert ok, message + assert "Ahmad A" in message and "mycompany.atlassian.net" in message.lower() + # Scheme/slash stripped exactly like JiraHandler.login(); v3 tried first. + assert calls[0][0] == "https://MyCompany.atlassian.net/rest/api/3/myself" + assert calls[0][1]["Authorization"].startswith("Basic ") + assert credential["domain"] == "MyCompany.atlassian.net" + assert credential["email"] == "You@Example.com" + assert credential["api_token"] == "ATATT3xFfGF0-secret" + assert credential["account_id"] == "5B10AC8D" + # The verified credential is identity-bearing (user + site). + assert ( + JiraProvider().identity_of(credential) + == "you@example.com@mycompany.atlassian.net" + ) + + +def test_verify_token_auth_failure_falls_back_v2_then_hints(monkeypatch): + calls = [] + + def fake_get(url, headers=None, timeout=None, follow_redirects=None): + calls.append(url) + return _FakeResponse(401, text="Unauthorized") + + monkeypatch.setattr(jira_provider_module.httpx, "get", fake_get) + + ok, message, credential = JiraProvider().verify_token(dict(JIRA_CRED)) + assert not ok and credential is None + assert "401" in message and "API token" in message + # Same v3 → v2 fallback the legacy handler runs. + assert [u.split("/rest/api/")[1] for u in calls] == ["3/myself", "2/myself"] + + +def test_verify_token_missing_fields_never_calls_network(monkeypatch): + def boom(*a, **k): # pragma: no cover - guards against network use + raise AssertionError("network must not be touched") + + monkeypatch.setattr(jira_provider_module.httpx, "get", boom) + ok, message, credential = JiraProvider().verify_token({"email": "x@y.com"}) + assert not ok and credential is None + + +# ── listener ───────────────────────────────────────────────────────────── + + +def test_make_listener_is_legacy_adapter_over_the_bound_client(): + provider = JiraProvider() + + async def emit(event): + pass + + client = provider.build_client(dict(JIRA_CRED), lambda c: None) + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert listener._client is client + assert listener.cursor() is None # legacy loop keeps its own watermark diff --git a/tests/integrations/test_lark_conformance.py b/tests/integrations/test_lark_conformance.py new file mode 100644 index 00000000..9bca4976 --- /dev/null +++ b/tests/integrations/test_lark_conformance.py @@ -0,0 +1,284 @@ +"""Lark family bridge-provider conformance + binding/verify tests. + +No network: token minting (``validate_and_mint_token``) and the bot-info +HTTP call are monkeypatched. What's real is conformance for all three +siblings, the shared family value, the credential binding (including the +tenant-token refresh routing through ``persist`` instead of the legacy +credential file), identity extraction, and verify_token mirroring the +legacy handlers' login(). +""" + +from __future__ import annotations + +import asyncio +import time + +import craftos_integrations.providers._lark as lark_base +import craftos_integrations.providers.lark.provider as lark_mod +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.lark import LarkProvider +from craftos_integrations.providers.lark.provider import BoundLarkClient +from craftos_integrations.providers.lark_calendar import LarkCalendarProvider +from craftos_integrations.providers.lark_calendar.provider import ( + BoundLarkCalendarClient, +) +from craftos_integrations.providers.lark_drive import LarkDriveProvider +from craftos_integrations.providers.lark_drive.provider import BoundLarkDriveClient + +from .conformance import ProviderConformance + +# Far-future expiry so the binding never tries to re-mint during tests +# that don't monkeypatch the minting call. +FRESH = 4102444800.0 # 2100-01-01 + +# Realistic SHAPE, fake values — asdict(LarkCredential) as verify_token +# builds it. All three services share the same shape (one Custom App); +# bot fields are populated only by the messaging integration. +LARK_CRED = { + "app_id": "cli_a1b2c3d4e5f6g7h8", + "app_secret": "FakeSecretFakeSecretFakeSec", + "tenant_access_token": "t-fake-cached-token", + "token_expires_at": FRESH, + "bot_name": "CraftBot", + "bot_open_id": "ou_fake_bot_open_id", +} +CAL_CRED = dict(LARK_CRED, bot_name="", bot_open_id="") +DRIVE_CRED = dict(LARK_CRED, bot_name="", bot_open_id="") + +JUNK_FIXTURES = [ + {"app_id": "", "app_secret": "orphan-secret"}, # no identity + {}, # junk — must not raise +] + + +class TestLarkConformance(ProviderConformance): + provider = LarkProvider() + credential_fixtures = [LARK_CRED] + JUNK_FIXTURES + + +class TestLarkCalendarConformance(ProviderConformance): + provider = LarkCalendarProvider() + credential_fixtures = [CAL_CRED] + JUNK_FIXTURES + + +class TestLarkDriveConformance(ProviderConformance): + provider = LarkDriveProvider() + credential_fixtures = [DRIVE_CRED] + JUNK_FIXTURES + + +ALL_PROVIDERS = (LarkProvider(), LarkCalendarProvider(), LarkDriveProvider()) + + +def test_family_is_lark_across_all_three(): + assert {p.family for p in ALL_PROVIDERS} == {"lark"} + assert [p.id for p in ALL_PROVIDERS] == ["lark", "lark_calendar", "lark_drive"] + + +def test_identity_is_lowercased_app_id(): + for provider in ALL_PROVIDERS: + assert provider.identity_of(LARK_CRED) == "cli_a1b2c3d4e5f6g7h8" + assert provider.identity_of({"app_id": " CLI_UpperCase "}) == "cli_uppercase" + assert provider.identity_of({"app_secret": "s"}) is None + assert provider.identity_of({"app_id": ""}) is None + assert provider.identity_of({"app_id": " "}) is None + assert provider.identity_of({"app_id": 123}) is None # non-str tolerated + + +def test_oauth_spec_declares_token_only(): + for provider in ALL_PROVIDERS: + try: + provider.oauth_spec() + except NotImplementedError: + pass + else: + raise AssertionError( + f"{provider.id} must declare token-only via NotImplementedError" + ) + assert not hasattr(provider, "run_login") # no OAuth add-account flow + + +def test_bridge_surface_is_empty(): + for provider in ALL_PROVIDERS: + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_binding_replaces_disk_plumbing(): + for cls in (BoundLarkClient, BoundLarkCalendarClient, BoundLarkDriveClient): + client = cls() + client.bind_credential(dict(LARK_CRED, extra_junk_key="ignored"), lambda c: None) + assert client.has_credentials() + cred = client._load() # fresh token → no mint, no persist + assert cred.app_id == LARK_CRED["app_id"] + assert cred.app_secret == LARK_CRED["app_secret"] + assert cred.tenant_access_token == LARK_CRED["tenant_access_token"] + + +def test_build_client_binds_credential(): + for provider, cls in zip( + ALL_PROVIDERS, (BoundLarkClient, BoundLarkCalendarClient, BoundLarkDriveClient) + ): + client = provider.build_client(LARK_CRED, lambda c: None) + assert isinstance(client, cls) + assert client._load().app_id == LARK_CRED["app_id"] + + +def test_token_refresh_routes_through_persist_not_legacy_file(monkeypatch): + """Expired cached token → the binding re-mints and persists through the + core; the legacy ``ensure_token``'s save_credential (which writes the + single-account lark*.json) must never fire, even on the legacy + ``_headers`` path that calls ``ensure_token`` after us.""" + import craftos_integrations.integrations._lark_common as legacy_common + + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: ("t-new-minted", time.time() + 7200, None), + ) + + def no_disk(*args, **kwargs): + raise AssertionError("legacy save_credential must not fire for bound clients") + + monkeypatch.setattr(legacy_common, "save_credential", no_disk) + + for provider in ALL_PROVIDERS: + holder = {} + client = provider.build_client( + dict(DRIVE_CRED, tenant_access_token="t-stale", token_expires_at=0.0), + holder.update, + ) + headers = client._headers() # legacy make_headers → ensure_token cache-hit + assert headers["Authorization"] == "Bearer t-new-minted" + assert holder["tenant_access_token"] == "t-new-minted" + assert holder["app_id"] == DRIVE_CRED["app_id"] + + +def test_provider_refresh_out_of_band(monkeypatch): + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: ("t-refreshed", time.time() + 7200, None), + ) + provider = LarkDriveProvider() + updated = asyncio.run( + provider.refresh(dict(DRIVE_CRED, token_expires_at=0.0)) + ) + assert updated["tenant_access_token"] == "t-refreshed" + # Still-fresh cached token → nothing persisted → None (no update). + assert asyncio.run(provider.refresh(DRIVE_CRED)) is None + + +def test_provider_refresh_failure_returns_none(monkeypatch): + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: (None, 0.0, "Invalid Lark credentials: app deleted"), + ) + updated = asyncio.run( + LarkProvider().refresh(dict(LARK_CRED, token_expires_at=0.0)) + ) + assert updated is None + + +def test_listener_support_per_platform(): + async def emit(event): + pass + + # lark (messaging): legacy WS loop is bridged via the generic adapter. + lark_provider = LarkProvider() + chat_client = lark_provider.build_client(LARK_CRED, lambda c: None) + assert chat_client.supports_listening + listener = lark_provider.make_listener(chat_client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + + # calendar / drive: request-response only → no listener. + for provider in (LarkCalendarProvider(), LarkDriveProvider()): + client = provider.build_client(CAL_CRED, lambda c: None) + assert not client.supports_listening + assert provider.make_listener(client, None, emit) is None + + +def test_verify_token_missing_fields(): + for provider in ALL_PROVIDERS: + ok, msg, cred = provider.verify_token({}) + assert not ok and cred is None and "App ID" in msg + ok, msg, cred = provider.verify_token({"app_id": "cli_x"}) + assert not ok and cred is None and "App Secret" in msg + + +def test_verify_token_rejected_by_api(monkeypatch): + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: (None, 0.0, "Invalid Lark credentials: app not found"), + ) + for provider in ALL_PROVIDERS: + ok, msg, cred = provider.verify_token( + {"app_id": "cli_bad", "app_secret": "wrong"} + ) + assert not ok and cred is None + assert "Invalid Lark credentials" in msg + + +def test_verify_token_success_calendar_and_drive(monkeypatch): + expires = time.time() + 7200 + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: ("t-minted", expires, None), + ) + for provider in (LarkCalendarProvider(), LarkDriveProvider()): + ok, msg, cred = provider.verify_token( + {"app_id": " CLI_AbC123 ", "app_secret": " s3cret "} + ) + assert ok, msg + assert provider.display_name in msg and "CLI_AbC123" in msg + assert cred["app_id"] == "CLI_AbC123" # stripped, case preserved + assert cred["app_secret"] == "s3cret" + assert cred["tenant_access_token"] == "t-minted" + assert cred["token_expires_at"] == expires + assert cred["bot_name"] == "" and cred["bot_open_id"] == "" + assert provider.identity_of(cred) == "cli_abc123" + + +def test_verify_token_lark_captures_bot_info(monkeypatch): + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: ("t-minted", time.time() + 7200, None), + ) + + def fake_request(method, url, **kwargs): + assert method == "GET" and url.endswith("/bot/v3/info") + assert kwargs["headers"]["Authorization"] == "Bearer t-minted" + return { + "ok": True, + "result": {"bot": {"app_name": "CraftBot", "open_id": "ou_bot_1"}}, + } + + monkeypatch.setattr(lark_mod, "http_request", fake_request) + ok, msg, cred = LarkProvider().verify_token( + {"app_id": "cli_chat", "app_secret": "s"} + ) + assert ok, msg + assert "CraftBot" in msg # label prefers bot name + assert cred["bot_name"] == "CraftBot" + assert cred["bot_open_id"] == "ou_bot_1" + assert LarkProvider().identity_of(cred) == "cli_chat" + + +def test_verify_token_lark_tolerates_bot_info_failure(monkeypatch): + monkeypatch.setattr( + lark_base, + "validate_and_mint_token", + lambda app_id, app_secret: ("t-minted", time.time() + 7200, None), + ) + monkeypatch.setattr( + lark_mod, "http_request", lambda *a, **k: {"error": "HTTP 400"} + ) + ok, msg, cred = LarkProvider().verify_token( + {"app_id": "cli_nobot", "app_secret": "s"} + ) + assert ok, msg # bot capability not enabled yet — still a valid app + assert "cli_nobot" in msg + assert cred["bot_name"] == "" and cred["bot_open_id"] == "" diff --git a/tests/integrations/test_line_conformance.py b/tests/integrations/test_line_conformance.py new file mode 100644 index 00000000..c1986fbc --- /dev/null +++ b/tests/integrations/test_line_conformance.py @@ -0,0 +1,142 @@ +"""LINE provider — conformance + wiring. + +No network: verify_token's HTTP call is monkeypatched. What's real is +the bridge contract — token-only OAuth declaration, per-account credential +binding, bot-user-id identity, and the no-listener declaration (LINE is +webhook-push only). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from craftos_integrations.providers.line import LineProvider +from craftos_integrations.providers.line.provider import BoundLineClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Real credential shape as verify_token stores it (bot user id captured +# from GET /v2/bot/info at verify time; mixed case: identity must lowercase it). +LINE_CRED = { + "channel_access_token": "test-channel-token-1", + "channel_secret": "test-channel-secret-1", + "bot_user_id": "Ub1234ABCDEF9876", + "bot_display_name": "CraftBot", +} + +# Pre-identity-capture shape — token only, no bot user id → LEGACY_IDENTITY. +LEGACY_CRED = {"channel_access_token": "test-old-token"} + + +class TestLineConformance(ProviderConformance): + provider = LineProvider() + credential_fixtures = [ + LINE_CRED, + LEGACY_CRED, # identity-less shape → None + {}, # junk + ] + + +def test_identity_is_bot_user_id_lowercased(): + provider = LineProvider() + assert provider.identity_of(LINE_CRED) == "ub1234abcdef9876" + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + # junk shapes never raise + assert provider.identity_of({"bot_user_id": " "}) is None + assert provider.identity_of({"bot_user_id": 123}) is None + + +def test_oauth_spec_declares_token_only(): + with pytest.raises(NotImplementedError): + LineProvider().oauth_spec() + assert not hasattr(LineProvider(), "run_login") + + +def test_refresh_is_none_tokens_do_not_expire(): + assert run(LineProvider().refresh(dict(LINE_CRED))) is None + + +def test_bridge_surface_is_empty(): + provider = LineProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_no_listener_line_is_webhook_push_only(): + async def emit(event): + pass + + provider = LineProvider() + client = provider.build_client(dict(LINE_CRED), lambda c: None) + assert client.supports_listening is False # legacy client declaration + assert provider.make_listener(client, None, emit) is None + + +def test_binding_injects_credential_and_ignores_extra_keys(): + client = BoundLineClient() + assert not client.has_credentials() # no disk fallback + client.bind_credential({**LINE_CRED, "stray_key": "x"}, lambda c: None) + assert client.has_credentials() + cred = client._load() + assert cred.channel_access_token == "test-channel-token-1" + assert cred.bot_user_id == "Ub1234ABCDEF9876" + # the auth header the legacy REST methods build uses the bound token + assert ( + client._headers()["Authorization"] == "Bearer test-channel-token-1" + ) + + +def test_verify_token_mirrors_legacy_login(monkeypatch): + """Same check as LineHandler.login(): GET /v2/bot/info with the token; + the bot's userId lands in the credential so identity_of works.""" + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs.get("headers", {}))) + return { + "result": {"userId": "Ub1234ABCDEF9876", "displayName": "CraftBot"} + } + + monkeypatch.setattr( + "craftos_integrations.providers.line.provider.http_request", fake_request + ) + provider = LineProvider() + ok, message, credential = provider.verify_token( + { + "channel_access_token": " test-channel-token-1 ", + "channel_secret": "test-channel-secret-1", + } + ) + assert ok and credential is not None + assert "CraftBot" in message + assert credential == LINE_CRED # stored shape == fixture shape + assert provider.identity_of(credential) == "ub1234abcdef9876" + + method, url, headers = calls[0] + assert method == "GET" + assert url == "https://api.line.me/v2/bot/info" + assert headers["Authorization"] == "Bearer test-channel-token-1" + + +def test_verify_token_rejects_bad_or_missing_token(monkeypatch): + provider = LineProvider() + + ok, message, credential = provider.verify_token({}) + assert not ok and credential is None + + monkeypatch.setattr( + "craftos_integrations.providers.line.provider.http_request", + lambda *a, **k: {"error": "HTTP 401"}, + ) + ok, message, credential = provider.verify_token( + {"channel_access_token": "bad-token"} + ) + assert not ok and credential is None + assert "Invalid channel access token" in message diff --git a/tests/integrations/test_management_actions.py b/tests/integrations/test_management_actions.py index 895dbaae..79713a38 100644 --- a/tests/integrations/test_management_actions.py +++ b/tests/integrations/test_management_actions.py @@ -192,18 +192,23 @@ def test_slack_token_connect_auth_failure_stores_nothing( assert v2_system.list_accounts("slack") == [] -def test_notion_token_connect_lands_on_legacy_sentinel( +def test_notion_token_connect_captures_bot_identity( action_registry, v2_system, monkeypatch ): - """Token-only Notion credentials carry no workspace id — plan §7 says - they live under the LEGACY sentinel until an OAuth re-auth upgrades - them in place.""" + """A pasted integration token is verified via /users/me and the bot's + workspace/bot ids are captured into the credential, so the account gets + a real identity — a second workspace's token becomes a second account + instead of silently replacing the first (the old LEGACY-sentinel + behavior this test used to pin).""" import craftos_integrations.integrations.notion as notion_mod monkeypatch.setattr( notion_mod, "_notion_call", - lambda method, path, headers, **kw: {"bot": {"workspace_name": "Acme WS"}}, + lambda method, path, headers, **kw: { + "id": "BOT-123", + "bot": {"workspace_name": "Acme WS", "workspace_id": "WS-9"}, + }, ) result = _run( action_registry, @@ -220,12 +225,41 @@ def test_notion_token_connect_lands_on_legacy_sentinel( "auth_type": "token", } accounts = v2_system.list_accounts("notion") - assert [a.identity for a in accounts] == ["legacy"] - assert v2_system.accounts.credential_for("notion", "legacy") == { - "token": "secret_abc" + assert [a.identity for a in accounts] == ["ws-9"] + assert v2_system.accounts.credential_for("notion", "ws-9") == { + "token": "secret_abc", + "bot_id": "BOT-123", + "workspace_id": "WS-9", } +def test_identity_less_token_connect_is_rejected( + action_registry, v2_system, monkeypatch +): + """When verification can't produce an identity, the connect is refused — + storing under the LEGACY sentinel would let the next identity-less + connect overwrite this account's credential.""" + import craftos_integrations.integrations.notion as notion_mod + + monkeypatch.setattr( + notion_mod, + "_notion_call", + lambda method, path, headers, **kw: {"bot": {"workspace_name": "Acme WS"}}, + ) + result = _run( + action_registry, + "connect_integration", + { + "integration_id": "notion", + "credentials": {"token": "secret_abc"}, + "auth_method": "token", + }, + ) + assert result["status"] == "error" + assert "overwritten" in result["message"] + assert v2_system.list_accounts("notion") == [] + + def test_hubspot_token_connect_uses_hub_id_identity( action_registry, v2_system, monkeypatch ): diff --git a/tests/integrations/test_provider_listeners.py b/tests/integrations/test_provider_listeners.py index 2a3b5eed..a3cff4e6 100644 --- a/tests/integrations/test_provider_listeners.py +++ b/tests/integrations/test_provider_listeners.py @@ -431,7 +431,8 @@ async def emit(event): # no-op pass providers = default_providers() - assert len(providers) == 10 + # 10 full ports + 5 wave-1 + 6 wave-2 + 2 wave-3 bridges + assert len(providers) == 23 with_listeners = set() for provider in providers: listener = provider.make_listener(object(), None, emit) @@ -440,5 +441,24 @@ async def emit(event): # no-op assert hasattr(listener, "start") assert hasattr(listener, "stop") assert hasattr(listener, "cursor") - assert listener.poll_interval > 0 - assert with_listeners == {"gmail", "outlook", "slack"} + # poll_interval is optional (stagger hint): hand-written + # listeners expose theirs; LegacyListenerAdapter does not. + interval = getattr(listener, "poll_interval", None) + if interval is not None: + assert interval > 0 + # Bridged platforms reuse their legacy listen loops via + # LegacyListenerAdapter: github/jira/twitter watch-polls, telegram_bot + # getUpdates long-poll, discord gateway, lark websocket. + assert with_listeners == { + "gmail", + "outlook", + "slack", + "github", + "jira", + "telegram_bot", + "discord", + "twitter", + "lark", + "telegram_user", + "whatsapp_web", + } diff --git a/tests/integrations/test_storage.py b/tests/integrations/test_storage.py index bf37c001..76405e73 100644 --- a/tests/integrations/test_storage.py +++ b/tests/integrations/test_storage.py @@ -44,6 +44,11 @@ def test_corrupt_document_is_quarantined_not_silently_empty(store, tmp_path): assert quarantined.read_text(encoding="utf-8") == "{this is not json" +@pytest.mark.skipif( + os.name == "nt", + reason="POSIX owner-only modes don't exist on Windows (no os.fchmod; " + "NTFS ACLs govern access)", +) def test_written_files_are_owner_only(store, tmp_path): store.replace("gmail", DOC) mode = stat.S_IMODE(os.stat(tmp_path / "gmail.accounts.json").st_mode) diff --git a/tests/integrations/test_stripe_conformance.py b/tests/integrations/test_stripe_conformance.py new file mode 100644 index 00000000..d649d5c9 --- /dev/null +++ b/tests/integrations/test_stripe_conformance.py @@ -0,0 +1,148 @@ +"""Stripe bridge-provider conformance + binding/verify tests. + +No network: verify_token's HTTP is monkeypatched. What's real is +conformance, the credential binding, identity extraction, and the +token-verification flow mirroring the legacy StripeHandler.login(). +""" + +from __future__ import annotations + +import craftos_integrations.providers.stripe.provider as stripe_mod +from craftos_integrations.providers.stripe import StripeProvider +from craftos_integrations.providers.stripe.provider import BoundStripeClient + +from .conformance import ProviderConformance + +# Realistic SHAPE, fake values — asdict(StripeCredential) as verify_token +# builds it after a successful /v1/account read. +STRIPE_CRED = { + "api_key": "rk_test_51FakeKeyFakeKeyFakeKey", + "account_id": "acct_1AbCdEfGhIjKlMnO", + "business_name": "Acme LLC", + "livemode": False, + "key_kind": "restricted", +} + + +class TestStripeConformance(ProviderConformance): + provider = StripeProvider() + credential_fixtures = [ + STRIPE_CRED, # real post-verify shape (account id captured) + # restricted key that couldn't read /v1/account → no identity + {"api_key": "rk_test_scoped", "account_id": "", "business_name": ""}, + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_account_id(): + provider = StripeProvider() + assert provider.identity_of(STRIPE_CRED) == "acct_1abcdefghijklmno" + assert provider.identity_of({"account_id": " ACCT_X "}) == "acct_x" + assert provider.identity_of({"api_key": "sk_test_old"}) is None + assert provider.identity_of({"account_id": ""}) is None + assert provider.identity_of({"account_id": " "}) is None + assert provider.identity_of({"account_id": 123}) is None # non-str tolerated + + +def test_oauth_spec_declares_token_only(): + provider = StripeProvider() + try: + provider.oauth_spec() + except NotImplementedError: + pass + else: + raise AssertionError("stripe must declare token-only via NotImplementedError") + assert not hasattr(provider, "run_login") # no OAuth add-account flow + + +def test_binding_replaces_disk_plumbing(): + client = BoundStripeClient() + client.bind_credential(dict(STRIPE_CRED, extra_junk_key="ignored"), lambda c: None) + assert client.has_credentials() + cred = client._load() + assert cred.api_key == STRIPE_CRED["api_key"] + assert cred.account_id == STRIPE_CRED["account_id"] + assert cred.key_kind == "restricted" + + +def test_build_client_binds_credential(): + client = StripeProvider().build_client(STRIPE_CRED, lambda c: None) + assert isinstance(client, BoundStripeClient) + assert client._load().api_key == STRIPE_CRED["api_key"] + + +def test_bridge_surface_is_empty(): + provider = StripeProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_make_listener_is_none_for_legacy_client(): + async def emit(event): + pass + + provider = StripeProvider() + client = provider.build_client(STRIPE_CRED, lambda c: None) + assert not client.supports_listening + assert provider.make_listener(client, None, emit) is None + + +def test_verify_token_rejects_bad_keys(): + provider = StripeProvider() + ok, msg, cred = provider.verify_token({}) + assert not ok and cred is None + ok, msg, cred = provider.verify_token({"api_key": "pk_test_x"}) + assert not ok and "publishable" in msg and cred is None + ok, msg, cred = provider.verify_token({"api_key": "not_a_key"}) + assert not ok and cred is None + + +def test_verify_token_success_captures_account_id(monkeypatch): + def fake_request(method, url, **kwargs): + assert method == "GET" and url.endswith("/account") + assert kwargs["headers"]["Authorization"] == "Bearer sk_test_fake" + return { + "ok": True, + "result": { + "id": "acct_1XYZ", + "business_profile": {"name": "Acme LLC"}, + }, + } + + monkeypatch.setattr(stripe_mod, "http_request", fake_request) + provider = StripeProvider() + ok, msg, cred = provider.verify_token({"api_key": " sk_test_fake "}) + assert ok, msg + assert cred["api_key"] == "sk_test_fake" + assert cred["account_id"] == "acct_1XYZ" + assert cred["business_name"] == "Acme LLC" + assert cred["livemode"] is False and cred["key_kind"] == "secret" + assert provider.identity_of(cred) == "acct_1xyz" + + +def test_verify_token_restricted_key_falls_back_to_balance(monkeypatch): + calls = [] + + def fake_request(method, url, **kwargs): + calls.append(url) + if url.endswith("/account"): + return {"error": "HTTP 401", "details": "scope"} + assert url.endswith("/balance") + return {"ok": True, "result": {"available": []}} + + monkeypatch.setattr(stripe_mod, "http_request", fake_request) + ok, msg, cred = StripeProvider().verify_token({"api_key": "rk_live_scoped"}) + assert ok, msg + assert len(calls) == 2 + assert cred["account_id"] == "" # identity unknown → legacy account slot + assert cred["livemode"] is True and cred["key_kind"] == "restricted" + assert StripeProvider().identity_of(cred) is None + + +def test_verify_token_auth_failure(monkeypatch): + def fake_request(method, url, **kwargs): + return {"error": "HTTP 401", "details": "bad key"} + + monkeypatch.setattr(stripe_mod, "http_request", fake_request) + ok, msg, cred = StripeProvider().verify_token({"api_key": "sk_test_bad"}) + assert not ok and cred is None and "auth failed" in msg diff --git a/tests/integrations/test_telegram_bot_conformance.py b/tests/integrations/test_telegram_bot_conformance.py new file mode 100644 index 00000000..97f87654 --- /dev/null +++ b/tests/integrations/test_telegram_bot_conformance.py @@ -0,0 +1,238 @@ +"""Telegram Bot bridge provider — conformance + binding wiring. + +No network: getMe and the long-poll fetch are stubbed. What's real is +the binding chain bind_credential → _load → _api_url, the identity +extraction from the bot_id captured at verify time, and the legacy +getUpdates loop running end-to-end through LegacyListenerAdapter with +per-instance offset state. +""" + +from __future__ import annotations + +import asyncio + +import craftos_integrations.providers.telegram_bot.provider as telegram_mod +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.telegram_bot import TelegramBotProvider +from craftos_integrations.providers.telegram_bot.provider import ( + BoundTelegramBotClient, +) + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Realistic post-verify shape, fake values: the legacy dataclass fields +# plus the provider-level bot_id captured from getMe at verify time. +TELEGRAM_CRED = { + "bot_token": "123456789:AAFakeTokenFakeTokenFakeToken", + "bot_username": "CraftBotHelperBot", + "bot_id": "123456789", +} + +# Legacy telegram_bot.json shape — saved by the legacy handler, before +# the bridge captured a bot_id → no identity → LEGACY_IDENTITY in core. +LEGACY_CRED = { + "bot_token": "123456789:AAFakeTokenFakeTokenFakeToken", + "bot_username": "CraftBotHelperBot", +} + + +class TestTelegramBotConformance(ProviderConformance): + provider = TelegramBotProvider() + credential_fixtures = [ + TELEGRAM_CRED, + LEGACY_CRED, # identity-less shape → None + {}, # junk + ] + + +def test_identity_is_bot_id(): + provider = TelegramBotProvider() + assert provider.identity_of(TELEGRAM_CRED) == "123456789" + assert provider.identity_of({"bot_id": " 42 "}) == "42" + assert provider.identity_of({"bot_id": 987654321}) == "987654321" # int tolerated + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + assert provider.identity_of({"bot_id": ""}) is None + assert provider.identity_of({"bot_id": " "}) is None + assert provider.identity_of({"bot_id": None}) is None + assert provider.identity_of({"bot_id": True}) is None # bool junk never raises + assert provider.identity_of({}) is None + + +def test_token_only_no_oauth_no_run_login(): + provider = TelegramBotProvider() + try: + provider.oauth_spec() + raise AssertionError("oauth_spec must raise NotImplementedError") + except NotImplementedError: + pass + assert not hasattr(provider, "run_login") + + +def test_refresh_is_none_bot_tokens_do_not_rotate(): + assert run(TelegramBotProvider().refresh(dict(TELEGRAM_CRED))) is None + + +def test_bridge_surface_is_empty(): + provider = TelegramBotProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_binding_injects_credential_no_disk(): + provider = TelegramBotProvider() + client = provider.build_client( + {**TELEGRAM_CRED, "stray_key": "ignored"}, lambda c: None + ) + assert isinstance(client, BoundTelegramBotClient) + assert client.has_credentials() # answered from the injection, not disk + cred = client._load() + assert cred.bot_token == TELEGRAM_CRED["bot_token"] + assert cred.bot_username == "CraftBotHelperBot" + # bot_id is a provider-level key, filtered before the legacy dataclass + assert not hasattr(cred, "bot_id") + assert client._api_url("getMe") == ( + f"https://api.telegram.org/bot{TELEGRAM_CRED['bot_token']}/getMe" + ) + + # Unbound: no legacy fallback — the legacy has_credentials would read + # telegram_bot.json and even auto-save shared-bot env credentials. + unbound = BoundTelegramBotClient() + assert not unbound.has_credentials() + try: + unbound._load() + raise AssertionError("_load must raise before bind_credential()") + except RuntimeError: + pass + + +def test_make_listener_wraps_the_legacy_poll_loop(): + provider = TelegramBotProvider() + client = provider.build_client(dict(TELEGRAM_CRED), lambda c: None) + + async def emit(event): + pass + + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert client.supports_listening + + +def test_listener_runs_legacy_poll_loop_per_instance(monkeypatch): + """End-to-end through LegacyListenerAdapter: catch-up drain advances + the offset without emitting; the next poll batch is emitted in the + host payload shape. The offset watermark is per bound instance, so a + second concurrently-bound bot account is unaffected.""" + provider = TelegramBotProvider() + client = provider.build_client(dict(TELEGRAM_CRED), lambda c: None) + other = provider.build_client(dict(TELEGRAM_CRED), lambda c: None) + + events = [] + got_event = asyncio.Event() + + async def emit(event): + events.append(event) + got_event.set() + + stale = {"update_id": 6, "message": {"text": "old", "chat": {}, "from": {}}} + update = { + "update_id": 7, + "message": { + "message_id": 55, + "date": 1755000000, + "text": "hello bot", + "chat": {"id": 1111, "type": "private", "first_name": "Ada"}, + "from": {"id": 1111, "first_name": "Ada", "username": "ada"}, + }, + } + + async def fake_get_me(self): + return {"ok": True, "result": {"id": 123456789, "username": "CraftBotHelperBot"}} + + calls = {"n": 0} + + async def fake_poll(self): + calls["n"] += 1 + if calls["n"] == 1: # catch-up drain — consumed, never emitted + return {"result": [stale]} + if calls["n"] == 2: + return {"result": [update]} + await asyncio.sleep(3600) # park until stop() cancels the task + return {"result": []} + + monkeypatch.setattr(BoundTelegramBotClient, "get_me", fake_get_me) + monkeypatch.setattr(BoundTelegramBotClient, "_poll_updates", fake_poll) + + async def scenario(): + listener = provider.make_listener(client, None, emit) + await listener.start() + assert client.is_listening + # Double-start guard: supervisor re-invokes start() after clean cycles. + await listener.start() + await asyncio.wait_for(got_event.wait(), timeout=5) + assert listener.cursor() is None + await listener.stop() + assert not client.is_listening + + run(scenario()) + + assert len(events) == 1 + event = events[0] + assert event["integrationType"] == "telegram_bot" + assert event["messageBody"] == "hello bot" + assert event["contactId"] == "1111" + assert "Ada" in event["contactName"] + assert event["channelId"] == "1111" + assert event["messageId"] == "55" + + # Watermark advanced past the processed update — on this instance only. + assert client._poll_offset == 8 + assert other._poll_offset == 0 + + +def test_verify_token_mirrors_legacy_login(monkeypatch): + provider = TelegramBotProvider() + calls = [] + + def fake_call(url, **kwargs): + calls.append(url) + return { + "ok": True, + "result": {"id": 987654321, "username": "AcmeOpsBot", "is_bot": True}, + } + + monkeypatch.setattr(telegram_mod, "_telegram_call_sync", fake_call) + ok, message, credential = provider.verify_token({"bot_token": " 987:AAtok "}) + assert ok, message + assert "AcmeOpsBot" in message + assert calls == ["https://api.telegram.org/bot987:AAtok/getMe"] + assert credential == { + "bot_token": "987:AAtok", + "bot_username": "AcmeOpsBot", + "bot_id": "987654321", + } + assert provider.identity_of(credential) == "987654321" + + +def test_verify_token_failure_paths(monkeypatch): + provider = TelegramBotProvider() + + ok, message, credential = provider.verify_token({}) + assert not ok and credential is None + assert "BotFather" in message + + ok, message, credential = provider.verify_token({"bot_token": " "}) + assert not ok and credential is None + + monkeypatch.setattr( + telegram_mod, + "_telegram_call_sync", + lambda url, **k: {"error": "Unauthorized", "details": {"ok": False}}, + ) + ok, message, credential = provider.verify_token({"bot_token": "bad:token"}) + assert not ok and credential is None + assert "Invalid bot token" in message diff --git a/tests/integrations/test_telegram_user_conformance.py b/tests/integrations/test_telegram_user_conformance.py new file mode 100644 index 00000000..697bb03e --- /dev/null +++ b/tests/integrations/test_telegram_user_conformance.py @@ -0,0 +1,471 @@ +"""Telegram User (MTProto) bridge provider — conformance + binding wiring. + +No network and no Telethon: the async auth helpers (start_auth / +complete_auth) and the legacy listen loop are stubbed. What's real is +the binding chain bind_credential → _load, the phone-number identity +normalization, the two-phase verify_token state machine over the shared +``_pending_telegram_auth`` dict, and the LegacyListenerAdapter wiring +with per-instance listener state. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import craftos_integrations.integrations.telegram_user._telegram_mtproto as mtproto +from craftos_integrations.config import ConfigStore +from craftos_integrations.integrations.telegram_user import ( + TelegramUserHandler, + _pending_telegram_auth, +) +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.telegram_user import TelegramUserProvider +from craftos_integrations.providers.telegram_user.provider import ( + BoundTelegramUserClient, +) + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Realistic post-verify shape, fake values: the legacy dataclass fields +# plus the provider-level telegram_user_id captured at verify time. +TELEGRAM_USER_CRED = { + "session_string": "1BVtsOKcBu5FAKEfakeFAKEfakeSessionString=", + "api_id": "12345", + "api_hash": "0123456789abcdef0123456789abcdef", + "phone_number": "+923001234567", + "telegram_user_id": "111222333", +} + +# QR-login shape — no phone captured → identity falls back to the user id. +QR_CRED = { + "session_string": "1BVtsOKcBu5FAKEqrSessionString=", + "api_id": "12345", + "api_hash": "0123456789abcdef0123456789abcdef", + "phone_number": "", + "telegram_user_id": "111222333", +} + + +@pytest.fixture(autouse=True) +def _clean_pending(): + _pending_telegram_auth.clear() + yield + _pending_telegram_auth.clear() + + +@pytest.fixture() +def api_config(monkeypatch): + monkeypatch.setitem(ConfigStore._oauth, "TELEGRAM_API_ID", "12345") + monkeypatch.setitem( + ConfigStore._oauth, "TELEGRAM_API_HASH", "0123456789abcdef0123456789abcdef" + ) + + +class TestTelegramUserConformance(ProviderConformance): + provider = TelegramUserProvider() + credential_fixtures = [ + TELEGRAM_USER_CRED, + QR_CRED, # phone-less → user-id fallback + {"session_string": "x"}, # identity-less → None (LEGACY sentinel in core) + {}, # junk + ] + + +def test_identity_is_normalized_phone(): + provider = TelegramUserProvider() + # digits only, leading zeros stripped — all spellings of one number collapse + assert provider.identity_of(TELEGRAM_USER_CRED) == "923001234567" + assert provider.identity_of({"phone_number": "92 300 1234567"}) == "923001234567" + assert provider.identity_of({"phone_number": "0092-300-1234567"}) == "923001234567" + assert provider.identity_of({"phone_number": "(92) 300.123.45.67"}) == ( + "923001234567" + ) + + +def test_identity_falls_back_to_user_id_then_none(): + provider = TelegramUserProvider() + assert provider.identity_of(QR_CRED) == "111222333" + assert provider.identity_of({"telegram_user_id": 987654321}) == "987654321" + assert provider.identity_of({"telegram_user_id": " 42 "}) == "42" + assert provider.identity_of({"phone_number": "+++"}) is None # no digits, no id + assert provider.identity_of({"telegram_user_id": True}) is None # bool junk + assert provider.identity_of({"phone_number": None}) is None + assert provider.identity_of({"session_string": "x"}) is None + assert provider.identity_of({}) is None + + +def test_phone_login_no_oauth_no_run_login(): + provider = TelegramUserProvider() + with pytest.raises(NotImplementedError): + provider.oauth_spec() + assert not hasattr(provider, "run_login") + + +def test_refresh_is_none_sessions_do_not_rotate(): + assert run(TelegramUserProvider().refresh(dict(TELEGRAM_USER_CRED))) is None + + +def test_bridge_surface_is_empty(): + provider = TelegramUserProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_handler_declares_token_fields(): + """The UI contract the two-phase verify_token rides on: token auth + with phone required and code/password marked optional (the connect + flow's missing-field check keys off 'optional' in the label).""" + assert TelegramUserHandler.auth_type == "token" + fields = {f["key"]: f for f in TelegramUserHandler.fields} + assert set(fields) == {"phone_number", "code", "password"} + assert "optional" not in fields["phone_number"]["label"].lower() + assert "optional" in fields["code"]["label"].lower() + assert "optional" in fields["password"]["label"].lower() + assert fields["password"]["password"] is True + # CLI flow unchanged — both login subcommands still exposed. + subs = TelegramUserHandler().subcommands + assert "login" in subs and "login-qr" in subs + + +def test_binding_injects_credential_no_disk(): + provider = TelegramUserProvider() + client = provider.build_client( + {**TELEGRAM_USER_CRED, "stray_key": "ignored"}, lambda c: None + ) + assert isinstance(client, BoundTelegramUserClient) + assert client.has_credentials() # answered from the injection, not disk + cred = client._load() + assert cred.session_string == TELEGRAM_USER_CRED["session_string"] + assert cred.api_id == "12345" + assert cred.phone_number == "+923001234567" + # telegram_user_id is a provider-level key, filtered before the dataclass + assert not hasattr(cred, "telegram_user_id") + + # Unbound: no legacy fallback — the legacy _load would read + # telegram_user.json from disk. + unbound = BoundTelegramUserClient() + assert not unbound.has_credentials() + with pytest.raises(RuntimeError): + unbound._load() + + +def test_two_bound_clients_are_independent(): + """Per-account isolation: every piece of listener/send state is + instance-level (no module-global Telethon client or session).""" + provider = TelegramUserProvider() + a = provider.build_client(dict(TELEGRAM_USER_CRED), lambda c: None) + b = provider.build_client( + {**TELEGRAM_USER_CRED, "phone_number": "+15551234567"}, lambda c: None + ) + assert a._load() is not b._load() + assert a._agent_sent_ids is not b._agent_sent_ids + a._my_user_id = 111 + assert b._my_user_id is None + assert a._live_client is None and b._live_client is None + + +# ── verify_token — two-phase phone login ───────────────────────────── + + +def test_verify_token_requires_phone(api_config): + ok, message, credential = TelegramUserProvider().verify_token({}) + assert not ok and credential is None + assert "phone number" in message.lower() + + +def test_verify_token_requires_api_config(monkeypatch): + monkeypatch.setitem(ConfigStore._oauth, "TELEGRAM_API_ID", "") + monkeypatch.setitem(ConfigStore._oauth, "TELEGRAM_API_HASH", "") + monkeypatch.delenv("TELEGRAM_API_ID", raising=False) + monkeypatch.delenv("TELEGRAM_API_HASH", raising=False) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567"} + ) + assert not ok and credential is None + assert "TELEGRAM_API_ID" in message + + monkeypatch.setitem(ConfigStore._oauth, "TELEGRAM_API_ID", "not-a-number") + monkeypatch.setitem(ConfigStore._oauth, "TELEGRAM_API_HASH", "abc") + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567"} + ) + assert not ok and credential is None + assert "must be a number" in message + + +def test_verify_token_phase1_sends_code_and_parks_pending(api_config, monkeypatch): + calls = {} + + async def fake_start_auth(api_id, api_hash, phone_number): + calls.update(api_id=api_id, api_hash=api_hash, phone_number=phone_number) + return { + "ok": True, + "result": { + "phone_code_hash": "hash123", + "phone_number": phone_number, + "session_string": "partial-session", + "status": "code_sent", + }, + } + + monkeypatch.setattr(mtproto, "start_auth", fake_start_auth) + + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": " +923001234567 ", "code": "", "password": ""} + ) + assert not ok and credential is None # False → message surfaces in connect UI + assert "Verification code sent to +923001234567" in message + assert "submit again" in message + assert calls == { + "api_id": 12345, + "api_hash": "0123456789abcdef0123456789abcdef", + "phone_number": "+923001234567", + } + # Pending state parked in the SAME dict the CLI flow uses. + assert _pending_telegram_auth["+923001234567"] == { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + + +def test_verify_token_phase1_send_failure(api_config, monkeypatch): + async def fake_start_auth(**kwargs): + return {"error": "Too many attempts. Please wait 30 seconds."} + + monkeypatch.setattr(mtproto, "start_auth", fake_start_auth) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567"} + ) + assert not ok and credential is None + assert "Failed to send code" in message + assert "+923001234567" not in _pending_telegram_auth + + +def test_verify_token_phase2_success_builds_credential(api_config, monkeypatch): + _pending_telegram_auth["+923001234567"] = { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + seen = {} + + async def fake_complete_auth(**kwargs): + seen.update(kwargs) + return { + "ok": True, + "result": { + "session_string": "final-session-string", + "user_id": 111222333, + "first_name": "Ahmad", + "last_name": "A", + "username": "ahmad", + "phone": "923001234567", + "status": "authenticated", + }, + } + + monkeypatch.setattr(mtproto, "complete_auth", fake_complete_auth) + + provider = TelegramUserProvider() + ok, message, credential = provider.verify_token( + {"phone_number": "+923001234567", "code": "54321", "password": ""} + ) + assert ok, message + assert "Telegram user connected: Ahmad A (@ahmad)" == message + assert seen["code"] == "54321" + assert seen["phone_code_hash"] == "hash123" + assert seen["pending_session_string"] == "partial-session" + assert seen["password"] is None # empty field → no 2FA attempt + assert credential == { + "session_string": "final-session-string", + "api_id": "12345", + "api_hash": "0123456789abcdef0123456789abcdef", + "phone_number": "923001234567", + "telegram_user_id": "111222333", + } + assert provider.identity_of(credential) == "923001234567" + # Pending entry consumed. + assert "+923001234567" not in _pending_telegram_auth + + +def test_verify_token_phase2_without_pending(api_config): + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567", "code": "54321"} + ) + assert not ok and credential is None + assert "No pending login" in message + + +def test_verify_token_phase2_invalid_code_keeps_pending(api_config, monkeypatch): + _pending_telegram_auth["+923001234567"] = { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + + async def fake_complete_auth(**kwargs): + return { + "error": "Invalid verification code.", + "details": {"status": "invalid_code"}, + } + + monkeypatch.setattr(mtproto, "complete_auth", fake_complete_auth) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567", "code": "00000"} + ) + assert not ok and credential is None + assert "Invalid verification code" in message + # Retry with a corrected code must still work — pending kept. + assert "+923001234567" in _pending_telegram_auth + + +def test_verify_token_phase2_2fa_needed_keeps_pending(api_config, monkeypatch): + _pending_telegram_auth["+923001234567"] = { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + + async def fake_complete_auth(**kwargs): + return { + "error": "Two-factor authentication is enabled. Please provide password.", + "details": {"requires_2fa": True, "status": "2fa_required"}, + } + + monkeypatch.setattr(mtproto, "complete_auth", fake_complete_auth) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567", "code": "54321"} + ) + assert not ok and credential is None + assert "2FA" in message and "password" in message.lower() + assert "+923001234567" in _pending_telegram_auth + + +def test_verify_token_phase2_expired_clears_pending(api_config, monkeypatch): + _pending_telegram_auth["+923001234567"] = { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + + async def fake_complete_auth(**kwargs): + return { + "error": "Verification code has expired. Please request a new one.", + "details": {"status": "code_expired"}, + } + + monkeypatch.setattr(mtproto, "complete_auth", fake_complete_auth) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567", "code": "54321"} + ) + assert not ok and credential is None + assert "Code expired" in message + assert "+923001234567" not in _pending_telegram_auth # dead code_hash purged + + +def test_verify_token_phase2_generic_failure(api_config, monkeypatch): + _pending_telegram_auth["+923001234567"] = { + "phone_code_hash": "hash123", + "session_string": "partial-session", + } + + async def fake_complete_auth(**kwargs): + return { + "error": "Invalid 2FA password.", + "details": {"status": "invalid_password"}, + } + + monkeypatch.setattr(mtproto, "complete_auth", fake_complete_auth) + ok, message, credential = TelegramUserProvider().verify_token( + {"phone_number": "+923001234567", "code": "54321", "password": "wrong"} + ) + assert not ok and credential is None + assert "Auth failed" in message and "Invalid 2FA password" in message + + +# ── listener ───────────────────────────────────────────────────────── + + +def test_make_listener_wraps_the_legacy_telethon_loop(): + provider = TelegramUserProvider() + client = provider.build_client(dict(TELEGRAM_USER_CRED), lambda c: None) + + async def emit(event): + pass + + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert client.supports_listening + assert listener.cursor() is None + + +def test_listener_start_stop_and_payload_shape(monkeypatch): + """Adapter drives the bound client's listen loop (stubbed — real one + needs a live Telethon connection) and the legacy PlatformMessage is + converted to the host payload shape. Double-start is a no-op.""" + from craftos_integrations import PlatformMessage + + provider = TelegramUserProvider() + client = provider.build_client(dict(TELEGRAM_USER_CRED), lambda c: None) + other = provider.build_client(dict(TELEGRAM_USER_CRED), lambda c: None) + + starts = {"n": 0} + + async def fake_start_listening(self, callback): + starts["n"] += 1 + self._message_callback = callback + self._listening = True + + async def fake_stop_listening(self): + self._listening = False + self._message_callback = None + + monkeypatch.setattr( + BoundTelegramUserClient, "start_listening", fake_start_listening + ) + monkeypatch.setattr(BoundTelegramUserClient, "stop_listening", fake_stop_listening) + + events = [] + + async def emit(event): + events.append(event) + + async def scenario(): + listener = provider.make_listener(client, None, emit) + await listener.start() + assert client.is_listening + await listener.start() # double-start guard: no second spawn + assert starts["n"] == 1 + # Other account's client is untouched — per-instance state only. + assert not other.is_listening + assert other._message_callback is None + + await client._message_callback( + PlatformMessage( + platform="telegram_user", + sender_id="444555", + sender_name="Ada L", + text="hello from telegram", + channel_id="444555", + channel_name="Ada L", + message_id="9001", + raw={"is_self_message": False}, + ) + ) + await listener.stop() + assert not client.is_listening + + run(scenario()) + + assert len(events) == 1 + event = events[0] + assert event["integrationType"] == "telegram_user" + assert event["source"] == "Telegram User" + assert event["messageBody"] == "hello from telegram" + assert event["contactId"] == "444555" + assert event["contactName"] == "Ada L" + assert event["messageId"] == "9001" + assert event["is_self_message"] is False diff --git a/tests/integrations/test_twitter_conformance.py b/tests/integrations/test_twitter_conformance.py new file mode 100644 index 00000000..4ddacba8 --- /dev/null +++ b/tests/integrations/test_twitter_conformance.py @@ -0,0 +1,230 @@ +"""Twitter/X bridge provider — conformance + binding wiring. + +No network: HTTP and the legacy poll loop are stubbed. What's real is the +binding chain bind_credential → _load → _auth_header, the start_listening +user_id/username backfill routed through persist instead of the legacy +file, and the token-verification flow mirroring the legacy +TwitterHandler.login() (OAuth 1.0a-signed GET /2/users/me). +""" + +from __future__ import annotations + +import asyncio + +from craftos_integrations.integrations.twitter import TwitterClient +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.twitter import TwitterProvider +from craftos_integrations.providers.twitter.provider import BoundTwitterClient + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Real twitter.json shape after a legacy /twitter login (all four OAuth 1.0a +# values + user id/username captured from GET /2/users/me). +TWITTER_CRED = { + "api_key": "fakeConsumerKey123", + "api_secret": "fakeConsumerSecret456", + "access_token": "1234567890-fakeAccessToken", + "access_token_secret": "fakeAccessTokenSecret789", + "user_id": "1234567890123456789", + "username": "CraftBot", +} + +# Tokens saved before user id/username were captured — no identity. +LEGACY_CRED = { + "api_key": "fakeConsumerKey123", + "api_secret": "fakeConsumerSecret456", + "access_token": "1234567890-fakeAccessToken", + "access_token_secret": "fakeAccessTokenSecret789", + "user_id": "", + "username": "", +} + + +class TestTwitterConformance(ProviderConformance): + provider = TwitterProvider() + credential_fixtures = [ + TWITTER_CRED, + LEGACY_CRED, # identity-less shape → None + {}, # junk + ] + + +def test_identity_prefers_user_id_falls_back_to_username(): + provider = TwitterProvider() + # Numeric user id is the stable key (survives handle renames). + assert provider.identity_of(TWITTER_CRED) == "1234567890123456789" + assert provider.identity_of({"user_id": " 42 ", "username": "Whatever"}) == "42" + # Pre-bridge credential without a user id: username, lowercased. + assert provider.identity_of({"user_id": "", "username": " CraftBot "}) == ( + "craftbot" + ) + assert provider.identity_of(LEGACY_CRED) is None # → LEGACY_IDENTITY in core + assert provider.identity_of({"user_id": 42}) is None # junk never raises + assert provider.identity_of({"username": 42}) is None + + +def test_token_only_no_oauth_no_run_login(): + provider = TwitterProvider() + try: + provider.oauth_spec() + raise AssertionError("oauth_spec must raise NotImplementedError") + except NotImplementedError: + pass + assert not hasattr(provider, "run_login") + + +def test_refresh_is_none_oauth1_tokens_do_not_expire(): + assert run(TwitterProvider().refresh(dict(TWITTER_CRED))) is None + + +def test_bridge_surface_is_empty(): + provider = TwitterProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_binding_injects_credential_and_signs_headers(): + provider = TwitterProvider() + client = provider.build_client( + {**TWITTER_CRED, "stray_key": "ignored"}, lambda c: None + ) + assert isinstance(client, BoundTwitterClient) + assert client.has_credentials() # no disk fallback + cred = client._load() + assert cred.api_key == TWITTER_CRED["api_key"] + assert cred.access_token_secret == TWITTER_CRED["access_token_secret"] + # The OAuth 1.0a signature is built from the bound credential. + header = client._auth_header("GET", "https://api.twitter.com/2/users/me") + assert header["Authorization"].startswith("OAuth ") + assert "fakeConsumerKey123" in header["Authorization"] + + unbound = BoundTwitterClient() + assert not unbound.has_credentials() + + +def test_make_listener_wraps_the_legacy_poll_loop(): + provider = TwitterProvider() + client = provider.build_client(dict(TWITTER_CRED), lambda c: None) + + async def emit(event): + pass + + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert client.supports_listening + # Poll watermarks are instance state — two bound accounts don't collide. + other = provider.build_client(dict(TWITTER_CRED), lambda c: None) + client._since_id = "111" + assert other._since_id is None + assert client._seen_ids is not other._seen_ids + + +def test_start_listening_backfills_identity_via_persist(monkeypatch): + """The legacy save_credential at ~line 340 (user_id/username backfill) + must never fire for a bound client — the update goes through persist.""" + persisted = [] + provider = TwitterProvider() + client = provider.build_client(dict(LEGACY_CRED), persisted.append) + + async def fake_get_me(self): + return { + "ok": True, + "result": {"id": "1234567890123456789", "username": "CraftBot"}, + } + + started = [] + + async def fake_super_start(self, callback): + started.append(callback) + + monkeypatch.setattr(BoundTwitterClient, "get_me", fake_get_me) + monkeypatch.setattr(TwitterClient, "start_listening", fake_super_start) + + async def callback(msg): + pass + + run(client.start_listening(callback)) + assert started == [callback] # delegated to the legacy loop + assert persisted == [dict(LEGACY_CRED, user_id="1234567890123456789", username="CraftBot")] + assert client._load().user_id == "1234567890123456789" + assert client._load().username == "CraftBot" + + # Second start with a synced identity: no further persist. + run(client.start_listening(callback)) + assert len(persisted) == 1 + + +def test_verify_token_mirrors_legacy_login(monkeypatch): + provider = TwitterProvider() + calls = [] + + def fake_request(method, url, headers=None, params=None, expected=None, **kwargs): + calls.append((method, url, headers, params)) + return { + "ok": True, + "result": { + "data": { + "id": "1234567890123456789", + "name": "Craft Bot", + "username": "CraftBot", + } + }, + } + + monkeypatch.setattr( + "craftos_integrations.providers.twitter.provider.http_request", fake_request + ) + ok, message, credential = provider.verify_token( + { + "api_key": " fakeConsumerKey123 ", + "api_secret": "fakeConsumerSecret456", + "access_token": "1234567890-fakeAccessToken", + "access_token_secret": "fakeAccessTokenSecret789", + } + ) + assert ok + assert "@CraftBot" in message + assert credential == TWITTER_CRED # whitespace stripped, identity captured + assert provider.identity_of(credential) == "1234567890123456789" + method, url, headers, params = calls[0] + assert (method, url) == ("GET", "https://api.twitter.com/2/users/me") + assert params == {"user.fields": "id,name,username"} + # Signed with the legacy module's own OAuth 1.0a helper. + assert headers["Authorization"].startswith("OAuth ") + assert "oauth_consumer_key" in headers["Authorization"] + assert "oauth_signature=" in headers["Authorization"] + + +def test_verify_token_failure_paths(monkeypatch): + provider = TwitterProvider() + + ok, message, credential = provider.verify_token({}) + assert not ok and credential is None + assert "api_key" in message and "access_token_secret" in message + + # Partial input names only the missing keys. + ok, message, credential = provider.verify_token( + {"api_key": "k", "api_secret": "s", "access_token": "t"} + ) + assert not ok and credential is None + assert "access_token_secret" in message and " api_key" not in message + + monkeypatch.setattr( + "craftos_integrations.providers.twitter.provider.http_request", + lambda *a, **k: {"error": "HTTP 401", "details": "Unauthorized"}, + ) + ok, message, credential = provider.verify_token( + { + "api_key": "k", + "api_secret": "s", + "access_token": "t", + "access_token_secret": "ts", + } + ) + assert not ok and credential is None + assert "Twitter auth failed" in message diff --git a/tests/integrations/test_whatsapp_business_conformance.py b/tests/integrations/test_whatsapp_business_conformance.py new file mode 100644 index 00000000..01ce132d --- /dev/null +++ b/tests/integrations/test_whatsapp_business_conformance.py @@ -0,0 +1,150 @@ +"""WhatsApp Business bridge-provider conformance + binding/verify tests. + +No network: verify_token's HTTP is monkeypatched. What's real is +conformance, the credential binding, identity extraction, and the +token-verification flow mirroring the legacy +WhatsAppBusinessHandler.login(). +""" + +from __future__ import annotations + +import craftos_integrations.providers.whatsapp_business.provider as wab_mod +from craftos_integrations.providers.whatsapp_business import WhatsAppBusinessProvider +from craftos_integrations.providers.whatsapp_business.provider import ( + BoundWhatsAppBusinessClient, +) + +from .conformance import ProviderConformance + +# Realistic SHAPE, fake values — asdict(WhatsAppBusinessCredential) as +# verify_token builds it after a successful Graph GET /{phone_number_id}. +WAB_CRED = { + "access_token": "EAAFakeMetaGraphToken1234567890", + "phone_number_id": "106540352242922", + "app_secret": "", + "verify_token": "", +} + + +class TestWhatsAppBusinessConformance(ProviderConformance): + provider = WhatsAppBusinessProvider() + credential_fixtures = [ + WAB_CRED, # real post-verify shape + {"access_token": "EAAOldToken", "phone_number_id": ""}, # no identity + {}, # junk — must not raise + ] + + +def test_identity_is_lowercased_phone_number_id(): + provider = WhatsAppBusinessProvider() + assert provider.identity_of(WAB_CRED) == "106540352242922" + assert provider.identity_of({"phone_number_id": " 106540352242922 "}) == ( + "106540352242922" + ) + assert provider.identity_of({"access_token": "EAAX"}) is None + assert provider.identity_of({"phone_number_id": ""}) is None + assert provider.identity_of({"phone_number_id": " "}) is None + assert provider.identity_of({"phone_number_id": 123}) is None # non-str tolerated + + +def test_oauth_spec_declares_token_only(): + provider = WhatsAppBusinessProvider() + try: + provider.oauth_spec() + except NotImplementedError: + pass + else: + raise AssertionError( + "whatsapp_business must declare token-only via NotImplementedError" + ) + assert not hasattr(provider, "run_login") # no OAuth add-account flow + + +def test_binding_replaces_disk_plumbing(): + client = BoundWhatsAppBusinessClient() + client.bind_credential(dict(WAB_CRED, extra_junk_key="ignored"), lambda c: None) + assert client.has_credentials() + cred = client._load() + assert cred.access_token == WAB_CRED["access_token"] + assert cred.phone_number_id == WAB_CRED["phone_number_id"] + + +def test_build_client_binds_credential(): + client = WhatsAppBusinessProvider().build_client(WAB_CRED, lambda c: None) + assert isinstance(client, BoundWhatsAppBusinessClient) + assert client._load().access_token == WAB_CRED["access_token"] + # The messages URL must route to THIS account's phone number id, not disk. + assert WAB_CRED["phone_number_id"] in client._messages_url() + + +def test_bridge_surface_is_empty(): + provider = WhatsAppBusinessProvider() + assert provider.operations() == [] + assert provider.guidance() == "" + + +def test_make_listener_is_none_for_legacy_client(): + async def emit(event): + pass + + provider = WhatsAppBusinessProvider() + client = provider.build_client(WAB_CRED, lambda c: None) + assert not client.supports_listening # Cloud API is webhook-push, no poll loop + assert provider.make_listener(client, None, emit) is None + + +def test_verify_token_rejects_missing_fields(): + provider = WhatsAppBusinessProvider() + ok, msg, cred = provider.verify_token({}) + assert not ok and cred is None and "access token" in msg.lower() + ok, msg, cred = provider.verify_token({"access_token": "EAAX"}) + assert not ok and cred is None and "phone number id" in msg.lower() + ok, msg, cred = provider.verify_token({"phone_number_id": "123"}) + assert not ok and cred is None and "access token" in msg.lower() + + +def test_verify_token_success_validates_phone_id(monkeypatch): + def fake_request(method, url, **kwargs): + assert method == "GET" and url.endswith("/106540352242922") + assert kwargs["headers"]["Authorization"] == "Bearer EAAFakeToken" + return { + "ok": True, + "result": { + "id": "106540352242922", + "display_phone_number": "+1 555-0100", + "verified_name": "Acme LLC", + }, + } + + monkeypatch.setattr(wab_mod, "http_request", fake_request) + provider = WhatsAppBusinessProvider() + ok, msg, cred = provider.verify_token( + {"access_token": " EAAFakeToken ", "phone_number_id": " 106540352242922 "} + ) + assert ok, msg + assert cred["access_token"] == "EAAFakeToken" + assert cred["phone_number_id"] == "106540352242922" + assert "Acme LLC" in msg + assert provider.identity_of(cred) == "106540352242922" + + +def test_verify_token_rejects_mismatched_phone_id(monkeypatch): + def fake_request(method, url, **kwargs): + return {"ok": True, "result": {"id": "999999999999999"}} + + monkeypatch.setattr(wab_mod, "http_request", fake_request) + ok, msg, cred = WhatsAppBusinessProvider().verify_token( + {"access_token": "EAAX", "phone_number_id": "106540352242922"} + ) + assert not ok and cred is None and "mismatch" in msg.lower() + + +def test_verify_token_auth_failure(monkeypatch): + def fake_request(method, url, **kwargs): + return {"error": "HTTP 401", "details": "bad token"} + + monkeypatch.setattr(wab_mod, "http_request", fake_request) + ok, msg, cred = WhatsAppBusinessProvider().verify_token( + {"access_token": "EAAbad", "phone_number_id": "106540352242922"} + ) + assert not ok and cred is None and "Invalid credentials" in msg diff --git a/tests/integrations/test_whatsapp_web_conformance.py b/tests/integrations/test_whatsapp_web_conformance.py new file mode 100644 index 00000000..61977417 --- /dev/null +++ b/tests/integrations/test_whatsapp_web_conformance.py @@ -0,0 +1,577 @@ +"""WhatsApp Web bridge provider — conformance + multi-account plumbing. + +No Node, no Chromium: the bridge registry is exercised with tmp auth +dirs and a FakeBridge class monkeypatched over ``WhatsAppBridge``; QR +session bookkeeping runs against the same fakes. What's real is the +identity normalization, the registry (register / rekey / drop / cap / +old-layout migration), the QR-session lifecycle (uuid ids, connected +result carrying identity + credential, cancel cleanup), and the binding +chain that gives each bound client its own account's bridge. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +import craftos_integrations.integrations.whatsapp_web as wa_mod +import craftos_integrations.integrations.whatsapp_web._bridge_client as bc +from craftos_integrations.integrations.whatsapp_web import ( + WhatsAppWebCredential, + cancel_qr_session, + check_qr_session_status, + start_qr_session, +) +from craftos_integrations.integrations.whatsapp_web._bridge_client import ( + BridgeCapacityError, + normalize_wa_identity, +) +from craftos_integrations.providers._shared import LegacyListenerAdapter +from craftos_integrations.providers.whatsapp_web import ( + WhatsAppWebProvider, + teardown_account, +) +from craftos_integrations.providers.whatsapp_web.provider import ( + BoundWhatsAppWebClient, +) + +from .conformance import ProviderConformance + + +def run(coro): + return asyncio.run(coro) + + +# Realistic post-QR shape, fake values: the legacy dataclass fields plus +# the provider-level ``wid`` captured from the bridge's ready event. +WA_CRED = { + "session_id": "14155552671", + "owner_phone": "14155552671", + "owner_name": "Ada Lovelace", + "wid": "14155552671:12@c.us", +} + +# Legacy whatsapp_web.json shape — saved by the pre-multi-account flow. +# owner_phone still resolves an identity (migration lands on the right +# account, not LEGACY_IDENTITY). +LEGACY_WA_CRED = { + "session_id": "bridge", + "owner_phone": "14155552671", + "owner_name": "Ada", +} + + +class TestWhatsAppWebConformance(ProviderConformance): + provider = WhatsAppWebProvider() + credential_fixtures = [ + WA_CRED, + LEGACY_WA_CRED, + {}, # junk + ] + + +# ════════════════════════════════════════════════════════════════════════ +# Identity normalization — the ONE rule +# ════════════════════════════════════════════════════════════════════════ + + +def test_normalize_wa_identity(): + assert normalize_wa_identity("14155552671") == "14155552671" + assert normalize_wa_identity("14155552671@c.us") == "14155552671" + # wid with device suffix + assert normalize_wa_identity("14155552671:12@c.us") == "14155552671" + assert normalize_wa_identity("14155552671:3") == "14155552671" + # +country / punctuation formatting + assert normalize_wa_identity("+1 (415) 555-2671") == "14155552671" + # 00-international prefix collapses to the same identity + assert normalize_wa_identity("0014155552671") == "14155552671" + assert normalize_wa_identity(14155552671) == "14155552671" + # junk never raises + assert normalize_wa_identity(None) is None + assert normalize_wa_identity("") is None + assert normalize_wa_identity(" ") is None + assert normalize_wa_identity("no digits here") is None + assert normalize_wa_identity("000") is None + + +def test_identity_of_prefers_wid_falls_back_to_phone(): + provider = WhatsAppWebProvider() + assert provider.identity_of(WA_CRED) == "14155552671" + # wid wins when both present (WhatsApp's own id) + assert ( + provider.identity_of( + {"wid": "923001234567:2@c.us", "owner_phone": "+1 415 555 2671"} + ) + == "923001234567" + ) + # legacy credential: phone only + assert provider.identity_of(LEGACY_WA_CRED) == "14155552671" + assert provider.identity_of({"owner_phone": "+92 300 1234567"}) == "923001234567" + assert provider.identity_of({}) is None + assert provider.identity_of({"owner_phone": ""}) is None + assert provider.identity_of({"wid": "junk", "owner_phone": None}) is None + + +def test_qr_only_no_oauth_no_run_login_no_verify_token(): + provider = WhatsAppWebProvider() + with pytest.raises(NotImplementedError): + provider.oauth_spec() + assert not hasattr(provider, "run_login") + assert not hasattr(provider, "verify_token") # QR is the only connect path + assert provider.operations() == [] + assert provider.guidance() == "" + assert run(provider.refresh(dict(WA_CRED))) is None + + +# ════════════════════════════════════════════════════════════════════════ +# Bridge registry — tmp dirs, no Node +# ════════════════════════════════════════════════════════════════════════ + + +@pytest.fixture +def bridge_env(tmp_path, monkeypatch): + """Isolated registry: tmp project root, no legacy credential, clean + registry before and after.""" + monkeypatch.setattr(bc.ConfigStore, "project_root", tmp_path) + bc._reset_bridge_registry_for_tests() + wa_mod._qr_sessions.clear() + yield tmp_path + bc._reset_bridge_registry_for_tests() + wa_mod._qr_sessions.clear() + + +class FakeBridge: + """WhatsAppBridge stand-in: same lifecycle surface, zero processes.""" + + def __init__(self, auth_dir: str, legacy_guard: bool = False): + self.auth_dir = auth_dir + self._legacy_guard = legacy_guard + self._running = False + self._ready = False + self.owner_phone = "" + self.owner_name = "" + self.wid = "" + self.logged_out = False + + @property + def is_running(self): + return self._running + + @property + def is_ready(self): + return self._ready and self._running + + async def start(self): + self._running = True + Path(self.auth_dir, "session").mkdir(parents=True, exist_ok=True) + + async def wait_for_qr_or_ready(self, timeout=60.0): + return "qr", {"qr_data_url": "data:image/png;base64,QUFBQQ=="} + + async def stop(self): + self._running = False + + async def abandon(self): + self._running = False + + async def logout(self): + self._running = False + self.logged_out = True + import shutil + + shutil.rmtree(self.auth_dir, ignore_errors=True) + + +@pytest.fixture +def fake_bridges(bridge_env, monkeypatch): + """bridge_env plus WhatsAppBridge replaced by FakeBridge.""" + monkeypatch.setattr(bc, "WhatsAppBridge", FakeBridge) + return bridge_env + + +def test_registry_keys_by_normalized_identity(bridge_env): + a = bc.get_whatsapp_bridge("14155552671") + assert a is bc.get_whatsapp_bridge("14155552671") # cached + # Any spelling of the same account resolves to the same bridge. + assert a is bc.get_whatsapp_bridge("+1 (415) 555-2671") + assert a is bc.get_whatsapp_bridge("14155552671:12@c.us") + assert Path(a.auth_dir) == bridge_env / ".credentials" / "whatsapp_wwebjs_auth" / "14155552671" + + b = bc.get_whatsapp_bridge("923001234567") + assert b is not a + assert Path(b.auth_dir).name == "923001234567" + + with pytest.raises(ValueError): + bc.get_whatsapp_bridge("no digits") + + +def test_registry_peek_and_drop(bridge_env): + assert bc.peek_whatsapp_bridge("14155552671") is None + a = bc.get_whatsapp_bridge("14155552671") + assert bc.peek_whatsapp_bridge("+1 415 555 2671") is a + assert bc.drop_whatsapp_bridge("14155552671") is a + assert bc.peek_whatsapp_bridge("14155552671") is None + assert bc.drop_whatsapp_bridge("14155552671") is None # idempotent + assert bc.get_whatsapp_bridge("14155552671") is not a # fresh after drop + + +def test_legacy_no_identity_resolution_uses_default_slot(bridge_env, monkeypatch): + monkeypatch.setattr(bc, "_legacy_owner_identity", lambda: None) + bridge = bc.get_whatsapp_bridge() # legacy caller, no credential yet + assert Path(bridge.auth_dir).name == "default" + assert bridge._legacy_guard # orphan-wipe stays legacy-only + + +def test_legacy_resolution_uses_credential_identity(bridge_env, monkeypatch): + monkeypatch.setattr(bc, "_legacy_owner_identity", lambda: "14155552671") + bridge = bc.get_whatsapp_bridge() + assert Path(bridge.auth_dir).name == "14155552671" + # Same account requested by identity → same instance. + assert bc.get_whatsapp_bridge("14155552671") is bridge + + +def test_v2_bridges_have_no_legacy_guard(bridge_env): + assert not bc.get_whatsapp_bridge("14155552671")._legacy_guard + + +# ── pending → promote (rekey) ──────────────────────────────────────────── + + +def test_pending_bridge_lifecycle_and_promote(fake_bridges): + root = fake_bridges / ".credentials" / "whatsapp_wwebjs_auth" + pending = bc.create_pending_bridge("sess1") + assert bc.create_pending_bridge("sess1") is pending # stable per session + assert Path(pending.auth_dir) == root / "pending-sess1" + + run(pending.start()) + (Path(pending.auth_dir) / "session" / "creds.json").write_text("fresh") + + promoted = run(bc.promote_pending_bridge("sess1", "+1 415 555 2671")) + assert Path(promoted.auth_dir) == root / "14155552671" + assert (root / "14155552671" / "session" / "creds.json").read_text() == "fresh" + assert not (root / "pending-sess1").exists() + # Re-keyed: identity registered, session key gone, pending stopped. + assert bc.peek_whatsapp_bridge("14155552671") is promoted + assert bc._bridges.get("sess1") is None + assert not pending.is_running + assert not promoted.is_running # host starts it (LocalAuth restores) + + +def test_promote_same_account_relogin_prefers_fresh_session(fake_bridges): + root = fake_bridges / ".credentials" / "whatsapp_wwebjs_auth" + # Existing connected account with an old session on disk + live bridge. + old = bc.get_whatsapp_bridge("14155552671") + run(old.start()) + (Path(old.auth_dir) / "session" / "creds.json").write_text("stale") + + pending = bc.create_pending_bridge("sess2") + run(pending.start()) + (Path(pending.auth_dir) / "session" / "creds.json").write_text("fresh") + + promoted = run(bc.promote_pending_bridge("sess2", "14155552671")) + assert (root / "14155552671" / "session" / "creds.json").read_text() == "fresh" + assert not old.is_running # old bridge stopped and replaced + assert bc.peek_whatsapp_bridge("14155552671") is promoted + + +def test_promote_unknown_session_raises(fake_bridges): + with pytest.raises(KeyError): + run(bc.promote_pending_bridge("nope", "14155552671")) + + +def test_discard_pending_bridge_cleans_dir_and_registry(fake_bridges): + pending = bc.create_pending_bridge("sess3") + run(pending.start()) + assert Path(pending.auth_dir).exists() + run(bc.discard_pending_bridge("sess3")) + assert not Path(pending.auth_dir).exists() + assert bc._bridges.get("sess3") is None + assert not pending.is_running + run(bc.discard_pending_bridge("sess3")) # idempotent + + +# ── capacity cap ───────────────────────────────────────────────────────── + + +def test_capacity_cap_blocks_pending_beyond_max(fake_bridges, monkeypatch): + monkeypatch.setattr(bc, "max_whatsapp_accounts", lambda: 1) + bc.create_pending_bridge("sess1") + with pytest.raises(BridgeCapacityError) as excinfo: + bc.create_pending_bridge("sess2") + message = str(excinfo.value) + assert "RAM" in message and "max_accounts" in message # names the cost + the knob + + +def test_capacity_counts_identity_dirs_on_disk(fake_bridges, monkeypatch): + monkeypatch.setattr(bc, "max_whatsapp_accounts", lambda: 1) + # A connected account from a previous run: auth dir on disk, nothing + # registered in this process yet. + (fake_bridges / ".credentials" / "whatsapp_wwebjs_auth" / "14155552671").mkdir( + parents=True + ) + with pytest.raises(BridgeCapacityError): + bc.create_pending_bridge("sess1") + + +def test_max_accounts_config_default_and_clamp(bridge_env): + assert bc.max_whatsapp_accounts() == 2 # no config file → default + cfg = bridge_env / ".credentials" / "whatsapp_web_config.json" + cfg.write_text(json.dumps({"self_messages_only": False, "max_accounts": 5})) + assert bc.max_whatsapp_accounts() == 5 + cfg.write_text(json.dumps({"max_accounts": 0})) + assert bc.max_whatsapp_accounts() == 1 # clamped — 0 would brick logins + + +# ── old-layout migration ───────────────────────────────────────────────── + + +def test_old_layout_migrates_into_identity_dir(bridge_env, monkeypatch): + root = bridge_env / ".credentials" / "whatsapp_wwebjs_auth" + (root / "session").mkdir(parents=True) + (root / "session" / "creds.json").write_text("old-session") + monkeypatch.setattr(bc, "_legacy_owner_identity", lambda: "14155552671") + + bridge = bc.get_whatsapp_bridge("14155552671") # triggers migration + assert (root / "14155552671" / "session" / "creds.json").read_text() == "old-session" + assert not (root / "session").exists() + assert Path(bridge.auth_dir) == root / "14155552671" + + +def test_old_layout_without_legacy_credential_left_in_place(bridge_env, monkeypatch): + root = bridge_env / ".credentials" / "whatsapp_wwebjs_auth" + (root / "session").mkdir(parents=True) + (root / "session" / "creds.json").write_text("orphan") + monkeypatch.setattr(bc, "_legacy_owner_identity", lambda: None) + + bc.get_whatsapp_bridge("923001234567") + assert (root / "session" / "creds.json").exists() # untouched, just logged + + +def test_migration_runs_once(bridge_env, monkeypatch): + calls = [] + monkeypatch.setattr( + bc, "_legacy_owner_identity", lambda: calls.append(1) or "14155552671" + ) + root = bridge_env / ".credentials" / "whatsapp_wwebjs_auth" + (root / "session").mkdir(parents=True) + bc.get_whatsapp_bridge("14155552671") + bc.get_whatsapp_bridge("923001234567") + assert len(calls) == 1 + + +# ════════════════════════════════════════════════════════════════════════ +# QR session bookkeeping — mocked bridges +# ════════════════════════════════════════════════════════════════════════ + + +def _legacy_json(tmp_root: Path) -> Path: + return tmp_root / ".credentials" / "whatsapp_web.json" + + +def test_start_qr_session_uses_real_uuid_ids(fake_bridges): + first = run(start_qr_session()) + second = run(start_qr_session()) + for result in (first, second): + assert result["success"] and result["status"] == "qr_ready" + assert result["qr_code"].startswith("data:image/") + sid = result["session_id"] + assert sid != "bridge" and len(sid) == 32 and sid in wa_mod._qr_sessions + assert first["session_id"] != second["session_id"] + # Concurrent sessions don't collide: distinct bridges, distinct dirs. + b1 = wa_mod._qr_sessions[first["session_id"]] + b2 = wa_mod._qr_sessions[second["session_id"]] + assert b1 is not b2 and b1.auth_dir != b2.auth_dir + + +def test_start_qr_session_refused_beyond_cap(fake_bridges, monkeypatch): + monkeypatch.setattr(bc, "max_whatsapp_accounts", lambda: 1) + assert run(start_qr_session())["status"] == "qr_ready" + refused = run(start_qr_session()) + assert refused["success"] is False and refused["status"] == "error" + assert "RAM" in refused["message"] + + +def test_check_qr_session_lifecycle_returns_identity_and_credential(fake_bridges): + root = fake_bridges / ".credentials" / "whatsapp_wwebjs_auth" + started = run(start_qr_session()) + sid = started["session_id"] + + waiting = run(check_qr_session_status(sid)) + assert waiting["status"] == "qr_ready" and waiting["connected"] is False + + fake = wa_mod._qr_sessions[sid] + fake.owner_phone = "14155552671" + fake.owner_name = "Ada Lovelace" + fake.wid = "14155552671:7@c.us" + fake._ready = True + + result = run(check_qr_session_status(sid)) + assert result["success"] and result["status"] == "connected" + assert result["connected"] is True + assert result["identity"] == "14155552671" + assert result["owner_phone"] == "14155552671" + assert result["owner_name"] == "Ada Lovelace" + assert result["credential"] == { + "session_id": "14155552671", + "owner_phone": "14155552671", + "owner_name": "Ada Lovelace", + "wid": "14155552671:7@c.us", + } + # Provider identity agrees with the QR flow — one rule everywhere. + assert WhatsAppWebProvider().identity_of(result["credential"]) == result["identity"] + + # Session bookkeeping: pending gone, bridge promoted to identity. + assert sid not in wa_mod._qr_sessions + assert not (root / f"pending-{sid}").exists() + assert bc.peek_whatsapp_bridge("14155552671") is not None + + # First account mirrors into the legacy json (interim compatibility). + legacy = json.loads(_legacy_json(fake_bridges).read_text()) + assert legacy["owner_phone"] == "14155552671" + + # A finished session polls as not-found. + assert run(check_qr_session_status(sid))["status"] == "error" + + +def test_second_account_never_touches_legacy_json(fake_bridges): + _legacy_json(fake_bridges).parent.mkdir(parents=True, exist_ok=True) + _legacy_json(fake_bridges).write_text( + json.dumps( + {"session_id": "14155552671", "owner_phone": "14155552671", "owner_name": "Ada"} + ) + ) + started = run(start_qr_session()) + sid = started["session_id"] + fake = wa_mod._qr_sessions[sid] + fake.owner_phone = "923001234567" + fake.owner_name = "Bea" + fake.wid = "923001234567:1@c.us" + fake._ready = True + + result = run(check_qr_session_status(sid)) + assert result["status"] == "connected" and result["identity"] == "923001234567" + # Account #1's legacy file is untouched — no overwrite bug. + assert json.loads(_legacy_json(fake_bridges).read_text())["owner_phone"] == "14155552671" + + +def test_check_unknown_session(fake_bridges): + result = run(check_qr_session_status("does-not-exist")) + assert result["success"] is False and result["connected"] is False + + +def test_cancel_qr_session_cleans_pending_bridge_and_temp_dir(fake_bridges): + started = run(start_qr_session()) + sid = started["session_id"] + fake = wa_mod._qr_sessions[sid] + assert Path(fake.auth_dir).exists() + + cancelled = cancel_qr_session(sid) + assert cancelled["success"] + assert sid not in wa_mod._qr_sessions + assert bc._bridges.get(sid) is None + assert not fake.is_running + assert not Path(fake.auth_dir).exists() # temp dir deleted + + assert cancel_qr_session(sid)["success"] # idempotent + + +# ════════════════════════════════════════════════════════════════════════ +# teardown_account — the host's disconnect hook +# ════════════════════════════════════════════════════════════════════════ + + +def test_teardown_account_stops_bridge_and_deletes_auth_dir(fake_bridges): + bridge = bc.get_whatsapp_bridge("14155552671") + run(bridge.start()) + assert Path(bridge.auth_dir).exists() + + run(teardown_account("+1 (415) 555-2671")) # any spelling + assert bridge.logged_out # server-side logout attempted + assert not bridge.is_running + assert bc.peek_whatsapp_bridge("14155552671") is None + assert not Path(bridge.auth_dir).exists() + + run(teardown_account("14155552671")) # idempotent + run(teardown_account("not a phone")) # junk never raises + + +def test_provider_method_teardown_delegates(fake_bridges): + bridge = bc.get_whatsapp_bridge("923001234567") + run(bridge.start()) + run(WhatsAppWebProvider().teardown_account("923001234567")) + assert bc.peek_whatsapp_bridge("923001234567") is None + assert not Path(bridge.auth_dir).exists() + + +# ════════════════════════════════════════════════════════════════════════ +# Binding — per-account credential + per-account bridge +# ════════════════════════════════════════════════════════════════════════ + + +def test_binding_injects_credential_no_disk(bridge_env): + provider = WhatsAppWebProvider() + client = provider.build_client(dict(WA_CRED), lambda c: None) + assert isinstance(client, BoundWhatsAppWebClient) + assert client.has_credentials() + cred = client._load() + assert cred.owner_phone == "14155552671" + assert cred.owner_name == "Ada Lovelace" + assert not hasattr(cred, "wid") # provider-level key filtered out + assert client.owner_phone == "14155552671" # legacy property path works + + unbound = BoundWhatsAppWebClient() + assert not unbound.has_credentials() + with pytest.raises(RuntimeError): + unbound._load() + with pytest.raises(RuntimeError): + unbound._get_bridge() + + with pytest.raises(ValueError): # identity-less credential can't bind + provider.build_client({"owner_name": "who?"}, lambda c: None) + + +def test_bound_clients_get_their_own_accounts_bridge(bridge_env): + provider = WhatsAppWebProvider() + ada = provider.build_client(dict(WA_CRED), lambda c: None) + bea = provider.build_client( + {"owner_phone": "923001234567", "owner_name": "Bea", "wid": "923001234567:1@c.us"}, + lambda c: None, + ) + ada_bridge = ada._get_bridge() + bea_bridge = bea._get_bridge() + assert ada_bridge is not bea_bridge # events can never cross accounts + assert Path(ada_bridge.auth_dir).name == "14155552671" + assert Path(bea_bridge.auth_dir).name == "923001234567" + assert ada_bridge is bc.get_whatsapp_bridge("14155552671") # registry-backed + + +def test_binding_persists_owner_refresh_to_account_not_legacy_json(bridge_env): + provider = WhatsAppWebProvider() + persisted = [] + client = provider.build_client(dict(WA_CRED), persisted.append) + client._store_updated_credential( + WhatsAppWebCredential( + session_id="14155552671", + owner_phone="14155552671", + owner_name="Ada L. (renamed)", + ) + ) + assert persisted and persisted[0]["owner_name"] == "Ada L. (renamed)" + assert persisted[0]["wid"] == WA_CRED["wid"] # identity key preserved + assert client._load().owner_name == "Ada L. (renamed)" + assert not _legacy_json(bridge_env).exists() # legacy file untouched + + +def test_make_listener_wraps_the_legacy_bridge_loop(bridge_env): + provider = WhatsAppWebProvider() + client = provider.build_client(dict(WA_CRED), lambda c: None) + + async def emit(event): + pass + + listener = provider.make_listener(client, None, emit) + assert isinstance(listener, LegacyListenerAdapter) + assert client.supports_listening diff --git a/tests/integrations/test_ws_account_handlers.py b/tests/integrations/test_ws_account_handlers.py index 0232ae72..79b9ad20 100644 --- a/tests/integrations/test_ws_account_handlers.py +++ b/tests/integrations/test_ws_account_handlers.py @@ -125,22 +125,21 @@ def system(monkeypatch): # ── integration_info: v2 accounts ride TOP-LEVEL ``data.accounts`` ────────── # # CONTRACT (frontend): IntegrationsSettings' ``integration_info`` handler -# reads ``data.accounts`` (sibling of ``data.integration``) and only renders -# the AccountsManager (Add account / alias / primary / listen) when that key -# is a ManagedAccount[] — ``{identity, alias, isPrimary, listen}``. The -# legacy status-parsed ``{display, id}`` rows stay INSIDE -# ``data.integration.accounts`` and must never be replaced with v2-shaped -# objects (the legacy modal body renders ``account.display``/``account.id``). +# reads ``data.accounts`` (sibling of ``data.integration``) and renders the +# AccountsManager when that key is a ManagedAccount[] — +# ``{identity, alias, isPrimary, listen}``. Metadata comes from +# ``get_metadata`` (no ``handler.status()`` scraping anymore); ``connected`` +# and ``accounts`` inside ``data.integration`` are AccountSet-derived. A +# MISSING top-level key means the account list couldn't be loaded — the +# frontend shows a reload hint (the legacy fallback rows are gone). def test_info_carries_v2_accounts_at_top_level(system, monkeypatch): - legacy_accounts = [{"display": "legacy", "id": "legacy"}] adapter, sent = make_adapter() + import craftos_integrations + monkeypatch.setattr( - ba, - "get_integration_info", - lambda _id: {"id": _id, "connected": True, - "accounts": list(legacy_accounts)}, + craftos_integrations, "get_metadata", lambda _id: {"id": _id} ) asyncio.run(adapter._handle_integration_info("gmail")) (data,) = results_of(sent, "integration_info") @@ -150,45 +149,49 @@ def test_info_carries_v2_accounts_at_top_level(system, monkeypatch): # Every row carries exactly the ManagedAccount wire keys: for row in data["accounts"]: assert set(row) == {"identity", "alias", "isPrimary", "listen"} - # Legacy-shaped rows inside ``integration`` are left untouched: - assert data["integration"]["accounts"] == legacy_accounts + # ``integration`` mirrors the AccountSet-derived state: + assert data["integration"]["connected"] is True + assert data["integration"]["accounts"] == WIRE_TWO -def test_info_non_v2_has_no_top_level_accounts(system, monkeypatch): +def test_info_unknown_to_system_reports_disconnected(system, monkeypatch): + """A provider id the system doesn't know (can't happen for shipped + integrations, but registry lookups can fail) reports disconnected with + no top-level accounts key.""" adapter, sent = make_adapter() - legacy_accounts = [{"display": "Me", "id": "me-1"}] + import craftos_integrations + monkeypatch.setattr( - ba, - "get_integration_info", - lambda _id: {"id": _id, "connected": True, "accounts": legacy_accounts}, + craftos_integrations, "get_metadata", lambda _id: {"id": _id} ) asyncio.run(adapter._handle_integration_info("jira")) (data,) = results_of(sent, "integration_info") - # Absent top-level key → frontend keeps managedAccounts = null → legacy UI. assert "accounts" not in data - assert data["integration"]["accounts"] == legacy_accounts + assert data["integration"]["connected"] is False + assert data["integration"]["accounts"] == [] -def test_info_v2_lookup_failure_degrades_to_legacy(monkeypatch): - """get_system() blowing up must not break the payload — no top-level - accounts (legacy modal), success still True, and the failure is loud.""" +def test_info_v2_lookup_failure_shows_reload_hint(monkeypatch): + """get_system() blowing up must not break the payload — success stays + True, connected reads False, and the missing top-level accounts key + makes the frontend render its reload hint. The failure is loud in logs.""" adapter, sent = make_adapter() def boom(): raise RuntimeError("bootstrap failed") monkeypatch.setattr(integrations, "get_system", boom) - legacy_accounts = [{"display": "a@x.com", "id": "a@x.com"}] + import craftos_integrations + monkeypatch.setattr( - ba, - "get_integration_info", - lambda _id: {"id": _id, "connected": True, "accounts": legacy_accounts}, + craftos_integrations, "get_metadata", lambda _id: {"id": _id} ) asyncio.run(adapter._handle_integration_info("gmail")) (data,) = results_of(sent, "integration_info") assert data["success"] is True assert "accounts" not in data - assert data["integration"]["accounts"] == legacy_accounts + assert data["integration"]["connected"] is False + assert data["integration"]["accounts"] == [] # ── integration_accounts_add ─────────────────────────────────────────────