diff --git a/.github/workflows/auto-integrate.yml b/.github/workflows/auto-integrate.yml index 7fc462c..1e2a435 100644 --- a/.github/workflows/auto-integrate.yml +++ b/.github/workflows/auto-integrate.yml @@ -27,7 +27,13 @@ name: auto-integrate on: schedule: - - cron: '0 * * * *' + # 5-minute cadence (GitHub Actions minimum). `concurrency` block + # below ensures only one run executes at a time; overlapping + # cron triggers queue rather than parallelize. Combined with + # the self-dispatch step at the end of the workflow, this gives + # near-continuous processing when the producer queue has work, + # and 5-minute polling when it doesn't. + - cron: '*/5 * * * *' workflow_dispatch: inputs: provider: @@ -46,7 +52,7 @@ on: permissions: contents: write - actions: read + actions: write # `write` (not `read`) so the workflow can self-dispatch on success id-token: write concurrency: @@ -88,46 +94,6 @@ jobs: .venv/bin/pip install --upgrade pip .venv/bin/pip install -e ".[dev]" - - name: Diagnose producer access (TEMPORARY - remove after debug) - env: - GH_TOKEN: ${{ secrets.DRAFTS_REPO_TOKEN }} - DRAFTS_REPO: ${{ vars.DRAFTS_REPO }} - run: | - set +e - echo "::group::1. Token + user identity (gh auth status + gh api user)" - gh auth status 2>&1 - echo "---" - gh api user --jq '{login,type}' 2>&1 - USER_EXIT=$? - echo "gh api user exit: ${USER_EXIT}" - echo "::endgroup::" - - echo "::group::2. Repo accessibility via REST API" - gh api "repos/${DRAFTS_REPO}" 2>&1 | head -30 - API_EXIT=${PIPESTATUS[0]} - echo "gh api repos exit: ${API_EXIT}" - echo "::endgroup::" - - echo "::group::3. Raw git clone (bypassing gh, verbose)" - GIT_TRACE=1 GIT_CURL_VERBOSE=1 git clone \ - --depth=1 \ - "https://x-access-token:${GH_TOKEN}@github.com/${DRAFTS_REPO}.git" \ - /tmp/raw-clone-test 2>&1 | tail -60 - GIT_EXIT=${PIPESTATUS[0]} - echo "git clone exit: ${GIT_EXIT}" - rm -rf /tmp/raw-clone-test - echo "::endgroup::" - - echo "::group::4. gh repo clone (the actual failing command)" - GH_DEBUG=api gh repo clone "${DRAFTS_REPO}" /tmp/gh-clone-test -- --depth=1 2>&1 | tail -40 - GH_CLONE_EXIT=${PIPESTATUS[0]} - echo "gh repo clone exit: ${GH_CLONE_EXIT}" - rm -rf /tmp/gh-clone-test - echo "::endgroup::" - - set -e - echo "Diagnostic complete. Continuing to actual clone step (which will likely fail too)." - - name: Clone producer repo env: GH_TOKEN: ${{ secrets.DRAFTS_REPO_TOKEN }} @@ -142,14 +108,24 @@ jobs: echo "::error::DRAFTS_REPO variable not set (Settings -> Secrets and variables -> Actions -> Variables)." exit 1 fi - gh repo clone "${DRAFTS_REPO}" /tmp/drafts-clone -- --depth=1 + # Raw `git clone` with token-in-URL auth. We tried + # `gh repo clone` but it exhibited non-deterministic exit 1 + # behavior under bash -e with no stderr surfacing. Going + # direct to git is the layer gh wraps anyway. + git clone --depth=1 \ + "https://x-access-token:${GH_TOKEN}@github.com/${DRAFTS_REPO}.git" \ + /tmp/drafts-clone # Auto-detect the staging directory inside the clone. We look for # the deepest folder that contains /manifest.py files. This # avoids hardcoding the producer's internal subfolder name in # this committed YAML file. - FIRST_MANIFEST=$(find /tmp/drafts-clone -mindepth 3 -maxdepth 3 -name 'manifest.py' -type f 2>/dev/null | head -1) + # `|| true` neutralizes SIGPIPE-induced exit 141 from + # `find` when `head -1` closes the pipe early. Without + # this, `set -eo pipefail` kills the script with no + # visible error, masking a successful clone as a failure. + FIRST_MANIFEST=$(find /tmp/drafts-clone -mindepth 3 -maxdepth 3 -name 'manifest.py' -type f 2>/dev/null | head -1 || true) if [ -z "${FIRST_MANIFEST}" ]; then - FIRST_MANIFEST=$(find /tmp/drafts-clone -mindepth 2 -maxdepth 2 -name 'manifest.py' -type f 2>/dev/null | head -1) + FIRST_MANIFEST=$(find /tmp/drafts-clone -mindepth 2 -maxdepth 2 -name 'manifest.py' -type f 2>/dev/null | head -1 || true) fi if [ -z "${FIRST_MANIFEST}" ]; then echo "::error::Cloned drafts repo has no /manifest.py files at depth 2 or 3. Repo layout unrecognized." @@ -192,6 +168,14 @@ jobs: with: use_bedrock: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} + # Allow github-actions bot as trigger actor. Default + # behavior rejects bot-initiated runs as prompt-injection + # protection. Our self-dispatch chain (last step) uses + # GITHUB_TOKEN and surfaces as "github-actions" actor, + # which would otherwise fail with "non-human actor" + # error. Safe here because the only bot that can trigger + # this workflow is our own self-dispatch. + allowed_bots: 'github-actions' claude_args: --dangerously-skip-permissions show_full_output: 'true' prompt: | @@ -229,6 +213,7 @@ jobs: with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} + allowed_bots: 'github-actions' # see primary step for rationale claude_args: --dangerously-skip-permissions show_full_output: 'true' prompt: | @@ -411,3 +396,20 @@ jobs: echo "**Outcome:** Unknown - pipeline did not emit a tool name." fi } >> "${GITHUB_STEP_SUMMARY}" + + # ---- Self-dispatch chain --------------------------------------------- + # If this run integrated a tool, immediately trigger the next run to + # drain the producer queue as fast as possible. If the producer queue + # is empty (skipped) or this run failed, the chain breaks and we fall + # back to the 5-minute cron failsafe. + # + # Concurrency group (`auto-integrate`) ensures self-dispatched runs + # never overlap with cron-triggered runs — they queue serially. + - name: Self-dispatch next run (chain on success) + if: steps.run.outputs.skipped != 'true' && steps.run.outputs.failed != 'true' && steps.run.outputs.tool_name != '' && github.event.inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TOOL_NAME: ${{ steps.run.outputs.tool_name }} + run: | + echo "Integrated ${TOOL_NAME}. Chaining next run to drain queue." + gh workflow run auto-integrate.yml --ref staging diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd904c..1ca90d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,75 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `canvas` integration — 5 actions, auth: custom. Learning management + system for course, assignment, and user management via the Canvas REST + API (list_accounts, list_assignments, list_courses, + search_course_content, update_assignment). Producer-staged by + integration-drafts; consumer-side audit applied 2 patches before merge. +- `product_hunt` integration — 1 action, auth: oauth2. Discover and explore + tech products and topics via the Product Hunt GraphQL API + (list_topic_options). Producer-staged by integration-drafts; consumer-side + audit applied 1 patch before merge. +- `shopify_partner` integration — 1 action, auth: api_key. Shopify Partner + webhook verification via local HMAC-SHA256 signature validation + (verify_webhook). Producer-staged by integration-drafts; consumer-side + audit applied 1 patch before merge. +- `browserbase` integration — 3 actions, auth: api_key. Cloud browser + infrastructure for running and managing headless browser sessions via + the Browserbase REST API (create_context, create_session, list_projects). + Producer-staged by integration-drafts; consumer-side audit applied + 1 patch before merge. +- `pagerduty` integration — 4 actions, auth: oauth2. Incident management + and on-call scheduling platform via the PagerDuty REST API + (trigger_incident, acknowledge_incident, resolve_incident, + find_oncall_user). Producer-staged by integration-drafts; consumer-side + audit applied 5 patches before merge. +- `netlify` integration — 4 actions, auth: oauth2. Web hosting and + automation platform for modern web projects via the Netlify REST API + (get_site, list_files, list_site_deploys, rollback_deploy). + Producer-staged by integration-drafts; consumer-side audit applied + 4 patches before merge. +- `azure_storage` integration — 4 actions, auth: oauth2. Manage blobs and + containers in Microsoft Azure Blob Storage via the Azure Blob Storage + REST API (create_container, delete_blob, list_containers, upload_blob). + Producer-staged by integration-drafts; consumer-side audit applied + 1 patch before merge. +- `insightly` integration — 2 actions, auth: api_key. CRM and project + management platform for managing contacts and tasks via the Insightly + REST API (create_contact, create_task). Producer-staged by + integration-drafts; consumer-side audit applied 1 patch before merge. +- `reflect` integration — 5 actions, auth: oauth2. Note-taking and + knowledge management via the Reflect API (append_daily_note, create_link, + get_user, list_graph_id_options, list_links). Producer-staged by + integration-drafts; consumer-side audit applied 7 patches before merge. +- `help_scout` integration — 8 actions, auth: oauth2. Customer support + helpdesk platform with shared inboxes, knowledge base, and live chat + via the Help Scout REST API. Producer-staged by integration-drafts; + consumer-side audit applied 3 patches before merge. +- `luma` integration — 8 actions, auth: api_key. Event management platform + for creating, managing, and tracking events and guests via the Luma + public API. Producer-staged by integration-drafts; consumer-side audit + applied 2 patches before merge. +- `typeform` integration — 12 actions, auth: oauth2. Online form builder + for surveys, quizzes, and interactive forms via the Typeform REST API. + Producer-staged by integration-drafts; consumer-side audit applied + 3 patches before merge. +- `microsoft_entra_id` integration — 12 actions, auth: oauth2. Identity + and access management via Microsoft Graph API for users, groups, and + directory objects. Producer-staged by integration-drafts; consumer-side + audit applied 4 patches before merge. +- `datadog` integration — 11 actions, auth: api_key. Infrastructure + monitoring, log management, and application performance platform via + the Datadog REST API. Producer-staged by integration-drafts; + consumer-side audit applied 2 patches before merge. +- `browser_use` integration — 25 actions, auth: api_key. AI-powered cloud + browser automation via the Browser Use API. Producer-staged by + integration-drafts; consumer-side audit applied 1 patch before merge. +- `freshdesk` integration — 45 actions, auth: api_key. Customer support + helpdesk platform for managing tickets, contacts, agents, and knowledge + base articles via the Freshdesk REST API. Producer-staged by + integration-drafts; consumer-side audit applied 1 patch before merge. + - `amazon_selling_partner` integration — 8 actions, auth: oauth2. Amazon Selling Partner API for managing orders, inventory, pricing, and reports on Amazon marketplaces (check_fba_inventory_levels, diff --git a/pyproject.toml b/pyproject.toml index 82214c8..7a79893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,10 @@ Issues = "https://github.com/ModuleXAI/modulex-integrations/issues" # Entry points — each migrated tool gets one line here. [project.entry-points."modulex.tools"] aws = "modulex_integrations.tools.aws" +azure_storage = "modulex_integrations.tools.azure_storage" bloomerang = "modulex_integrations.tools.bloomerang" +browserbase = "modulex_integrations.tools.browserbase" +browser_use = "modulex_integrations.tools.browser_use" github = "modulex_integrations.tools.github" gitlab = "modulex_integrations.tools.gitlab" slack = "modulex_integrations.tools.slack" @@ -71,6 +74,7 @@ etsy = "modulex_integrations.tools.etsy" exa = "modulex_integrations.tools.exa" fal_ai = "modulex_integrations.tools.fal_ai" tavily = "modulex_integrations.tools.tavily" +insightly = "modulex_integrations.tools.insightly" instacart = "modulex_integrations.tools.instacart" tinyurl = "modulex_integrations.tools.tinyurl" customerio = "modulex_integrations.tools.customerio" @@ -84,19 +88,24 @@ crunchbase = "modulex_integrations.tools.crunchbase" dropbox = "modulex_integrations.tools.dropbox" docusign = "modulex_integrations.tools.docusign" hackernews = "modulex_integrations.tools.hackernews" +help_scout = "modulex_integrations.tools.help_scout" heroku = "modulex_integrations.tools.heroku" hootsuite = "modulex_integrations.tools.hootsuite" lemon_squeezy = "modulex_integrations.tools.lemon_squeezy" short_io = "modulex_integrations.tools.short_io" nasdaq = "modulex_integrations.tools.nasdaq" +netlify = "modulex_integrations.tools.netlify" firecrawl = "modulex_integrations.tools.firecrawl" +freshdesk = "modulex_integrations.tools.freshdesk" jina_ai = "modulex_integrations.tools.jina_ai" jira = "modulex_integrations.tools.jira" cal_com = "modulex_integrations.tools.cal_com" calendly = "modulex_integrations.tools.calendly" canva = "modulex_integrations.tools.canva" +canvas = "modulex_integrations.tools.canvas" linear = "modulex_integrations.tools.linear" linkedin = "modulex_integrations.tools.linkedin" +luma = "modulex_integrations.tools.luma" ahrefs = "modulex_integrations.tools.ahrefs" airtable = "modulex_integrations.tools.airtable" algolia = "modulex_integrations.tools.algolia" @@ -105,6 +114,7 @@ amazon_selling_partner = "modulex_integrations.tools.amazon_selling_partner" apify = "modulex_integrations.tools.apify" telegram = "modulex_integrations.tools.telegram" twilio = "modulex_integrations.tools.twilio" +typeform = "modulex_integrations.tools.typeform" servicenow = "modulex_integrations.tools.servicenow" intercom = "modulex_integrations.tools.intercom" scrape_do = "modulex_integrations.tools.scrape_do" @@ -126,6 +136,7 @@ sendgrid = "modulex_integrations.tools.sendgrid" sentry = "modulex_integrations.tools.sentry" coinbase = "modulex_integrations.tools.coinbase" databricks = "modulex_integrations.tools.databricks" +datadog = "modulex_integrations.tools.datadog" postgresql = "modulex_integrations.tools.postgresql" mysql = "modulex_integrations.tools.mysql" snowflake = "modulex_integrations.tools.snowflake" @@ -133,6 +144,7 @@ supabase = "modulex_integrations.tools.supabase" hubspot = "modulex_integrations.tools.hubspot" notion = "modulex_integrations.tools.notion" elevenlabs = "modulex_integrations.tools.elevenlabs" +reflect = "modulex_integrations.tools.reflect" salesforce = "modulex_integrations.tools.salesforce" clickup = "modulex_integrations.tools.clickup" google_drive = "modulex_integrations.tools.google_drive" @@ -153,6 +165,7 @@ medium = "modulex_integrations.tools.medium" microsoft_365_people = "modulex_integrations.tools.microsoft_365_people" microsoft_bookings = "modulex_integrations.tools.microsoft_bookings" microsoft_dynamics_365_sales = "modulex_integrations.tools.microsoft_dynamics_365_sales" +microsoft_entra_id = "modulex_integrations.tools.microsoft_entra_id" microsoft_excel = "modulex_integrations.tools.microsoft_excel" microsoft_onedrive = "modulex_integrations.tools.microsoft_onedrive" microsoft_outlook = "modulex_integrations.tools.microsoft_outlook" @@ -164,8 +177,11 @@ mixpanel = "modulex_integrations.tools.mixpanel" monday = "modulex_integrations.tools.monday" posthog = "modulex_integrations.tools.posthog" postman = "modulex_integrations.tools.postman" +product_hunt = "modulex_integrations.tools.product_hunt" okta = "modulex_integrations.tools.okta" +pagerduty = "modulex_integrations.tools.pagerduty" shopify = "modulex_integrations.tools.shopify" +shopify_partner = "modulex_integrations.tools.shopify_partner" zoom = "modulex_integrations.tools.zoom" [tool.hatch.version] @@ -499,6 +515,67 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # string literals in ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/amazon_selling_partner/manifest.py" = ["E501"] "src/modulex_integrations/tools/amazon_selling_partner/tools.py" = ["E501"] +# freshdesk manifest, tools, and tests have long description string +# literals in ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/freshdesk/manifest.py" = ["E501"] +"src/modulex_integrations/tools/freshdesk/tools.py" = ["E501"] +"src/modulex_integrations/tools/freshdesk/tests/test_freshdesk.py" = ["E501"] +# browser_use manifest, tools, and tests have long description string +# literals in ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/browser_use/manifest.py" = ["E501"] +"src/modulex_integrations/tools/browser_use/tools.py" = ["E501"] +"src/modulex_integrations/tools/browser_use/tests/test_browser_use.py" = ["E501"] +# datadog manifest, tools, and tests have long description string +# literals in ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/datadog/manifest.py" = ["E501"] +"src/modulex_integrations/tools/datadog/tools.py" = ["E501"] +"src/modulex_integrations/tools/datadog/tests/test_datadog.py" = ["E501"] +# typeform manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs and mock JSON that cannot be wrapped. +"src/modulex_integrations/tools/typeform/manifest.py" = ["E501"] +"src/modulex_integrations/tools/typeform/tools.py" = ["E501"] +"src/modulex_integrations/tools/typeform/tests/test_typeform.py" = ["E501"] +# microsoft_entra_id manifest, tools, and tests have long description string +# literals in ParameterDef / Field kwargs and credential guard lines +# that cannot be wrapped. +"src/modulex_integrations/tools/microsoft_entra_id/manifest.py" = ["E501"] +"src/modulex_integrations/tools/microsoft_entra_id/tools.py" = ["E501"] +"src/modulex_integrations/tools/microsoft_entra_id/tests/test_microsoft_entra_id.py" = ["E501"] +# luma manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped. +"src/modulex_integrations/tools/luma/manifest.py" = ["E501"] +"src/modulex_integrations/tools/luma/tools.py" = ["E501"] +"src/modulex_integrations/tools/luma/tests/test_luma.py" = ["E501"] +# help_scout manifest and tools have long description string literals in +# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped. +"src/modulex_integrations/tools/help_scout/manifest.py" = ["E501"] +"src/modulex_integrations/tools/help_scout/tools.py" = ["E501"] +# azure_storage manifest, tools, and tests have long description string +# literals in ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/azure_storage/manifest.py" = ["E501"] +"src/modulex_integrations/tools/azure_storage/tools.py" = ["E501"] +"src/modulex_integrations/tools/azure_storage/tests/test_azure_storage.py" = ["E501"] +# insightly manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/insightly/manifest.py" = ["E501"] +"src/modulex_integrations/tools/insightly/tools.py" = ["E501"] +"src/modulex_integrations/tools/insightly/tests/test_insightly.py" = ["E501"] +# pagerduty manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/pagerduty/manifest.py" = ["E501"] +"src/modulex_integrations/tools/pagerduty/tools.py" = ["E501"] +# browserbase manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/browserbase/manifest.py" = ["E501"] +"src/modulex_integrations/tools/browserbase/tools.py" = ["E501"] +# shopify_partner manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/shopify_partner/manifest.py" = ["E501"] +"src/modulex_integrations/tools/shopify_partner/tools.py" = ["E501"] +# canvas manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/canvas/manifest.py" = ["E501"] +"src/modulex_integrations/tools/canvas/tools.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/azure_storage/README.md b/src/modulex_integrations/tools/azure_storage/README.md new file mode 100644 index 0000000..d44cc9e --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/README.md @@ -0,0 +1,46 @@ +# Azure Storage + +Manage blobs and containers in Microsoft Azure Blob Storage via the Azure Blob +Storage REST API (`https://.blob.core.windows.net`). + +## Authentication + +### Microsoft OAuth2 (recommended) + +- Register an Azure AD application at the + [Azure Portal App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade). +- Add redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Grant the application **Storage Blob Data Contributor** role on your storage + account. +- Scopes requested: `https://storage.azure.com/user_impersonation`, + `offline_access` +- Required env vars: + - `AZURE_STORAGE_OAUTH2_CLIENT_ID` (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) + - `AZURE_STORAGE_OAUTH2_CLIENT_SECRET` + - `AZURE_STORAGE_ACCOUNT_NAME` (your storage account name, e.g. `mystorageaccount`) + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_container` | Create a new container under the specified storage account | `container_name` | +| `delete_blob` | Delete a specific blob from a container in Azure Storage | `container_name`, `blob_name` | +| `list_containers` | List all containers in the storage account | _(none)_ | +| `upload_blob` | Upload content from a URL to a blob in Azure Storage | `container_name`, `blob_name`, `file_url` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime +fills in from the resolved OAuth credential. The `storage_account_name` field +is read from `auth_data` to construct the per-account endpoint URL. + +## Limits & Quotas + +- Azure Storage limits vary by account type and tier; see + [Azure Storage scalability targets](https://learn.microsoft.com/en-us/azure/storage/common/scalability-targets-standard-account). +- Blob REST API requests are subject to per-account throughput limits + (up to 20,000 requests/sec for general-purpose v2 accounts). +- Error model: non-2xx responses are caught and returned as + `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/azure_storage/__init__.py b/src/modulex_integrations/tools/azure_storage/__init__.py new file mode 100644 index 0000000..784432d --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/__init__.py @@ -0,0 +1,24 @@ +"""Azure Storage integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.azure_storage.manifest import manifest +from modulex_integrations.tools.azure_storage.tools import ( + create_container, + delete_blob, + list_containers, + upload_blob, +) + +TOOLS = ( + create_container, + delete_blob, + list_containers, + upload_blob, +) + +__all__ = [ + "TOOLS", + "create_container", + "delete_blob", + "list_containers", + "manifest", + "upload_blob", +] diff --git a/src/modulex_integrations/tools/azure_storage/dependencies.toml b/src/modulex_integrations/tools/azure_storage/dependencies.toml new file mode 100644 index 0000000..b846ed1 --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the azure_storage integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/azure_storage/manifest.py b/src/modulex_integrations/tools/azure_storage/manifest.py new file mode 100644 index 0000000..b325619 --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/manifest.py @@ -0,0 +1,138 @@ +"""Azure Storage integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="azure_storage", + display_name="Azure Storage", + description="Manage blobs and containers in Microsoft Azure Blob Storage", + version="1.0.0", + author="ModuleX", + logo="modulex:azure_storage-themed", + app_url="https://azure.microsoft.com/en-us/products/storage/blobs", + categories=["Cloud Infrastructure", "Storage"], + actions=[ + ActionDefinition( + name="create_container", + description="Create a new container under the specified storage account", + parameters={ + "container_name": ParameterDef( + type="string", + description="Name of the container to create (lowercase, alphanumeric and hyphens only)", + required=True, + ), + }, + ), + ActionDefinition( + name="delete_blob", + description="Delete a specific blob from a container in Azure Storage", + parameters={ + "container_name": ParameterDef( + type="string", + description="Name of the container holding the blob", + required=True, + ), + "blob_name": ParameterDef( + type="string", + description="Name of the blob to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="list_containers", + description="List all containers in the storage account", + parameters={}, + ), + ActionDefinition( + name="upload_blob", + description="Upload content from a URL to a blob in Azure Storage", + parameters={ + "container_name": ParameterDef( + type="string", + description="Name of the target container", + required=True, + ), + "blob_name": ParameterDef( + type="string", + description="Name for the blob in the container", + required=True, + ), + "file_url": ParameterDef( + type="string", + description="Publicly accessible URL of the file to upload", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="Microsoft OAuth2", + description="Connect using Microsoft OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="AZURE_STORAGE_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Azure AD App Registration Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + ), + EnvVar( + name="AZURE_STORAGE_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Azure AD App Registration Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="x" * 40, + about_url="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + ), + EnvVar( + name="AZURE_STORAGE_ACCOUNT_NAME", + display_name="Storage Account Name", + description="Azure Storage account name (appears in the blob endpoint URL)", + required=True, + sensitive=False, + only_for_custom=False, + sample_format="mystorageaccount", + about_url="https://portal.azure.com/#view/HubsExtension/BrowseResource/resourceType/Microsoft.Storage%2FStorageAccounts", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", + scopes=["https://storage.azure.com/user_impersonation", "offline_access"], + ), + test_endpoint=TestEndpoint( + url="https://{storage_account_name}.blob.core.windows.net/?comp=list&maxresults=1", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + "x-ms-version": "2021-12-02", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Lists containers (max 1) to validate the OAuth token", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/azure_storage/outputs.py b/src/modulex_integrations/tools/azure_storage/outputs.py new file mode 100644 index 0000000..8e362da --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/outputs.py @@ -0,0 +1,38 @@ +"""Pydantic response models for the azure_storage integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateContainerOutput", + "DeleteBlobOutput", + "ListContainersOutput", + "UploadBlobOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateContainerOutput(_Base): + success: bool + error: str | None = None + + +class DeleteBlobOutput(_Base): + success: bool + error: str | None = None + + +class ListContainersOutput(_Base): + success: bool + error: str | None = None + containers: list[str] = Field(default_factory=list) + + +class UploadBlobOutput(_Base): + success: bool + error: str | None = None diff --git a/src/modulex_integrations/tools/azure_storage/tests/__init__.py b/src/modulex_integrations/tools/azure_storage/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/azure_storage/tests/test_azure_storage.py b/src/modulex_integrations/tools/azure_storage/tests/test_azure_storage.py new file mode 100644 index 0000000..011a1a9 --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/tests/test_azure_storage.py @@ -0,0 +1,130 @@ +"""Happy-path tests for every azure_storage @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.azure_storage import ( + TOOLS, + create_container, + delete_blob, + list_containers, + manifest, + upload_blob, +) +from modulex_integrations.tools.azure_storage.outputs import ( + CreateContainerOutput, + DeleteBlobOutput, + ListContainersOutput, + UploadBlobOutput, +) + +_ACCOUNT = "teststorage" +_BASE = f"https://{_ACCOUNT}.blob.core.windows.net" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token", "storage_account_name": _ACCOUNT}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_4_actions(self) -> None: + assert len(manifest.actions) == 4 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_container(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{_BASE}/mycontainer?restype=container", + status_code=201, + ) + + result_dict = await create_container.ainvoke(_args(container_name="mycontainer")) + + assert isinstance(result_dict, dict) + result = CreateContainerOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_delete_blob(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{_BASE}/mycontainer/myblob.txt", + status_code=202, + ) + + result_dict = await delete_blob.ainvoke(_args(container_name="mycontainer", blob_name="myblob.txt")) + + assert isinstance(result_dict, dict) + result = DeleteBlobOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_containers(httpx_mock): # type: ignore[no-untyped-def] + xml_body = ( + '' + "" + "" + "container1" + "container2" + "" + "" + ) + httpx_mock.add_response( + method="GET", + url=f"{_BASE}?comp=list", + text=xml_body, + status_code=200, + ) + + result_dict = await list_containers.ainvoke(_AUTH) + + assert isinstance(result_dict, dict) + result = ListContainersOutput.model_validate(result_dict) + assert result.success is True + assert result.containers == ["container1", "container2"] + + +@pytest.mark.asyncio +async def test_upload_blob(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://example.com/file.txt", + content=b"hello world", + status_code=200, + ) + httpx_mock.add_response( + method="PUT", + url=f"{_BASE}/mycontainer/file.txt", + status_code=201, + ) + + result_dict = await upload_blob.ainvoke( + _args(container_name="mycontainer", blob_name="file.txt", file_url="https://example.com/file.txt") + ) + + assert isinstance(result_dict, dict) + result = UploadBlobOutput.model_validate(result_dict) + assert result.success is True diff --git a/src/modulex_integrations/tools/azure_storage/tools.py b/src/modulex_integrations/tools/azure_storage/tools.py new file mode 100644 index 0000000..e557eaa --- /dev/null +++ b/src/modulex_integrations/tools/azure_storage/tools.py @@ -0,0 +1,212 @@ +"""Azure Storage LangChain @tool functions.""" +from __future__ import annotations + +import mimetypes +from typing import Any +from xml.etree import ElementTree + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.azure_storage.outputs import ( + CreateContainerOutput, + DeleteBlobOutput, + ListContainersOutput, + UploadBlobOutput, +) + +__all__ = [ + "create_container", + "delete_blob", + "list_containers", + "upload_blob", +] + +_API_VERSION = "2021-12-02" + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for Azure Blob Storage REST API.""" + headers: dict[str, str] = { + "x-ms-version": _API_VERSION, + } + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +def _base_url(auth_data: dict[str, Any]) -> str: + """Construct the dynamic base URL from the storage account name in auth_data.""" + account = auth_data.get("storage_account_name", "") + return f"https://{account}.blob.core.windows.net" + + +# --- Input schemas -------------------------------------------------------- + + +class CreateContainerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + container_name: str = Field(description="Name of the container to create (lowercase, alphanumeric and hyphens only)") + + +class DeleteBlobInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + container_name: str = Field(description="Name of the container holding the blob") + blob_name: str = Field(description="Name of the blob to delete") + + +class ListContainersInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class UploadBlobInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + container_name: str = Field(description="Name of the target container") + blob_name: str = Field(description="Name for the blob in the container") + file_url: str = Field(description="Publicly accessible URL of the file to upload") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateContainerInput) +@serialize_pydantic_return +async def create_container( + auth_type: str, + auth_data: dict[str, Any], + container_name: str, +) -> CreateContainerOutput: + """Create a new container under the specified storage account.""" + headers = _get_auth_headers(auth_type, auth_data) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{base}/{container_name}", + headers=headers, + params={"restype": "container"}, + ) + if response.status_code not in (201, 204): + return CreateContainerOutput( + success=False, + error=f"Azure API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return CreateContainerOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateContainerOutput(success=False, error=f"Call failed: {exc}") + return CreateContainerOutput(success=True) + + +@tool(args_schema=DeleteBlobInput) +@serialize_pydantic_return +async def delete_blob( + auth_type: str, + auth_data: dict[str, Any], + container_name: str, + blob_name: str, +) -> DeleteBlobOutput: + """Delete a specific blob from a container in Azure Storage.""" + headers = _get_auth_headers(auth_type, auth_data) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.delete( + f"{base}/{container_name}/{blob_name}", + headers=headers, + ) + if response.status_code not in (200, 202, 204): + return DeleteBlobOutput( + success=False, + error=f"Azure API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteBlobOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteBlobOutput(success=False, error=f"Call failed: {exc}") + return DeleteBlobOutput(success=True) + + +@tool(args_schema=ListContainersInput) +@serialize_pydantic_return +async def list_containers( + auth_type: str, + auth_data: dict[str, Any], +) -> ListContainersOutput: + """List all containers in the storage account.""" + headers = _get_auth_headers(auth_type, auth_data) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + base, + headers=headers, + params={"comp": "list"}, + ) + if response.status_code != 200: + return ListContainersOutput( + success=False, + error=f"Azure API error ({response.status_code}): {response.text}", + ) + root = ElementTree.fromstring(response.text) + containers = [ + elem.text + for elem in root.iter("Name") + if elem.text is not None + ] + except httpx.TimeoutException: + return ListContainersOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListContainersOutput(success=False, error=f"Call failed: {exc}") + return ListContainersOutput(success=True, containers=containers) + + +@tool(args_schema=UploadBlobInput) +@serialize_pydantic_return +async def upload_blob( + auth_type: str, + auth_data: dict[str, Any], + container_name: str, + blob_name: str, + file_url: str, +) -> UploadBlobOutput: + """Upload content from a URL to a blob in Azure Storage.""" + headers = _get_auth_headers(auth_type, auth_data) + base = _base_url(auth_data) + content_type, _ = mimetypes.guess_type(blob_name) + if not content_type: + content_type = "application/octet-stream" + try: + async with httpx.AsyncClient(timeout=60.0) as client: + download = await client.get(file_url) + download.raise_for_status() + blob_content = download.content + + upload_headers = { + **headers, + "x-ms-blob-type": "BlockBlob", + "Content-Type": content_type, + } + response = await client.put( + f"{base}/{container_name}/{blob_name}", + headers=upload_headers, + content=blob_content, + ) + if response.status_code not in (200, 201): + return UploadBlobOutput( + success=False, + error=f"Azure API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UploadBlobOutput(success=False, error="Request timed out.") + except Exception as exc: + return UploadBlobOutput(success=False, error=f"Call failed: {exc}") + return UploadBlobOutput(success=True) diff --git a/src/modulex_integrations/tools/browser_use/README.md b/src/modulex_integrations/tools/browser_use/README.md new file mode 100644 index 0000000..a42a09a --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/README.md @@ -0,0 +1,61 @@ +# Browser Use + +AI-powered cloud browser automation via the Browser Use REST API +(`api.browser-use.com/api/v3`). Create agent sessions to perform web +tasks, manage standalone browser sessions via CDP, and organize +persistent profiles and workspaces. + +## Authentication + +### API Key (recommended) + +- Sign in at , navigate to your project + settings or API Keys section. +- Create or copy your API key. +- Required env var: `BROWSER_USE_API_KEY` (format: `bu_xxxxxxxxxxxxxxxxxxxxxxxxxxxx`). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_session` | Create an agent session, dispatch a task, or dispatch a follow-up task to an existing idle session | — | +| `get_session` | Get the current state, output, live URL, screenshot URL, and cost details for an agent session | `session_id` | +| `list_sessions` | List Browser Use agent sessions for the authenticated project | — | +| `delete_session` | Delete an agent session | `session_id` | +| `stop_session` | Stop the current task or stop the entire Browser Use agent session | `session_id` | +| `list_session_messages` | List messages from a Browser Use agent session | `session_id` | +| `create_browser_session` | Create a standalone browser session for direct browser control through CDP | — | +| `get_browser_session` | Get details for a standalone browser session | `browser_session_id` | +| `list_browser_sessions` | List standalone browser sessions for direct browser control via CDP | — | +| `update_browser_session` | Update a standalone browser session (currently supports stop) | `browser_session_id`, `action` | +| `create_profile` | Create a profile to preserve cookies, local storage, and login state | — | +| `get_profile` | Get a Browser Use profile by ID | `profile_id` | +| `list_profiles` | List Browser Use profiles | — | +| `delete_profile` | Delete a Browser Use profile and its persisted browser state | `profile_id` | +| `update_profile` | Update a Browser Use profile name or user ID | `profile_id` | +| `create_workspace` | Create a workspace for persistent shared file storage | — | +| `get_workspace` | Get a Browser Use workspace by ID | `workspace_id` | +| `list_workspaces` | List Browser Use workspaces | — | +| `delete_workspace` | Delete a workspace and its stored files (irreversible) | `workspace_id` | +| `update_workspace` | Update a Browser Use workspace name | `workspace_id`, `name` | +| `get_workspace_size` | Get storage usage for a workspace | `workspace_id` | +| `list_workspace_files` | List files and folders in a workspace | `workspace_id` | +| `delete_workspace_file` | Delete a file from a workspace | `workspace_id`, `path` | +| `upload_workspace_files` | Create presigned upload URLs for workspace files | `workspace_id`, `files_json` | +| `get_account_billing` | Get account billing details for the authenticated project | — | + +Every tool takes an additional `api_key` parameter that the runtime +fills in from the resolved credential. + +## Limits & Quotas + +- No publicly documented request rate limits at the time of writing. +- Agent sessions are billed by runtime and model usage; `max_cost_usd` + parameter available to cap spend per session. +- Browser sessions are billed by runtime and can run up to 4 hours. +- Error model: non-2xx responses and timeouts are caught and returned + as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/browser_use/__init__.py b/src/modulex_integrations/tools/browser_use/__init__.py new file mode 100644 index 0000000..628ea43 --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/__init__.py @@ -0,0 +1,87 @@ +"""Browser Use integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.browser_use.manifest import manifest +from modulex_integrations.tools.browser_use.tools import ( + create_browser_session, + create_profile, + create_session, + create_workspace, + delete_profile, + delete_session, + delete_workspace, + delete_workspace_file, + get_account_billing, + get_browser_session, + get_profile, + get_session, + get_workspace, + get_workspace_size, + list_browser_sessions, + list_profiles, + list_session_messages, + list_sessions, + list_workspace_files, + list_workspaces, + stop_session, + update_browser_session, + update_profile, + update_workspace, + upload_workspace_files, +) + +TOOLS = ( + create_session, + get_session, + list_sessions, + delete_session, + stop_session, + list_session_messages, + create_browser_session, + get_browser_session, + list_browser_sessions, + update_browser_session, + create_profile, + get_profile, + list_profiles, + delete_profile, + update_profile, + create_workspace, + get_workspace, + list_workspaces, + delete_workspace, + update_workspace, + get_workspace_size, + list_workspace_files, + delete_workspace_file, + upload_workspace_files, + get_account_billing, +) + +__all__ = [ + "TOOLS", + "create_browser_session", + "create_profile", + "create_session", + "create_workspace", + "delete_profile", + "delete_session", + "delete_workspace", + "delete_workspace_file", + "get_account_billing", + "get_browser_session", + "get_profile", + "get_session", + "get_workspace", + "get_workspace_size", + "list_browser_sessions", + "list_profiles", + "list_session_messages", + "list_sessions", + "list_workspace_files", + "list_workspaces", + "manifest", + "stop_session", + "update_browser_session", + "update_profile", + "update_workspace", + "upload_workspace_files", +] diff --git a/src/modulex_integrations/tools/browser_use/dependencies.toml b/src/modulex_integrations/tools/browser_use/dependencies.toml new file mode 100644 index 0000000..deb07cf --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the browser_use integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/browser_use/manifest.py b/src/modulex_integrations/tools/browser_use/manifest.py new file mode 100644 index 0000000..b40bca8 --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/manifest.py @@ -0,0 +1,538 @@ +"""Browser Use integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="browser_use", + display_name="Browser Use", + logo="modulex:browser_use-themed", + description="AI-powered cloud browser automation via the Browser Use API", + version="1.0.0", + author="ModuleX", + app_url="https://browser-use.com", + categories=["Automation", "AI", "Developer Tools & Infrastructure"], + actions=[ + ActionDefinition( + name="create_session", + description="Create an agent session, dispatch a task, or dispatch a follow-up task to an existing idle session", + parameters={ + "task": ParameterDef( + type="string", + description="Natural-language instruction for the agent", + ), + "model": ParameterDef( + type="string", + description="Browser Use agent model. Allowed values: claude-sonnet-4.6, claude-opus-4.6, gemini-3-flash, bu-mini, bu-max, bu-ultra", + default="claude-sonnet-4.6", + ), + "session_id": ParameterDef( + type="string", + description="ID of an existing session to dispatch a follow-up task to", + ), + "keep_alive": ParameterDef( + type="boolean", + description="If true, the session stays idle after the task completes so it can accept follow-up tasks", + default=False, + ), + "max_cost_usd": ParameterDef( + type="string", + description="Maximum total session cost in USD. Example: 1.50", + ), + "profile_id": ParameterDef( + type="string", + description="ID of a Browser Use profile to use", + ), + "workspace_id": ParameterDef( + type="string", + description="ID of a Browser Use workspace to attach", + ), + "proxy_country_code": ParameterDef( + type="string", + description="Lowercase proxy country code for browser traffic. Examples: us, de, jp. Enter none to disable proxy", + default="us", + ), + "output_schema": ParameterDef( + type="object", + description="Optional JSON Schema for structured output", + ), + "enable_scheduled_tasks": ParameterDef( + type="boolean", + description="If true, the agent can create scheduled tasks tied to your project", + default=False, + ), + "sensitive_data": ParameterDef( + type="object", + description="Key-value pairs available to the agent through secure placeholders. Keys are visible to the model; values are hidden", + ), + "enable_recording": ParameterDef( + type="boolean", + description="If true, Browser Use records the browser session and returns recording URLs after completion", + default=False, + ), + "skills": ParameterDef( + type="boolean", + description="If true, enables built-in Browser Use agent skills such as file management", + default=True, + ), + "agentmail": ParameterDef( + type="boolean", + description="If true, provisions a temporary email inbox for the session", + default=True, + ), + "cache_script": ParameterDef( + type="string", + description="Controls deterministic script caching. Allowed values: auto, enabled, disabled", + default="auto", + ), + "use_own_key": ParameterDef( + type="boolean", + description="If true, uses your configured LLM provider key instead of Browser Use managed keys", + default=False, + ), + "auto_heal": ParameterDef( + type="boolean", + description="When script caching is active, validates cached script output and reruns the full agent if the result looks incorrect", + default=True, + ), + }, + ), + ActionDefinition( + name="get_session", + description="Get the current state, output, live URL, screenshot URL, and cost details for an agent session", + parameters={ + "session_id": ParameterDef( + type="string", + description="ID of the Browser Use agent session", + required=True, + ), + }, + ), + ActionDefinition( + name="list_sessions", + description="List Browser Use agent sessions for the authenticated project", + parameters={ + "page_number": ParameterDef( + type="integer", + description="Page number to fetch. The first page is 1", + default=1, + ), + "page_size": ParameterDef( + type="integer", + description="Number of records to return per page. Maximum: 100", + default=20, + ), + }, + ), + ActionDefinition( + name="delete_session", + description="Delete an agent session", + parameters={ + "session_id": ParameterDef( + type="string", + description="ID of the Browser Use agent session to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="stop_session", + description="Stop the current task or stop the entire Browser Use agent session", + parameters={ + "session_id": ParameterDef( + type="string", + description="ID of the Browser Use agent session", + required=True, + ), + "strategy": ParameterDef( + type="string", + description="Use task to stop only the current task and keep the session alive, or session to destroy the sandbox entirely. Allowed values: task, session", + default="session", + ), + }, + ), + ActionDefinition( + name="list_session_messages", + description="List messages from a Browser Use agent session, including reasoning, tool calls, browser actions, screenshots, and results", + parameters={ + "session_id": ParameterDef( + type="string", + description="ID of the Browser Use agent session", + required=True, + ), + "after": ParameterDef( + type="string", + description="Return messages after this message ID cursor", + ), + "before": ParameterDef( + type="string", + description="Return messages before this message ID cursor", + ), + "limit": ParameterDef( + type="integer", + description="Maximum number of messages to return. Maximum: 100", + default=10, + ), + }, + ), + ActionDefinition( + name="create_browser_session", + description="Create a standalone browser session for direct browser control through CDP", + parameters={ + "profile_id": ParameterDef( + type="string", + description="ID of a Browser Use profile", + ), + "proxy_country_code": ParameterDef( + type="string", + description="Lowercase proxy country code for browser traffic. Examples: us, de, jp. Enter none to disable proxy", + default="us", + ), + "timeout": ParameterDef( + type="integer", + description="Session timeout in minutes. Supported range: 1 to 240", + default=60, + ), + "browser_screen_width": ParameterDef( + type="integer", + description="Custom browser screen width in pixels. Supported range: 320 to 6144", + ), + "browser_screen_height": ParameterDef( + type="integer", + description="Custom browser screen height in pixels. Supported range: 320 to 3456", + ), + "allow_resizing": ParameterDef( + type="boolean", + description="Whether to allow browser resizing during the session", + default=False, + ), + "custom_proxy": ParameterDef( + type="object", + description="Custom proxy object with host, port, username, and password fields. Requires an active subscription", + ), + "enable_recording": ParameterDef( + type="boolean", + description="If true, records the browser session", + default=False, + ), + }, + ), + ActionDefinition( + name="get_browser_session", + description="Get details for a standalone browser session, including live URL, CDP URL, status, timeout, and cost fields", + parameters={ + "browser_session_id": ParameterDef( + type="string", + description="ID of the Browser Use browser session", + required=True, + ), + }, + ), + ActionDefinition( + name="list_browser_sessions", + description="List standalone browser sessions for direct browser control via CDP", + parameters={ + "page_size": ParameterDef( + type="integer", + description="Number of records to return per page. Maximum: 100", + default=20, + ), + "page_number": ParameterDef( + type="integer", + description="Page number to fetch. The first page is 1", + default=1, + ), + "filter_by": ParameterDef( + type="string", + description="Filter browser sessions by status. Allowed values: active, stopped", + ), + }, + ), + ActionDefinition( + name="update_browser_session", + description="Update a standalone browser session. Currently supports the stop action", + parameters={ + "browser_session_id": ParameterDef( + type="string", + description="ID of the Browser Use browser session", + required=True, + ), + "action": ParameterDef( + type="string", + description="Action to perform on the browser session. Currently supported value: stop", + required=True, + default="stop", + ), + }, + ), + ActionDefinition( + name="create_profile", + description="Create a profile to preserve cookies, local storage, and login state across sessions", + parameters={ + "name": ParameterDef( + type="string", + description="Optional profile name. Maximum length: 100 characters", + ), + "user_id": ParameterDef( + type="string", + description="Optional internal user identifier from your system. Maximum length: 255 characters", + ), + }, + ), + ActionDefinition( + name="get_profile", + description="Get a Browser Use profile by ID", + parameters={ + "profile_id": ParameterDef( + type="string", + description="ID of the Browser Use profile", + required=True, + ), + }, + ), + ActionDefinition( + name="list_profiles", + description="List Browser Use profiles, optionally searching by profile name or user ID", + parameters={ + "page_size": ParameterDef( + type="integer", + description="Number of records to return per page. Maximum: 100", + default=20, + ), + "page_number": ParameterDef( + type="integer", + description="Page number to fetch. The first page is 1", + default=1, + ), + "query": ParameterDef( + type="string", + description="Search query for profile name or user ID. Maximum length: 200 characters", + ), + }, + ), + ActionDefinition( + name="delete_profile", + description="Delete a Browser Use profile and its persisted browser state", + parameters={ + "profile_id": ParameterDef( + type="string", + description="ID of the Browser Use profile to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="update_profile", + description="Update a Browser Use profile name or user ID", + parameters={ + "profile_id": ParameterDef( + type="string", + description="ID of the Browser Use profile", + required=True, + ), + "name": ParameterDef( + type="string", + description="Updated profile name. Maximum length: 100 characters", + ), + "user_id": ParameterDef( + type="string", + description="Updated internal user identifier. Maximum length: 255 characters", + ), + }, + ), + ActionDefinition( + name="create_workspace", + description="Create a workspace for persistent shared file storage across sessions", + parameters={ + "name": ParameterDef( + type="string", + description="Optional workspace name. Maximum length: 100 characters", + ), + }, + ), + ActionDefinition( + name="get_workspace", + description="Get a Browser Use workspace by ID", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + }, + ), + ActionDefinition( + name="list_workspaces", + description="List Browser Use workspaces for persistent shared file storage across sessions", + parameters={ + "page_size": ParameterDef( + type="integer", + description="Number of records to return per page. Maximum: 100", + default=20, + ), + "page_number": ParameterDef( + type="integer", + description="Page number to fetch. The first page is 1", + default=1, + ), + }, + ), + ActionDefinition( + name="delete_workspace", + description="Delete a Browser Use workspace and its stored files. This cannot be undone", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="update_workspace", + description="Update a Browser Use workspace name", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + "name": ParameterDef( + type="string", + description="Updated workspace name. Maximum length: 100 characters", + required=True, + ), + }, + ), + ActionDefinition( + name="get_workspace_size", + description="Get storage usage for a Browser Use workspace", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + }, + ), + ActionDefinition( + name="list_workspace_files", + description="List files and folders in a Browser Use workspace, optionally returning presigned download URLs", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + "prefix": ParameterDef( + type="string", + description="Optional directory prefix to list. Example: reports/", + ), + "limit": ParameterDef( + type="integer", + description="Maximum number of files to return. Maximum: 100", + default=50, + ), + "cursor": ParameterDef( + type="string", + description="Pagination cursor from a previous response", + ), + "include_urls": ParameterDef( + type="boolean", + description="If true, include presigned download URLs for files", + default=False, + ), + "shallow": ParameterDef( + type="boolean", + description="If true, list only immediate files and folders at the prefix", + default=False, + ), + }, + ), + ActionDefinition( + name="delete_workspace_file", + description="Delete a file from a Browser Use workspace", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + "path": ParameterDef( + type="string", + description="Relative workspace file path to delete. Example: reports/data.csv", + required=True, + ), + }, + ), + ActionDefinition( + name="upload_workspace_files", + description="Create presigned upload URLs for workspace files", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="ID of the Browser Use workspace", + required=True, + ), + "prefix": ParameterDef( + type="string", + description="Optional directory prefix to upload into. Example: uploads/", + ), + "files_json": ParameterDef( + type="string", + description="JSON array of file metadata objects. Each object has name (required), contentType (optional), and size (optional integer). 1 to 10 files per request", + required=True, + ), + }, + ), + ActionDefinition( + name="get_account_billing", + description="Get account billing details for the authenticated project", + parameters={}, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Browser Use API key", + setup_instructions=[ + "Go to https://cloud.browser-use.com and sign in", + "Navigate to your project settings or API Keys section", + "Create a new API key or copy your existing one", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="BROWSER_USE_API_KEY", + display_name="Browser Use API Key", + description="Your Browser Use API key from cloud.browser-use.com", + required=True, + sensitive=True, + sample_format="bu_xxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://cloud.browser-use.com", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.browser-use.com/api/v3/billing/account", + method="GET", + headers={"X-Browser-Use-API-Key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Validates the API key by fetching account billing details", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/browser_use/outputs.py b/src/modulex_integrations/tools/browser_use/outputs.py new file mode 100644 index 0000000..fed7edf --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/outputs.py @@ -0,0 +1,227 @@ +"""Pydantic response models for the browser_use integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateBrowserSessionOutput", + "CreateProfileOutput", + "CreateSessionOutput", + "CreateWorkspaceOutput", + "DeleteProfileOutput", + "DeleteSessionOutput", + "DeleteWorkspaceFileOutput", + "DeleteWorkspaceOutput", + "GetAccountBillingOutput", + "GetBrowserSessionOutput", + "GetProfileOutput", + "GetSessionOutput", + "GetWorkspaceOutput", + "GetWorkspaceSizeOutput", + "ListBrowserSessionsOutput", + "ListProfilesOutput", + "ListSessionMessagesOutput", + "ListSessionsOutput", + "ListWorkspaceFilesOutput", + "ListWorkspacesOutput", + "StopSessionOutput", + "UpdateBrowserSessionOutput", + "UpdateProfileOutput", + "UpdateWorkspaceOutput", + "UploadWorkspaceFilesOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateSessionOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + status: str | None = None + task: str | None = None + live_url: str | None = None + data: dict[str, Any] | None = None + + +class GetSessionOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + status: str | None = None + task: str | None = None + output: str | None = None + live_url: str | None = None + screenshot_url: str | None = None + cost: float | None = None + data: dict[str, Any] | None = None + + +class ListSessionsOutput(_Base): + success: bool + error: str | None = None + sessions: list[dict[str, Any]] = Field(default_factory=list) + total: int | None = None + + +class DeleteSessionOutput(_Base): + success: bool + error: str | None = None + + +class StopSessionOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ListSessionMessagesOutput(_Base): + success: bool + error: str | None = None + messages: list[dict[str, Any]] = Field(default_factory=list) + + +class CreateBrowserSessionOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + status: str | None = None + live_url: str | None = None + cdp_url: str | None = None + data: dict[str, Any] | None = None + + +class GetBrowserSessionOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + status: str | None = None + live_url: str | None = None + cdp_url: str | None = None + timeout: int | None = None + cost: float | None = None + data: dict[str, Any] | None = None + + +class ListBrowserSessionsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + total_items: int | None = None + + +class UpdateBrowserSessionOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateProfileOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + data: dict[str, Any] | None = None + + +class GetProfileOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + user_id: str | None = None + data: dict[str, Any] | None = None + + +class ListProfilesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + total_items: int | None = None + + +class DeleteProfileOutput(_Base): + success: bool + error: str | None = None + + +class UpdateProfileOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + user_id: str | None = None + data: dict[str, Any] | None = None + + +class CreateWorkspaceOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + data: dict[str, Any] | None = None + + +class GetWorkspaceOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + data: dict[str, Any] | None = None + + +class ListWorkspacesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + total_items: int | None = None + + +class DeleteWorkspaceOutput(_Base): + success: bool + error: str | None = None + + +class UpdateWorkspaceOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + data: dict[str, Any] | None = None + + +class GetWorkspaceSizeOutput(_Base): + success: bool + error: str | None = None + size_bytes: int | None = None + data: dict[str, Any] | None = None + + +class ListWorkspaceFilesOutput(_Base): + success: bool + error: str | None = None + files: list[dict[str, Any]] = Field(default_factory=list) + cursor: str | None = None + + +class DeleteWorkspaceFileOutput(_Base): + success: bool + error: str | None = None + + +class UploadWorkspaceFilesOutput(_Base): + success: bool + error: str | None = None + files: list[dict[str, Any]] = Field(default_factory=list) + + +class GetAccountBillingOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None diff --git a/src/modulex_integrations/tools/browser_use/tests/__init__.py b/src/modulex_integrations/tools/browser_use/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/browser_use/tests/test_browser_use.py b/src/modulex_integrations/tools/browser_use/tests/test_browser_use.py new file mode 100644 index 0000000..22bca3d --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/tests/test_browser_use.py @@ -0,0 +1,582 @@ +"""Happy-path tests for every browser_use @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.browser_use import ( + TOOLS, + create_browser_session, + create_profile, + create_session, + create_workspace, + delete_profile, + delete_session, + delete_workspace, + delete_workspace_file, + get_account_billing, + get_browser_session, + get_profile, + get_session, + get_workspace, + get_workspace_size, + list_browser_sessions, + list_profiles, + list_session_messages, + list_sessions, + list_workspace_files, + list_workspaces, + manifest, + stop_session, + update_browser_session, + update_profile, + update_workspace, + upload_workspace_files, +) +from modulex_integrations.tools.browser_use.outputs import ( + CreateBrowserSessionOutput, + CreateProfileOutput, + CreateSessionOutput, + CreateWorkspaceOutput, + DeleteProfileOutput, + DeleteSessionOutput, + DeleteWorkspaceFileOutput, + DeleteWorkspaceOutput, + GetAccountBillingOutput, + GetBrowserSessionOutput, + GetProfileOutput, + GetSessionOutput, + GetWorkspaceOutput, + GetWorkspaceSizeOutput, + ListBrowserSessionsOutput, + ListProfilesOutput, + ListSessionMessagesOutput, + ListSessionsOutput, + ListWorkspaceFilesOutput, + ListWorkspacesOutput, + StopSessionOutput, + UpdateBrowserSessionOutput, + UpdateProfileOutput, + UpdateWorkspaceOutput, + UploadWorkspaceFilesOutput, +) + +API = "https://api.browser-use.com/api/v3" + +_API_KEY = "fake-browser-use-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_25_actions(self) -> None: + assert len(manifest.actions) == 25 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/sessions", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "sess_123", + "status": "running", + "task": "Go to example.com", + "liveUrl": "https://live.browser-use.com/sess_123", + }, + ) + + result_dict = await create_session.ainvoke(_args(task="Go to example.com")) + + assert isinstance(result_dict, dict) + result = CreateSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "sess_123" + + +@pytest.mark.asyncio +async def test_get_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sessions/sess_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "sess_123", + "status": "completed", + "task": "Go to example.com", + "output": "The page contains...", + "liveUrl": "https://live.browser-use.com/sess_123", + "screenshotUrl": "https://cdn.browser-use.com/ss/123.png", + "cost": 0.05, + }, + ) + + result_dict = await get_session.ainvoke(_args(session_id="sess_123")) + + assert isinstance(result_dict, dict) + result = GetSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.status == "completed" + + +@pytest.mark.asyncio +async def test_list_sessions(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sessions?page=1&page_size=20", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "sessions": [{"id": "sess_1"}, {"id": "sess_2"}], + "total": 2, + }, + ) + + result_dict = await list_sessions.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListSessionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.sessions) == 2 + + +@pytest.mark.asyncio +async def test_delete_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/sessions/sess_123", + status_code=204, + ) + + result_dict = await delete_session.ainvoke(_args(session_id="sess_123")) + + assert isinstance(result_dict, dict) + result = DeleteSessionOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_stop_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/sessions/sess_123/stop", + json={"status": "stopped"}, + ) + + result_dict = await stop_session.ainvoke(_args(session_id="sess_123")) + + assert isinstance(result_dict, dict) + result = StopSessionOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_session_messages(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sessions/sess_123/messages?limit=10", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "messages": [{"id": "msg_1", "role": "assistant", "content": "Navigating..."}], + }, + ) + + result_dict = await list_session_messages.ainvoke(_args(session_id="sess_123")) + + assert isinstance(result_dict, dict) + result = ListSessionMessagesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.messages) == 1 + + +@pytest.mark.asyncio +async def test_create_browser_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/browsers", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "bs_123", + "status": "active", + "liveUrl": "https://live.browser-use.com/bs_123", + "cdpUrl": "wss://cdp.browser-use.com/bs_123", + }, + ) + + result_dict = await create_browser_session.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = CreateBrowserSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "bs_123" + + +@pytest.mark.asyncio +async def test_get_browser_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/browsers/bs_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "bs_123", + "status": "active", + "liveUrl": "https://live.browser-use.com/bs_123", + "cdpUrl": "wss://cdp.browser-use.com/bs_123", + "timeout": 60, + "cost": 0.01, + }, + ) + + result_dict = await get_browser_session.ainvoke(_args(browser_session_id="bs_123")) + + assert isinstance(result_dict, dict) + result = GetBrowserSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.status == "active" + + +@pytest.mark.asyncio +async def test_list_browser_sessions(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/browsers?page_size=20&page=1", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "items": [{"id": "bs_1"}, {"id": "bs_2"}], + "totalItems": 2, + }, + ) + + result_dict = await list_browser_sessions.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListBrowserSessionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 2 + + +@pytest.mark.asyncio +async def test_update_browser_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/browsers/bs_123", + json={"status": "stopped"}, + ) + + result_dict = await update_browser_session.ainvoke(_args(browser_session_id="bs_123", action="stop")) + + assert isinstance(result_dict, dict) + result = UpdateBrowserSessionOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_profile(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/profiles", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "prof_123", + "name": "My Profile", + }, + ) + + result_dict = await create_profile.ainvoke(_args(name="My Profile")) + + assert isinstance(result_dict, dict) + result = CreateProfileOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "prof_123" + + +@pytest.mark.asyncio +async def test_get_profile(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/profiles/prof_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "prof_123", + "name": "My Profile", + "userId": "user_abc", + }, + ) + + result_dict = await get_profile.ainvoke(_args(profile_id="prof_123")) + + assert isinstance(result_dict, dict) + result = GetProfileOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "My Profile" + + +@pytest.mark.asyncio +async def test_list_profiles(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/profiles?page_size=20&page=1", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "items": [{"id": "prof_1"}], + "totalItems": 1, + }, + ) + + result_dict = await list_profiles.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListProfilesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 1 + + +@pytest.mark.asyncio +async def test_delete_profile(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/profiles/prof_123", + status_code=204, + ) + + result_dict = await delete_profile.ainvoke(_args(profile_id="prof_123")) + + assert isinstance(result_dict, dict) + result = DeleteProfileOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_profile(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/profiles/prof_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "prof_123", + "name": "Updated Profile", + "userId": "user_abc", + }, + ) + + result_dict = await update_profile.ainvoke(_args(profile_id="prof_123", name="Updated Profile")) + + assert isinstance(result_dict, dict) + result = UpdateProfileOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "Updated Profile" + + +@pytest.mark.asyncio +async def test_create_workspace(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/workspaces", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "ws_123", + "name": "My Workspace", + }, + ) + + result_dict = await create_workspace.ainvoke(_args(name="My Workspace")) + + assert isinstance(result_dict, dict) + result = CreateWorkspaceOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "ws_123" + + +@pytest.mark.asyncio +async def test_get_workspace(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/workspaces/ws_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "ws_123", + "name": "My Workspace", + }, + ) + + result_dict = await get_workspace.ainvoke(_args(workspace_id="ws_123")) + + assert isinstance(result_dict, dict) + result = GetWorkspaceOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "My Workspace" + + +@pytest.mark.asyncio +async def test_list_workspaces(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/workspaces?page_size=20&page=1", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "items": [{"id": "ws_1"}], + "totalItems": 1, + }, + ) + + result_dict = await list_workspaces.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListWorkspacesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 1 + + +@pytest.mark.asyncio +async def test_delete_workspace(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/workspaces/ws_123", + status_code=204, + ) + + result_dict = await delete_workspace.ainvoke(_args(workspace_id="ws_123")) + + assert isinstance(result_dict, dict) + result = DeleteWorkspaceOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_workspace(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/workspaces/ws_123", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "ws_123", + "name": "Updated Workspace", + }, + ) + + result_dict = await update_workspace.ainvoke(_args(workspace_id="ws_123", name="Updated Workspace")) + + assert isinstance(result_dict, dict) + result = UpdateWorkspaceOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "Updated Workspace" + + +@pytest.mark.asyncio +async def test_get_workspace_size(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/workspaces/ws_123/size", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "sizeBytes": 1048576, + }, + ) + + result_dict = await get_workspace_size.ainvoke(_args(workspace_id="ws_123")) + + assert isinstance(result_dict, dict) + result = GetWorkspaceSizeOutput.model_validate(result_dict) + assert result.success is True + assert result.size_bytes == 1048576 + + +@pytest.mark.asyncio +async def test_list_workspace_files(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/workspaces/ws_123/files?limit=50&includeUrls=false&shallow=false", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "files": [{"name": "report.csv", "path": "reports/report.csv"}], + "cursor": None, + }, + ) + + result_dict = await list_workspace_files.ainvoke(_args(workspace_id="ws_123")) + + assert isinstance(result_dict, dict) + result = ListWorkspaceFilesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.files) == 1 + + +@pytest.mark.asyncio +async def test_delete_workspace_file(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/workspaces/ws_123/files?path=reports%2Fdata.csv", + status_code=204, + ) + + result_dict = await delete_workspace_file.ainvoke(_args(workspace_id="ws_123", path="reports/data.csv")) + + assert isinstance(result_dict, dict) + result = DeleteWorkspaceFileOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_upload_workspace_files(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/workspaces/ws_123/files/upload", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "files": [{"name": "data.csv", "uploadUrl": "https://s3.amazonaws.com/...", "path": "data.csv"}], + }, + ) + + result_dict = await upload_workspace_files.ainvoke( + _args(workspace_id="ws_123", files_json='[{"name": "data.csv"}]') + ) + + assert isinstance(result_dict, dict) + result = UploadWorkspaceFilesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.files) == 1 + + +@pytest.mark.asyncio +async def test_get_account_billing(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/billing/account", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "plan": "pro", + "credits_remaining": 100.0, + }, + ) + + result_dict = await get_account_billing.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetAccountBillingOutput.model_validate(result_dict) + assert result.success is True + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_session_validates_empty_api_key() -> None: + result_dict = await create_session.ainvoke({"api_key": ""}) + result = CreateSessionOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/browser_use/tools.py b/src/modulex_integrations/tools/browser_use/tools.py new file mode 100644 index 0000000..cde4da5 --- /dev/null +++ b/src/modulex_integrations/tools/browser_use/tools.py @@ -0,0 +1,1062 @@ +"""Browser Use LangChain @tool functions.""" +from __future__ import annotations + +import json +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.browser_use.outputs import ( + CreateBrowserSessionOutput, + CreateProfileOutput, + CreateSessionOutput, + CreateWorkspaceOutput, + DeleteProfileOutput, + DeleteSessionOutput, + DeleteWorkspaceFileOutput, + DeleteWorkspaceOutput, + GetAccountBillingOutput, + GetBrowserSessionOutput, + GetProfileOutput, + GetSessionOutput, + GetWorkspaceOutput, + GetWorkspaceSizeOutput, + ListBrowserSessionsOutput, + ListProfilesOutput, + ListSessionMessagesOutput, + ListSessionsOutput, + ListWorkspaceFilesOutput, + ListWorkspacesOutput, + StopSessionOutput, + UpdateBrowserSessionOutput, + UpdateProfileOutput, + UpdateWorkspaceOutput, + UploadWorkspaceFilesOutput, +) + +__all__ = [ + "create_browser_session", + "create_profile", + "create_session", + "create_workspace", + "delete_profile", + "delete_session", + "delete_workspace", + "delete_workspace_file", + "get_account_billing", + "get_browser_session", + "get_profile", + "get_session", + "get_workspace", + "get_workspace_size", + "list_browser_sessions", + "list_profiles", + "list_session_messages", + "list_sessions", + "list_workspace_files", + "list_workspaces", + "stop_session", + "update_browser_session", + "update_profile", + "update_workspace", + "upload_workspace_files", +] + +_BASE_URL = "https://api.browser-use.com/api/v3" +_TIMEOUT = 30.0 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "X-Browser-Use-API-Key": api_key, + "Content-Type": "application/json", + "Accept": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + task: str | None = Field(default=None, description="Natural-language instruction for the agent") + model: str = Field(default="claude-sonnet-4.6", description="Browser Use agent model") + session_id: str | None = Field(default=None, description="ID of an existing session to dispatch a follow-up task to") + keep_alive: bool = Field(default=False, description="If true, the session stays idle after the task completes") + max_cost_usd: str | None = Field(default=None, description="Maximum total session cost in USD") + profile_id: str | None = Field(default=None, description="ID of a Browser Use profile to use") + workspace_id: str | None = Field(default=None, description="ID of a Browser Use workspace to attach") + proxy_country_code: str = Field(default="us", description="Lowercase proxy country code for browser traffic") + output_schema: dict[str, Any] | None = Field(default=None, description="JSON Schema for structured output") + enable_scheduled_tasks: bool = Field(default=False, description="If true, the agent can create scheduled tasks") + sensitive_data: dict[str, Any] | None = Field(default=None, description="Key-value pairs available through secure placeholders") + enable_recording: bool = Field(default=False, description="If true, records the browser session") + skills: bool = Field(default=True, description="If true, enables built-in agent skills") + agentmail: bool = Field(default=True, description="If true, provisions a temporary email inbox") + cache_script: str = Field(default="auto", description="Controls script caching. Allowed: auto, enabled, disabled") + use_own_key: bool = Field(default=False, description="If true, uses your configured LLM provider key") + auto_heal: bool = Field(default=True, description="Validates cached script output and reruns if incorrect") + + +class GetSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + session_id: str = Field(description="ID of the Browser Use agent session") + + +class ListSessionsInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + page_number: int = Field(default=1, description="Page number to fetch") + page_size: int = Field(default=20, description="Number of records per page. Maximum: 100") + + +class DeleteSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + session_id: str = Field(description="ID of the session to delete") + + +class StopSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + session_id: str = Field(description="ID of the Browser Use agent session") + strategy: str = Field(default="session", description="Use task or session. Allowed: task, session") + + +class ListSessionMessagesInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + session_id: str = Field(description="ID of the Browser Use agent session") + after: str | None = Field(default=None, description="Return messages after this message ID cursor") + before: str | None = Field(default=None, description="Return messages before this message ID cursor") + limit: int = Field(default=10, description="Maximum number of messages to return. Maximum: 100") + + +class CreateBrowserSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + profile_id: str | None = Field(default=None, description="ID of a Browser Use profile") + proxy_country_code: str = Field(default="us", description="Lowercase proxy country code") + timeout: int = Field(default=60, description="Session timeout in minutes. Range: 1 to 240") + browser_screen_width: int | None = Field(default=None, description="Custom browser screen width in pixels") + browser_screen_height: int | None = Field(default=None, description="Custom browser screen height in pixels") + allow_resizing: bool = Field(default=False, description="Whether to allow browser resizing") + custom_proxy: dict[str, Any] | None = Field(default=None, description="Custom proxy object with host, port, username, password") + enable_recording: bool = Field(default=False, description="If true, records the browser session") + + +class GetBrowserSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + browser_session_id: str = Field(description="ID of the Browser Use browser session") + + +class ListBrowserSessionsInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + page_size: int = Field(default=20, description="Number of records per page. Maximum: 100") + page_number: int = Field(default=1, description="Page number to fetch") + filter_by: str | None = Field(default=None, description="Filter by status. Allowed: active, stopped") + + +class UpdateBrowserSessionInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + browser_session_id: str = Field(description="ID of the Browser Use browser session") + action: str = Field(default="stop", description="Action to perform. Currently supported: stop") + + +class CreateProfileInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + name: str | None = Field(default=None, description="Profile name. Maximum: 100 characters") + user_id: str | None = Field(default=None, description="Internal user identifier. Maximum: 255 characters") + + +class GetProfileInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + profile_id: str = Field(description="ID of the Browser Use profile") + + +class ListProfilesInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + page_size: int = Field(default=20, description="Number of records per page. Maximum: 100") + page_number: int = Field(default=1, description="Page number to fetch") + query: str | None = Field(default=None, description="Search query for profile name or user ID") + + +class DeleteProfileInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + profile_id: str = Field(description="ID of the profile to delete") + + +class UpdateProfileInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + profile_id: str = Field(description="ID of the Browser Use profile") + name: str | None = Field(default=None, description="Updated profile name. Maximum: 100 characters") + user_id: str | None = Field(default=None, description="Updated internal user identifier") + + +class CreateWorkspaceInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + name: str | None = Field(default=None, description="Workspace name. Maximum: 100 characters") + + +class GetWorkspaceInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + + +class ListWorkspacesInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + page_size: int = Field(default=20, description="Number of records per page. Maximum: 100") + page_number: int = Field(default=1, description="Page number to fetch") + + +class DeleteWorkspaceInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the workspace to delete") + + +class UpdateWorkspaceInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + name: str = Field(description="Updated workspace name. Maximum: 100 characters") + + +class GetWorkspaceSizeInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + + +class ListWorkspaceFilesInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + prefix: str | None = Field(default=None, description="Directory prefix to list") + limit: int = Field(default=50, description="Maximum number of files to return. Maximum: 100") + cursor: str | None = Field(default=None, description="Pagination cursor from a previous response") + include_urls: bool = Field(default=False, description="If true, include presigned download URLs") + shallow: bool = Field(default=False, description="If true, list only immediate files at the prefix") + + +class DeleteWorkspaceFileInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + path: str = Field(description="Relative workspace file path to delete") + + +class UploadWorkspaceFilesInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + workspace_id: str = Field(description="ID of the Browser Use workspace") + prefix: str | None = Field(default=None, description="Directory prefix to upload into") + files_json: str = Field(description="JSON array of file metadata objects with name, contentType, and size fields") + + +class GetAccountBillingInput(BaseModel): + api_key: str = Field(description="Browser Use API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateSessionInput) +@serialize_pydantic_return +async def create_session( + api_key: str, + task: str | None = None, + model: str = "claude-sonnet-4.6", + session_id: str | None = None, + keep_alive: bool = False, + max_cost_usd: str | None = None, + profile_id: str | None = None, + workspace_id: str | None = None, + proxy_country_code: str = "us", + output_schema: dict[str, Any] | None = None, + enable_scheduled_tasks: bool = False, + sensitive_data: dict[str, Any] | None = None, + enable_recording: bool = False, + skills: bool = True, + agentmail: bool = True, + cache_script: str = "auto", + use_own_key: bool = False, + auto_heal: bool = True, +) -> CreateSessionOutput: + """Create an agent session, dispatch a task, or dispatch a follow-up task to an existing idle session.""" + if not api_key or not api_key.strip(): + return CreateSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + + proxy_value: str | None = None if proxy_country_code == "none" else proxy_country_code + cache_value: bool | None = None + if cache_script == "enabled": + cache_value = True + elif cache_script == "disabled": + cache_value = False + + body: dict[str, Any] = { + "model": model, + "keepAlive": keep_alive, + "enableScheduledTasks": enable_scheduled_tasks, + "enableRecording": enable_recording, + "skills": skills, + "agentmail": agentmail, + "useOwnKey": use_own_key, + "autoHeal": auto_heal, + } + if task is not None: + body["task"] = task + if session_id is not None: + body["sessionId"] = session_id + if max_cost_usd is not None: + body["maxCostUsd"] = float(max_cost_usd) + if profile_id is not None: + body["profileId"] = profile_id + if workspace_id is not None: + body["workspaceId"] = workspace_id + if proxy_value is not None: + body["proxyCountryCode"] = proxy_value + if output_schema is not None: + body["outputSchema"] = output_schema + if sensitive_data is not None: + body["sensitiveData"] = sensitive_data + if cache_value is not None: + body["cacheScript"] = cache_value + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post(f"{_BASE_URL}/sessions", headers=_headers(api_key), json=body) + if response.status_code not in (200, 201): + return CreateSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateSessionOutput(success=False, error=f"Call failed: {exc}") + + return CreateSessionOutput( + success=True, + id=data.get("id"), + status=data.get("status"), + task=data.get("task"), + live_url=data.get("liveUrl"), + data=data, + ) + + +@tool(args_schema=GetSessionInput) +@serialize_pydantic_return +async def get_session( + api_key: str, + session_id: str, +) -> GetSessionOutput: + """Get the current state, output, live URL, screenshot URL, and cost details for an agent session.""" + if not api_key or not api_key.strip(): + return GetSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/sessions/{session_id}", headers=_headers(api_key)) + if response.status_code != 200: + return GetSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetSessionOutput(success=False, error=f"Call failed: {exc}") + + return GetSessionOutput( + success=True, + id=data.get("id"), + status=data.get("status"), + task=data.get("task"), + output=data.get("output"), + live_url=data.get("liveUrl"), + screenshot_url=data.get("screenshotUrl"), + cost=data.get("cost"), + data=data, + ) + + +@tool(args_schema=ListSessionsInput) +@serialize_pydantic_return +async def list_sessions( + api_key: str, + page_number: int = 1, + page_size: int = 20, +) -> ListSessionsOutput: + """List Browser Use agent sessions for the authenticated project.""" + if not api_key or not api_key.strip(): + return ListSessionsOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/sessions", + headers=_headers(api_key), + params={"page": page_number, "page_size": page_size}, + ) + if response.status_code != 200: + return ListSessionsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListSessionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListSessionsOutput(success=False, error=f"Call failed: {exc}") + + return ListSessionsOutput( + success=True, + sessions=data.get("sessions", []), + total=data.get("total"), + ) + + +@tool(args_schema=DeleteSessionInput) +@serialize_pydantic_return +async def delete_session( + api_key: str, + session_id: str, +) -> DeleteSessionOutput: + """Delete an agent session.""" + if not api_key or not api_key.strip(): + return DeleteSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete(f"{_BASE_URL}/sessions/{session_id}", headers=_headers(api_key)) + if response.status_code not in (200, 204): + return DeleteSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return DeleteSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteSessionOutput(success=False, error=f"Call failed: {exc}") + + return DeleteSessionOutput(success=True) + + +@tool(args_schema=StopSessionInput) +@serialize_pydantic_return +async def stop_session( + api_key: str, + session_id: str, + strategy: str = "session", +) -> StopSessionOutput: + """Stop the current task or stop the entire Browser Use agent session.""" + if not api_key or not api_key.strip(): + return StopSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/sessions/{session_id}/stop", + headers=_headers(api_key), + json={"strategy": strategy}, + ) + if response.status_code != 200: + return StopSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return StopSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return StopSessionOutput(success=False, error=f"Call failed: {exc}") + + return StopSessionOutput(success=True, data=data) + + +@tool(args_schema=ListSessionMessagesInput) +@serialize_pydantic_return +async def list_session_messages( + api_key: str, + session_id: str, + after: str | None = None, + before: str | None = None, + limit: int = 10, +) -> ListSessionMessagesOutput: + """List messages from a Browser Use agent session, including reasoning, tool calls, browser actions, screenshots, and results.""" + if not api_key or not api_key.strip(): + return ListSessionMessagesOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"limit": limit} + if after is not None: + params["after"] = after + if before is not None: + params["before"] = before + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/sessions/{session_id}/messages", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return ListSessionMessagesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListSessionMessagesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListSessionMessagesOutput(success=False, error=f"Call failed: {exc}") + + return ListSessionMessagesOutput(success=True, messages=data.get("messages", [])) + + +@tool(args_schema=CreateBrowserSessionInput) +@serialize_pydantic_return +async def create_browser_session( + api_key: str, + profile_id: str | None = None, + proxy_country_code: str = "us", + timeout: int = 60, + browser_screen_width: int | None = None, + browser_screen_height: int | None = None, + allow_resizing: bool = False, + custom_proxy: dict[str, Any] | None = None, + enable_recording: bool = False, +) -> CreateBrowserSessionOutput: + """Create a standalone browser session for direct browser control through CDP.""" + if not api_key or not api_key.strip(): + return CreateBrowserSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + + proxy_value: str | None = None if proxy_country_code == "none" else proxy_country_code + body: dict[str, Any] = { + "timeout": timeout, + "allowResizing": allow_resizing, + "enableRecording": enable_recording, + } + if profile_id is not None: + body["profileId"] = profile_id + if proxy_value is not None: + body["proxyCountryCode"] = proxy_value + if browser_screen_width is not None: + body["browserScreenWidth"] = browser_screen_width + if browser_screen_height is not None: + body["browserScreenHeight"] = browser_screen_height + if custom_proxy is not None: + body["customProxy"] = custom_proxy + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post(f"{_BASE_URL}/browsers", headers=_headers(api_key), json=body) + if response.status_code not in (200, 201): + return CreateBrowserSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateBrowserSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateBrowserSessionOutput(success=False, error=f"Call failed: {exc}") + + return CreateBrowserSessionOutput( + success=True, + id=data.get("id"), + status=data.get("status"), + live_url=data.get("liveUrl"), + cdp_url=data.get("cdpUrl"), + data=data, + ) + + +@tool(args_schema=GetBrowserSessionInput) +@serialize_pydantic_return +async def get_browser_session( + api_key: str, + browser_session_id: str, +) -> GetBrowserSessionOutput: + """Get details for a standalone browser session, including live URL, CDP URL, status, timeout, and cost fields.""" + if not api_key or not api_key.strip(): + return GetBrowserSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/browsers/{browser_session_id}", headers=_headers(api_key)) + if response.status_code != 200: + return GetBrowserSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetBrowserSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetBrowserSessionOutput(success=False, error=f"Call failed: {exc}") + + return GetBrowserSessionOutput( + success=True, + id=data.get("id"), + status=data.get("status"), + live_url=data.get("liveUrl"), + cdp_url=data.get("cdpUrl"), + timeout=data.get("timeout"), + cost=data.get("cost"), + data=data, + ) + + +@tool(args_schema=ListBrowserSessionsInput) +@serialize_pydantic_return +async def list_browser_sessions( + api_key: str, + page_size: int = 20, + page_number: int = 1, + filter_by: str | None = None, +) -> ListBrowserSessionsOutput: + """List standalone browser sessions for direct browser control via CDP.""" + if not api_key or not api_key.strip(): + return ListBrowserSessionsOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"page_size": page_size, "page": page_number} + if filter_by is not None: + params["status"] = filter_by + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/browsers", headers=_headers(api_key), params=params) + if response.status_code != 200: + return ListBrowserSessionsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListBrowserSessionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListBrowserSessionsOutput(success=False, error=f"Call failed: {exc}") + + return ListBrowserSessionsOutput( + success=True, + items=data.get("items", []), + total_items=data.get("totalItems"), + ) + + +@tool(args_schema=UpdateBrowserSessionInput) +@serialize_pydantic_return +async def update_browser_session( + api_key: str, + browser_session_id: str, + action: str = "stop", +) -> UpdateBrowserSessionOutput: + """Update a standalone browser session. Currently supports the stop action.""" + if not api_key or not api_key.strip(): + return UpdateBrowserSessionOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/browsers/{browser_session_id}", + headers=_headers(api_key), + json={"action": action}, + ) + if response.status_code != 200: + return UpdateBrowserSessionOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateBrowserSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateBrowserSessionOutput(success=False, error=f"Call failed: {exc}") + + return UpdateBrowserSessionOutput(success=True, data=data) + + +@tool(args_schema=CreateProfileInput) +@serialize_pydantic_return +async def create_profile( + api_key: str, + name: str | None = None, + user_id: str | None = None, +) -> CreateProfileOutput: + """Create a profile to preserve cookies, local storage, and login state across sessions.""" + if not api_key or not api_key.strip(): + return CreateProfileOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if user_id is not None: + body["userId"] = user_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post(f"{_BASE_URL}/profiles", headers=_headers(api_key), json=body) + if response.status_code not in (200, 201): + return CreateProfileOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateProfileOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateProfileOutput(success=False, error=f"Call failed: {exc}") + + return CreateProfileOutput(success=True, id=data.get("id"), name=data.get("name"), data=data) + + +@tool(args_schema=GetProfileInput) +@serialize_pydantic_return +async def get_profile( + api_key: str, + profile_id: str, +) -> GetProfileOutput: + """Get a Browser Use profile by ID.""" + if not api_key or not api_key.strip(): + return GetProfileOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/profiles/{profile_id}", headers=_headers(api_key)) + if response.status_code != 200: + return GetProfileOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetProfileOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetProfileOutput(success=False, error=f"Call failed: {exc}") + + return GetProfileOutput( + success=True, + id=data.get("id"), + name=data.get("name"), + user_id=data.get("userId"), + data=data, + ) + + +@tool(args_schema=ListProfilesInput) +@serialize_pydantic_return +async def list_profiles( + api_key: str, + page_size: int = 20, + page_number: int = 1, + query: str | None = None, +) -> ListProfilesOutput: + """List Browser Use profiles, optionally searching by profile name or user ID.""" + if not api_key or not api_key.strip(): + return ListProfilesOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"page_size": page_size, "page": page_number} + if query is not None: + params["query"] = query + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/profiles", headers=_headers(api_key), params=params) + if response.status_code != 200: + return ListProfilesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListProfilesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListProfilesOutput(success=False, error=f"Call failed: {exc}") + + return ListProfilesOutput( + success=True, + items=data.get("items", []), + total_items=data.get("totalItems"), + ) + + +@tool(args_schema=DeleteProfileInput) +@serialize_pydantic_return +async def delete_profile( + api_key: str, + profile_id: str, +) -> DeleteProfileOutput: + """Delete a Browser Use profile and its persisted browser state.""" + if not api_key or not api_key.strip(): + return DeleteProfileOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete(f"{_BASE_URL}/profiles/{profile_id}", headers=_headers(api_key)) + if response.status_code not in (200, 204): + return DeleteProfileOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return DeleteProfileOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteProfileOutput(success=False, error=f"Call failed: {exc}") + + return DeleteProfileOutput(success=True) + + +@tool(args_schema=UpdateProfileInput) +@serialize_pydantic_return +async def update_profile( + api_key: str, + profile_id: str, + name: str | None = None, + user_id: str | None = None, +) -> UpdateProfileOutput: + """Update a Browser Use profile name or user ID.""" + if not api_key or not api_key.strip(): + return UpdateProfileOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + if user_id is not None: + body["userId"] = user_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch(f"{_BASE_URL}/profiles/{profile_id}", headers=_headers(api_key), json=body) + if response.status_code != 200: + return UpdateProfileOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateProfileOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateProfileOutput(success=False, error=f"Call failed: {exc}") + + return UpdateProfileOutput( + success=True, + id=data.get("id"), + name=data.get("name"), + user_id=data.get("userId"), + data=data, + ) + + +@tool(args_schema=CreateWorkspaceInput) +@serialize_pydantic_return +async def create_workspace( + api_key: str, + name: str | None = None, +) -> CreateWorkspaceOutput: + """Create a workspace for persistent shared file storage across sessions.""" + if not api_key or not api_key.strip(): + return CreateWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post(f"{_BASE_URL}/workspaces", headers=_headers(api_key), json=body) + if response.status_code not in (200, 201): + return CreateWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateWorkspaceOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateWorkspaceOutput(success=False, error=f"Call failed: {exc}") + + return CreateWorkspaceOutput(success=True, id=data.get("id"), name=data.get("name"), data=data) + + +@tool(args_schema=GetWorkspaceInput) +@serialize_pydantic_return +async def get_workspace( + api_key: str, + workspace_id: str, +) -> GetWorkspaceOutput: + """Get a Browser Use workspace by ID.""" + if not api_key or not api_key.strip(): + return GetWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/workspaces/{workspace_id}", headers=_headers(api_key)) + if response.status_code != 200: + return GetWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetWorkspaceOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetWorkspaceOutput(success=False, error=f"Call failed: {exc}") + + return GetWorkspaceOutput(success=True, id=data.get("id"), name=data.get("name"), data=data) + + +@tool(args_schema=ListWorkspacesInput) +@serialize_pydantic_return +async def list_workspaces( + api_key: str, + page_size: int = 20, + page_number: int = 1, +) -> ListWorkspacesOutput: + """List Browser Use workspaces for persistent shared file storage across sessions.""" + if not api_key or not api_key.strip(): + return ListWorkspacesOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/workspaces", + headers=_headers(api_key), + params={"page_size": page_size, "page": page_number}, + ) + if response.status_code != 200: + return ListWorkspacesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListWorkspacesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListWorkspacesOutput(success=False, error=f"Call failed: {exc}") + + return ListWorkspacesOutput( + success=True, + items=data.get("items", []), + total_items=data.get("totalItems"), + ) + + +@tool(args_schema=DeleteWorkspaceInput) +@serialize_pydantic_return +async def delete_workspace( + api_key: str, + workspace_id: str, +) -> DeleteWorkspaceOutput: + """Delete a Browser Use workspace and its stored files. This cannot be undone.""" + if not api_key or not api_key.strip(): + return DeleteWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete(f"{_BASE_URL}/workspaces/{workspace_id}", headers=_headers(api_key)) + if response.status_code not in (200, 204): + return DeleteWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return DeleteWorkspaceOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteWorkspaceOutput(success=False, error=f"Call failed: {exc}") + + return DeleteWorkspaceOutput(success=True) + + +@tool(args_schema=UpdateWorkspaceInput) +@serialize_pydantic_return +async def update_workspace( + api_key: str, + workspace_id: str, + name: str = "", +) -> UpdateWorkspaceOutput: + """Update a Browser Use workspace name.""" + if not api_key or not api_key.strip(): + return UpdateWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/workspaces/{workspace_id}", + headers=_headers(api_key), + json={"name": name}, + ) + if response.status_code != 200: + return UpdateWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateWorkspaceOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateWorkspaceOutput(success=False, error=f"Call failed: {exc}") + + return UpdateWorkspaceOutput(success=True, id=data.get("id"), name=data.get("name"), data=data) + + +@tool(args_schema=GetWorkspaceSizeInput) +@serialize_pydantic_return +async def get_workspace_size( + api_key: str, + workspace_id: str, +) -> GetWorkspaceSizeOutput: + """Get storage usage for a Browser Use workspace.""" + if not api_key or not api_key.strip(): + return GetWorkspaceSizeOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/workspaces/{workspace_id}/size", headers=_headers(api_key)) + if response.status_code != 200: + return GetWorkspaceSizeOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetWorkspaceSizeOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetWorkspaceSizeOutput(success=False, error=f"Call failed: {exc}") + + return GetWorkspaceSizeOutput(success=True, size_bytes=data.get("sizeBytes"), data=data) + + +@tool(args_schema=ListWorkspaceFilesInput) +@serialize_pydantic_return +async def list_workspace_files( + api_key: str, + workspace_id: str, + prefix: str | None = None, + limit: int = 50, + cursor: str | None = None, + include_urls: bool = False, + shallow: bool = False, +) -> ListWorkspaceFilesOutput: + """List files and folders in a Browser Use workspace, optionally returning presigned download URLs.""" + if not api_key or not api_key.strip(): + return ListWorkspaceFilesOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"limit": limit, "includeUrls": include_urls, "shallow": shallow} + if prefix is not None: + params["prefix"] = prefix + if cursor is not None: + params["cursor"] = cursor + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/workspaces/{workspace_id}/files", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return ListWorkspaceFilesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListWorkspaceFilesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListWorkspaceFilesOutput(success=False, error=f"Call failed: {exc}") + + return ListWorkspaceFilesOutput( + success=True, + files=data.get("files", []), + cursor=data.get("cursor"), + ) + + +@tool(args_schema=DeleteWorkspaceFileInput) +@serialize_pydantic_return +async def delete_workspace_file( + api_key: str, + workspace_id: str, + path: str, +) -> DeleteWorkspaceFileOutput: + """Delete a file from a Browser Use workspace.""" + if not api_key or not api_key.strip(): + return DeleteWorkspaceFileOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{_BASE_URL}/workspaces/{workspace_id}/files", + headers=_headers(api_key), + params={"path": path}, + ) + if response.status_code not in (200, 204): + return DeleteWorkspaceFileOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return DeleteWorkspaceFileOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteWorkspaceFileOutput(success=False, error=f"Call failed: {exc}") + + return DeleteWorkspaceFileOutput(success=True) + + +@tool(args_schema=UploadWorkspaceFilesInput) +@serialize_pydantic_return +async def upload_workspace_files( + api_key: str, + workspace_id: str, + files_json: str, + prefix: str | None = None, +) -> UploadWorkspaceFilesOutput: + """Create presigned upload URLs for workspace files.""" + if not api_key or not api_key.strip(): + return UploadWorkspaceFilesOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + files_list = json.loads(files_json) + except (json.JSONDecodeError, TypeError) as exc: + return UploadWorkspaceFilesOutput(success=False, error=f"Invalid files_json: {exc}") + + body: dict[str, Any] = {"files": files_list} + if prefix is not None: + body["prefix"] = prefix + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/workspaces/{workspace_id}/files/upload", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return UploadWorkspaceFilesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UploadWorkspaceFilesOutput(success=False, error="Request timed out.") + except Exception as exc: + return UploadWorkspaceFilesOutput(success=False, error=f"Call failed: {exc}") + + return UploadWorkspaceFilesOutput(success=True, files=data.get("files", [])) + + +@tool(args_schema=GetAccountBillingInput) +@serialize_pydantic_return +async def get_account_billing( + api_key: str, +) -> GetAccountBillingOutput: + """Get account billing details for the authenticated project.""" + if not api_key or not api_key.strip(): + return GetAccountBillingOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get(f"{_BASE_URL}/billing/account", headers=_headers(api_key)) + if response.status_code != 200: + return GetAccountBillingOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetAccountBillingOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetAccountBillingOutput(success=False, error=f"Call failed: {exc}") + + return GetAccountBillingOutput(success=True, data=data) diff --git a/src/modulex_integrations/tools/browserbase/README.md b/src/modulex_integrations/tools/browserbase/README.md new file mode 100644 index 0000000..ad6f96c --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/README.md @@ -0,0 +1,33 @@ +# Browserbase + +Cloud browser infrastructure for running and managing headless browser sessions +via the Browserbase REST API (`api.browserbase.com/v1`). + +## Authentication + +### API Key Authentication + +- Sign in at and navigate to Settings > API Keys. +- Create a new API key or copy your existing one. +- Required env var: `BROWSERBASE_API_KEY` (format: `bb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_context` | Creates a new context in Browserbase for persistent browser state | `project_id` | +| `create_session` | Creates a new browser session with specified settings | `project_id` | +| `list_projects` | Lists all projects in the Browserbase account | _(none)_ | + +Every tool takes an additional `api_key` parameter that the runtime fills in +from the resolved credential. + +## Limits & Quotas + +- Rate limits are not publicly documented by Browserbase; contact their support for enterprise limits. +- Session timeout range: 60–21600 seconds. +- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/browserbase/__init__.py b/src/modulex_integrations/tools/browserbase/__init__.py new file mode 100644 index 0000000..bd0c204 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/__init__.py @@ -0,0 +1,21 @@ +"""Browserbase integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.browserbase.manifest import manifest +from modulex_integrations.tools.browserbase.tools import ( + create_context, + create_session, + list_projects, +) + +TOOLS = ( + create_context, + create_session, + list_projects, +) + +__all__ = [ + "TOOLS", + "create_context", + "create_session", + "list_projects", + "manifest", +] diff --git a/src/modulex_integrations/tools/browserbase/dependencies.toml b/src/modulex_integrations/tools/browserbase/dependencies.toml new file mode 100644 index 0000000..ab35856 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the browserbase integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/browserbase/manifest.py b/src/modulex_integrations/tools/browserbase/manifest.py new file mode 100644 index 0000000..6decf29 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/manifest.py @@ -0,0 +1,116 @@ +"""Browserbase integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="browserbase", + display_name="Browserbase", + description="Cloud browser infrastructure for running and managing headless browser sessions", + version="1.0.0", + author="ModuleX", + logo="modulex:browserbase-themed", + app_url="https://www.browserbase.com", + categories=["Developer Tools & Infrastructure", "automation", "browser"], + actions=[ + ActionDefinition( + name="create_context", + description="Creates a new context in Browserbase for persistent browser state", + parameters={ + "project_id": ParameterDef( + type="string", + description="The ID of the Browserbase project", + required=True, + ), + }, + ), + ActionDefinition( + name="create_session", + description="Creates a new browser session with specified settings", + parameters={ + "project_id": ParameterDef( + type="string", + description="The ID of the Browserbase project", + required=True, + ), + "extension_id": ParameterDef( + type="string", + description="The uploaded Extension ID to load in the session", + ), + "browser_settings": ParameterDef( + type="object", + description="Settings for the session (e.g. fingerprint, viewport). See Browserbase docs for schema.", + ), + "timeout": ParameterDef( + type="integer", + description="Duration in seconds after which the session will automatically end. Min: 60, Max: 21600.", + ), + "keep_alive": ParameterDef( + type="boolean", + description="Set to true to keep the session alive even after disconnections", + ), + "proxies": ParameterDef( + type="array", + description="Array of proxy configuration objects. Each element should have type and optional geolocation fields.", + ), + "region": ParameterDef( + type="string", + description="The region where the session should run. One of: us-west-2, us-east-1, eu-central-1, ap-southeast-1.", + ), + "user_metadata": ParameterDef( + type="object", + description="Arbitrary user metadata to attach to the session", + ), + }, + ), + ActionDefinition( + name="list_projects", + description="Lists all projects in the Browserbase account", + parameters={}, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Browserbase API key", + setup_instructions=[ + "Go to https://www.browserbase.com and sign in", + "Navigate to Settings > API Keys", + "Create a new API key or copy your existing one", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="BROWSERBASE_API_KEY", + display_name="Browserbase API Key", + description="Your Browserbase API key from the settings page", + required=True, + sensitive=True, + sample_format="bb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://www.browserbase.com/settings", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.browserbase.com/v1/projects", + method="GET", + headers={"x-bb-api-key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Validates the API key by listing projects", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/browserbase/outputs.py b/src/modulex_integrations/tools/browserbase/outputs.py new file mode 100644 index 0000000..cab8826 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/outputs.py @@ -0,0 +1,53 @@ +"""Pydantic response models for the browserbase integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateContextOutput", + "CreateSessionOutput", + "ListProjectsOutput", + "ProjectSummary", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class ProjectSummary(_Base): + id: str | None = None + name: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class CreateContextOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + project_id: str | None = None + created_at: str | None = None + + +class CreateSessionOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + project_id: str | None = None + status: str | None = None + created_at: str | None = None + region: str | None = None + connect_url: str | None = None + + +class ListProjectsOutput(_Base): + success: bool + error: str | None = None + projects: list[ProjectSummary] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/browserbase/tests/__init__.py b/src/modulex_integrations/tools/browserbase/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/browserbase/tests/test_browserbase.py b/src/modulex_integrations/tools/browserbase/tests/test_browserbase.py new file mode 100644 index 0000000..a310179 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/tests/test_browserbase.py @@ -0,0 +1,133 @@ +"""Happy-path tests for every browserbase @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.browserbase import ( + TOOLS, + create_context, + create_session, + list_projects, + manifest, +) +from modulex_integrations.tools.browserbase.outputs import ( + CreateContextOutput, + CreateSessionOutput, + ListProjectsOutput, +) + +API = "https://api.browserbase.com/v1" + +_API_KEY = "fake-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_3_actions(self) -> None: + assert len(manifest.actions) == 3 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_context(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/contexts", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "ctx_abc123", + "projectId": "proj_xyz", + "createdAt": "2026-01-01T00:00:00Z", + }, + status_code=201, + ) + + result_dict = await create_context.ainvoke(_args(project_id="proj_xyz")) + + assert isinstance(result_dict, dict) + result = CreateContextOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "ctx_abc123" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-bb-api-key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_create_session(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/sessions", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "id": "sess_abc123", + "projectId": "proj_xyz", + "status": "RUNNING", + "createdAt": "2026-01-01T00:00:00Z", + "region": "us-west-2", + "connectUrl": "wss://connect.browserbase.com/sess_abc123", + }, + status_code=201, + ) + + result_dict = await create_session.ainvoke(_args(project_id="proj_xyz")) + + assert isinstance(result_dict, dict) + result = CreateSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "sess_abc123" + assert result.status == "RUNNING" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-bb-api-key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_list_projects(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/projects", + json=[ + # TODO: fill in a representative response shape from the upstream API docs + {"id": "proj_1", "name": "My Project"}, + {"id": "proj_2", "name": "Another Project"}, + ], + ) + + result_dict = await list_projects.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListProjectsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.projects) == 2 + assert result.projects[0].id == "proj_1" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["x-bb-api-key"] == _API_KEY + + +# --- Failure-path tests --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_context_validates_empty_api_key() -> None: + result_dict = await create_context.ainvoke({"project_id": "x", "api_key": ""}) + result = CreateContextOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/browserbase/tools.py b/src/modulex_integrations/tools/browserbase/tools.py new file mode 100644 index 0000000..f07a265 --- /dev/null +++ b/src/modulex_integrations/tools/browserbase/tools.py @@ -0,0 +1,195 @@ +"""Browserbase LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.browserbase.outputs import ( + CreateContextOutput, + CreateSessionOutput, + ListProjectsOutput, + ProjectSummary, +) + +__all__ = [ + "create_context", + "create_session", + "list_projects", +] + +_BASE_URL = "https://api.browserbase.com/v1" + + +def _headers(api_key: str) -> dict[str, str]: + return { + "x-bb-api-key": api_key, + "Content-Type": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateContextInput(BaseModel): + project_id: str = Field(description="The ID of the Browserbase project") + api_key: str = Field(description="Browserbase API key") + + +class CreateSessionInput(BaseModel): + project_id: str = Field(description="The ID of the Browserbase project") + api_key: str = Field(description="Browserbase API key") + extension_id: str | None = Field(default=None, description="The uploaded Extension ID to load in the session") + browser_settings: dict[str, Any] | None = Field(default=None, description="Settings for the session (e.g. fingerprint, viewport)") + timeout: int | None = Field(default=None, description="Duration in seconds after which the session will automatically end. Min: 60, Max: 21600.") + keep_alive: bool | None = Field(default=None, description="Set to true to keep the session alive even after disconnections") + proxies: list[dict[str, Any]] | None = Field(default=None, description="Array of proxy configuration objects") + region: str | None = Field(default=None, description="The region where the session should run. One of: us-west-2, us-east-1, eu-central-1, ap-southeast-1.") + user_metadata: dict[str, Any] | None = Field(default=None, description="Arbitrary user metadata to attach to the session") + + +class ListProjectsInput(BaseModel): + api_key: str = Field(description="Browserbase API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateContextInput) +@serialize_pydantic_return +async def create_context( + project_id: str, + api_key: str, +) -> CreateContextOutput: + """Creates a new context in Browserbase for persistent browser state.""" + if not api_key or not api_key.strip(): + return CreateContextOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/contexts", + headers=_headers(api_key), + json={"projectId": project_id}, + ) + if response.status_code not in (200, 201): + return CreateContextOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateContextOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateContextOutput(success=False, error=f"Call failed: {exc}") + + return CreateContextOutput( + success=True, + id=data.get("id"), + project_id=data.get("projectId"), + created_at=data.get("createdAt"), + ) + + +@tool(args_schema=CreateSessionInput) +@serialize_pydantic_return +async def create_session( + project_id: str, + api_key: str, + extension_id: str | None = None, + browser_settings: dict[str, Any] | None = None, + timeout: int | None = None, + keep_alive: bool | None = None, + proxies: list[dict[str, Any]] | None = None, + region: str | None = None, + user_metadata: dict[str, Any] | None = None, +) -> CreateSessionOutput: + """Creates a new browser session with specified settings.""" + if not api_key or not api_key.strip(): + return CreateSessionOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = {"projectId": project_id} + if extension_id is not None: + body["extensionId"] = extension_id + if browser_settings is not None: + body["browserSettings"] = browser_settings + if timeout is not None: + body["timeout"] = timeout + if keep_alive is not None: + body["keepAlive"] = keep_alive + if proxies is not None: + body["proxies"] = proxies + if region is not None: + body["region"] = region + if user_metadata is not None: + body["userMetadata"] = user_metadata + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/sessions", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreateSessionOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateSessionOutput(success=False, error=f"Call failed: {exc}") + + return CreateSessionOutput( + success=True, + id=data.get("id"), + project_id=data.get("projectId"), + status=data.get("status"), + created_at=data.get("createdAt"), + region=data.get("region"), + connect_url=data.get("connectUrl"), + ) + + +@tool(args_schema=ListProjectsInput) +@serialize_pydantic_return +async def list_projects( + api_key: str, +) -> ListProjectsOutput: + """Lists all projects in the Browserbase account.""" + if not api_key or not api_key.strip(): + return ListProjectsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/projects", + headers=_headers(api_key), + ) + if response.status_code != 200: + return ListProjectsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListProjectsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListProjectsOutput(success=False, error=f"Call failed: {exc}") + + projects = [ + ProjectSummary(id=p.get("id"), name=p.get("name")) + for p in (data if isinstance(data, list) else []) + ] + return ListProjectsOutput(success=True, projects=projects) diff --git a/src/modulex_integrations/tools/canvas/README.md b/src/modulex_integrations/tools/canvas/README.md new file mode 100644 index 0000000..d940fad --- /dev/null +++ b/src/modulex_integrations/tools/canvas/README.md @@ -0,0 +1,35 @@ +# Canvas LMS + +Learning management system integration for course, assignment, and user management via the Canvas REST API (`https://{your-domain}/api/v1`). + +## Authentication + +### Canvas OAuth Token + Domain + +Canvas LMS is self-hosted (each institution runs its own instance), so both your instance domain and an access token are required. + +- **Canvas Domain**: Your Canvas instance hostname (e.g. `myschool.instructure.com`). Required env var: `CANVAS_DOMAIN`. +- **Access Token**: Generate from Account > Settings > Approved Integrations in your Canvas instance. Required env var: `CANVAS_ACCESS_TOKEN` (format: `7~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`). +- Guide: [Managing API Access Tokens](https://community.canvaslms.com/t5/Admin-Guide/How-do-I-manage-API-access-tokens-as-an-admin/ta-p/89) + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `list_accounts` | List Canvas accounts accessible to the authenticated user. | _(none)_ | +| `list_assignments` | Retrieve a list of assignments for a user in a specific course. | `user_id`, `course_id` | +| `list_courses` | List all courses associated with a given user. | `user_id` | +| `search_course_content` | Search for content in a course using Canvas smart search. | `course_id`, `query` | +| `update_assignment` | Update an existing assignment in a course. | `course_id`, `assignment_id` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential (token-style injection). + +## Limits & Quotas + +- **Rate limits**: Canvas enforces per-user rate limits (typically 700 requests per 10 minutes for the default configuration, varies by institution). +- **Pagination**: List endpoints may return paginated results; current implementation fetches the first page. +- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/canvas/__init__.py b/src/modulex_integrations/tools/canvas/__init__.py new file mode 100644 index 0000000..07d3602 --- /dev/null +++ b/src/modulex_integrations/tools/canvas/__init__.py @@ -0,0 +1,27 @@ +"""Canvas LMS integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.canvas.manifest import manifest +from modulex_integrations.tools.canvas.tools import ( + list_accounts, + list_assignments, + list_courses, + search_course_content, + update_assignment, +) + +TOOLS = ( + list_accounts, + list_assignments, + list_courses, + search_course_content, + update_assignment, +) + +__all__ = [ + "TOOLS", + "list_accounts", + "list_assignments", + "list_courses", + "manifest", + "search_course_content", + "update_assignment", +] diff --git a/src/modulex_integrations/tools/canvas/dependencies.toml b/src/modulex_integrations/tools/canvas/dependencies.toml new file mode 100644 index 0000000..bd62e5e --- /dev/null +++ b/src/modulex_integrations/tools/canvas/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the canvas integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/canvas/manifest.py b/src/modulex_integrations/tools/canvas/manifest.py new file mode 100644 index 0000000..b1e2bec --- /dev/null +++ b/src/modulex_integrations/tools/canvas/manifest.py @@ -0,0 +1,159 @@ +"""Canvas LMS integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + CustomAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="canvas", + display_name="Canvas LMS", + description="Learning management system for course, assignment, and user management via the Canvas REST API.", + version="1.0.0", + author="ModuleX", + logo="modulex:canvas-themed", + app_url="https://www.instructure.com/canvas", + categories=["Education", "Learning Management"], + actions=[ + ActionDefinition( + name="list_accounts", + description="List Canvas accounts accessible to the authenticated user.", + parameters={}, + ), + ActionDefinition( + name="list_assignments", + description="Retrieve a list of assignments for a user in a specific course.", + parameters={ + "user_id": ParameterDef( + type="string", + description="The ID of the user whose assignments to list.", + required=True, + ), + "course_id": ParameterDef( + type="string", + description="The ID of the course to list assignments from.", + required=True, + ), + }, + ), + ActionDefinition( + name="list_courses", + description="List all courses associated with a given user.", + parameters={ + "user_id": ParameterDef( + type="string", + description="The ID of the user whose courses to list.", + required=True, + ), + }, + ), + ActionDefinition( + name="search_course_content", + description="Search for content in a course using Canvas smart search.", + parameters={ + "course_id": ParameterDef( + type="string", + description="The ID of the course to search within.", + required=True, + ), + "query": ParameterDef( + type="string", + description="The search query string.", + required=True, + ), + }, + ), + ActionDefinition( + name="update_assignment", + description="Update an existing assignment in a course.", + parameters={ + "course_id": ParameterDef( + type="string", + description="The ID of the course containing the assignment.", + required=True, + ), + "assignment_id": ParameterDef( + type="string", + description="The ID of the assignment to update.", + required=True, + ), + "name": ParameterDef( + type="string", + description="The new name of the assignment.", + ), + "description": ParameterDef( + type="string", + description="The new description of the assignment (supports HTML).", + ), + "submission_type": ParameterDef( + type="string", + description="Submission type: online_quiz, none, on_paper, discussion_topic, external_tool, online_upload, online_text_entry, online_url, media_recording, student_annotation.", + ), + "notify_of_update": ParameterDef( + type="boolean", + description="Whether to notify students of the update.", + ), + "points_possible": ParameterDef( + type="integer", + description="Maximum points possible on the assignment.", + ), + "grading_type": ParameterDef( + type="string", + description="Grading strategy: pass_fail, percent, letter_grade, gpa_scale, points, not_graded.", + ), + "due_at": ParameterDef( + type="string", + description="Due date/time in ISO 8601 format (e.g. 2014-10-21T18:48:00Z).", + ), + "omit_from_final_grade": ParameterDef( + type="boolean", + description="Whether to omit this assignment from the student's final grade.", + ), + "allowed_attempts": ParameterDef( + type="integer", + description="Number of submission attempts allowed (-1 for unlimited).", + ), + }, + ), + ], + auth_schemas=[ + CustomAuthSchema( + display_name="Canvas OAuth Token + Domain", + description=( + "Authenticate using a Canvas access token and your instance domain. " + "Canvas LMS is self-hosted, so both the domain and token are required." + ), + setup_instructions=[ + "Log into your Canvas instance.", + "Go to Account > Settings > Approved Integrations (or generate a new access token).", + "Copy your access token and note your Canvas domain (e.g. myschool.instructure.com).", + ], + setup_environment_variables=[ + EnvVar( + name="CANVAS_DOMAIN", + display_name="Canvas Domain", + description="Your Canvas instance domain (e.g. myschool.instructure.com)", + required=True, + sensitive=False, + sample_format="myschool.instructure.com", + ), + EnvVar( + name="CANVAS_ACCESS_TOKEN", + display_name="Access Token", + description="Your Canvas API access token", + required=True, + sensitive=True, + sample_format="7~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://community.canvaslms.com/t5/Admin-Guide/How-do-I-manage-API-access-tokens-as-an-admin/ta-p/89", + ), + ], + ), + ], +) diff --git a/src/modulex_integrations/tools/canvas/outputs.py b/src/modulex_integrations/tools/canvas/outputs.py new file mode 100644 index 0000000..63e993e --- /dev/null +++ b/src/modulex_integrations/tools/canvas/outputs.py @@ -0,0 +1,97 @@ +"""Pydantic response models for the canvas integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AccountOption", + "AssignmentSummary", + "CourseSummary", + "ListAccountsOutput", + "ListAssignmentsOutput", + "ListCoursesOutput", + "SearchCourseContentOutput", + "SearchResultItem", + "UpdateAssignmentOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class AccountOption(_Base): + """A Canvas account option.""" + + id: int | None = None + name: str | None = None + + +class AssignmentSummary(_Base): + """A Canvas assignment.""" + + id: int | None = None + name: str | None = None + description: str | None = None + due_at: str | None = None + points_possible: float | None = None + grading_type: str | None = None + submission_types: list[str] = Field(default_factory=list) + course_id: int | None = None + allowed_attempts: int | None = None + omit_from_final_grade: bool | None = None + + +class CourseSummary(_Base): + """A Canvas course.""" + + id: int | None = None + name: str | None = None + course_code: str | None = None + workflow_state: str | None = None + enrollment_term_id: int | None = None + + +class SearchResultItem(_Base): + """A search result from Canvas smart search.""" + + content_id: int | None = None + content_type: str | None = None + title: str | None = None + body: str | None = None + html_url: str | None = None + distance: float | None = None + readable_type: str | None = None + relevance: float | None = None + + +class ListAccountsOutput(_Base): + success: bool + error: str | None = None + accounts: list[AccountOption] = Field(default_factory=list) + + +class ListAssignmentsOutput(_Base): + success: bool + error: str | None = None + assignments: list[AssignmentSummary] = Field(default_factory=list) + + +class ListCoursesOutput(_Base): + success: bool + error: str | None = None + courses: list[CourseSummary] = Field(default_factory=list) + + +class SearchCourseContentOutput(_Base): + success: bool + error: str | None = None + results: list[SearchResultItem] = Field(default_factory=list) + + +class UpdateAssignmentOutput(_Base): + success: bool + error: str | None = None + assignment: AssignmentSummary | None = None diff --git a/src/modulex_integrations/tools/canvas/tests/__init__.py b/src/modulex_integrations/tools/canvas/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/canvas/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/canvas/tests/test_canvas.py b/src/modulex_integrations/tools/canvas/tests/test_canvas.py new file mode 100644 index 0000000..afe5332 --- /dev/null +++ b/src/modulex_integrations/tools/canvas/tests/test_canvas.py @@ -0,0 +1,211 @@ +"""Happy-path tests for every canvas @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.canvas import ( + TOOLS, + list_accounts, + list_assignments, + list_courses, + manifest, + search_course_content, + update_assignment, +) +from modulex_integrations.tools.canvas.outputs import ( + ListAccountsOutput, + ListAssignmentsOutput, + ListCoursesOutput, + SearchCourseContentOutput, + UpdateAssignmentOutput, +) + +API = "https://myschool.instructure.com/api/v1" + +_AUTH: dict[str, Any] = { + "auth_type": "custom", + "auth_data": { + "domain": "myschool.instructure.com", + "access_token": "fake_token", + }, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_5_actions(self) -> None: + assert len(manifest.actions) == 5 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_custom_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"custom"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_accounts(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/accounts", + json=[ + # TODO: fill in a representative response shape from the Canvas API docs + {"id": 1, "name": "Default Account"}, + ], + ) + + result_dict = await list_accounts.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListAccountsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.accounts) == 1 + assert result.accounts[0].id == 1 + + +@pytest.mark.asyncio +async def test_list_assignments(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/42/courses/101/assignments", + json=[ + # TODO: fill in a representative response shape from the Canvas API docs + { + "id": 1, + "name": "Homework 1", + "due_at": "2024-10-21T18:48:00Z", + "points_possible": 100, + "grading_type": "points", + "submission_types": ["online_upload"], + "course_id": 101, + }, + ], + ) + + result_dict = await list_assignments.ainvoke(_args(user_id="42", course_id="101")) + + assert isinstance(result_dict, dict) + result = ListAssignmentsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.assignments) == 1 + assert result.assignments[0].name == "Homework 1" + + +@pytest.mark.asyncio +async def test_list_courses(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/42/courses", + json=[ + # TODO: fill in a representative response shape from the Canvas API docs + { + "id": 101, + "name": "Introduction to AI", + "course_code": "CS101", + "workflow_state": "available", + "enrollment_term_id": 1, + }, + ], + ) + + result_dict = await list_courses.ainvoke(_args(user_id="42")) + + assert isinstance(result_dict, dict) + result = ListCoursesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.courses) == 1 + assert result.courses[0].name == "Introduction to AI" + + +@pytest.mark.asyncio +async def test_search_course_content(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/courses/101/smartsearch?q=machine+learning", + json=[ + # TODO: fill in a representative response shape from the Canvas API docs + { + "content_id": 5, + "content_type": "WikiPage", + "title": "Machine Learning Basics", + "body": "An introduction to ML...", + "html_url": "https://myschool.instructure.com/courses/101/pages/ml-basics", + "relevance": 0.95, + }, + ], + ) + + result_dict = await search_course_content.ainvoke( + _args(course_id="101", query="machine learning") + ) + + assert isinstance(result_dict, dict) + result = SearchCourseContentOutput.model_validate(result_dict) + assert result.success is True + assert len(result.results) == 1 + assert result.results[0].title == "Machine Learning Basics" + + +@pytest.mark.asyncio +async def test_update_assignment(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/courses/101/assignments/1", + json={ + # TODO: fill in a representative response shape from the Canvas API docs + "id": 1, + "name": "Updated Homework", + "description": "New description", + "due_at": "2024-11-01T23:59:00Z", + "points_possible": 150, + "grading_type": "points", + "submission_types": ["online_upload"], + "course_id": 101, + "allowed_attempts": 3, + "omit_from_final_grade": False, + }, + ) + + result_dict = await update_assignment.ainvoke( + _args( + course_id="101", + assignment_id="1", + name="Updated Homework", + points_possible=150, + ) + ) + + assert isinstance(result_dict, dict) + result = UpdateAssignmentOutput.model_validate(result_dict) + assert result.success is True + assert result.assignment is not None + assert result.assignment.name == "Updated Homework" + assert result.assignment.points_possible == 150 + + +# --- Failure-path tests --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_accounts_empty_credentials() -> None: + """Empty credentials should return success=False without hitting the wire.""" + result_dict = await list_accounts.ainvoke( + {"auth_type": "custom", "auth_data": {"domain": "", "access_token": ""}} + ) + + assert isinstance(result_dict, dict) + result = ListAccountsOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None diff --git a/src/modulex_integrations/tools/canvas/tools.py b/src/modulex_integrations/tools/canvas/tools.py new file mode 100644 index 0000000..da57dcb --- /dev/null +++ b/src/modulex_integrations/tools/canvas/tools.py @@ -0,0 +1,366 @@ +"""Canvas LMS LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.canvas.outputs import ( + AccountOption, + AssignmentSummary, + CourseSummary, + ListAccountsOutput, + ListAssignmentsOutput, + ListCoursesOutput, + SearchCourseContentOutput, + SearchResultItem, + UpdateAssignmentOutput, +) + +__all__ = [ + "list_accounts", + "list_assignments", + "list_courses", + "search_course_content", + "update_assignment", +] + +_TIMEOUT = 30.0 + + +def _base_url(auth_data: dict[str, Any]) -> str: + domain = auth_data.get("domain", "").strip().rstrip("/") + if not domain: + return "" + if not domain.startswith("http"): + domain = f"https://{domain}" + return f"{domain}/api/v1" + + +def _headers(auth_data: dict[str, Any]) -> dict[str, str]: + token = auth_data.get("access_token", "") + return { + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + +def _validate_auth(auth_data: dict[str, Any]) -> str | None: + domain = auth_data.get("domain", "") + token = auth_data.get("access_token", "") + if not domain or not str(domain).strip(): + return "Canvas domain is missing. Please configure your Canvas instance domain." + if not token or not str(token).strip(): + return "Access token is missing. Please configure a valid Canvas access token." + return None + + +# --- Input schemas -------------------------------------------------------- + + +class ListAccountsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class ListAssignmentsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str = Field(description="The ID of the user whose assignments to list.") + course_id: str = Field(description="The ID of the course to list assignments from.") + + +class ListCoursesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str = Field(description="The ID of the user whose courses to list.") + + +class SearchCourseContentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + course_id: str = Field(description="The ID of the course to search within.") + query: str = Field(description="The search query string.") + + +class UpdateAssignmentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + course_id: str = Field(description="The ID of the course containing the assignment.") + assignment_id: str = Field(description="The ID of the assignment to update.") + name: str | None = Field(default=None, description="The new name of the assignment.") + description: str | None = Field(default=None, description="The new description of the assignment (supports HTML).") + submission_type: str | None = Field(default=None, description="Submission type: online_quiz, none, on_paper, discussion_topic, external_tool, online_upload, online_text_entry, online_url, media_recording, student_annotation.") + notify_of_update: bool | None = Field(default=None, description="Whether to notify students of the update.") + points_possible: int | None = Field(default=None, description="Maximum points possible on the assignment.") + grading_type: str | None = Field(default=None, description="Grading strategy: pass_fail, percent, letter_grade, gpa_scale, points, not_graded.") + due_at: str | None = Field(default=None, description="Due date/time in ISO 8601 format (e.g. 2014-10-21T18:48:00Z).") + omit_from_final_grade: bool | None = Field(default=None, description="Whether to omit this assignment from the student's final grade.") + allowed_attempts: int | None = Field(default=None, description="Number of submission attempts allowed (-1 for unlimited).") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=ListAccountsInput) +@serialize_pydantic_return +async def list_accounts( + auth_type: str, + auth_data: dict[str, Any], +) -> ListAccountsOutput: + """List Canvas accounts accessible to the authenticated user.""" + err = _validate_auth(auth_data) + if err: + return ListAccountsOutput(success=False, error=err) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base}/accounts", + headers=_headers(auth_data), + ) + if response.status_code != 200: + return ListAccountsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListAccountsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAccountsOutput(success=False, error=f"Call failed: {exc}") + + accounts = [ + AccountOption(id=a.get("id"), name=a.get("name")) + for a in data + if isinstance(a, dict) + ] + return ListAccountsOutput(success=True, accounts=accounts) + + +@tool(args_schema=ListAssignmentsInput) +@serialize_pydantic_return +async def list_assignments( + auth_type: str, + auth_data: dict[str, Any], + user_id: str, + course_id: str, +) -> ListAssignmentsOutput: + """Retrieve a list of assignments for a user in a specific course.""" + err = _validate_auth(auth_data) + if err: + return ListAssignmentsOutput(success=False, error=err) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base}/users/{user_id}/courses/{course_id}/assignments", + headers=_headers(auth_data), + ) + if response.status_code != 200: + return ListAssignmentsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListAssignmentsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAssignmentsOutput(success=False, error=f"Call failed: {exc}") + + assignments = [ + AssignmentSummary( + id=a.get("id"), + name=a.get("name"), + description=a.get("description"), + due_at=a.get("due_at"), + points_possible=a.get("points_possible"), + grading_type=a.get("grading_type"), + submission_types=a.get("submission_types") or [], + course_id=a.get("course_id"), + allowed_attempts=a.get("allowed_attempts"), + omit_from_final_grade=a.get("omit_from_final_grade"), + ) + for a in data + if isinstance(a, dict) + ] + return ListAssignmentsOutput(success=True, assignments=assignments) + + +@tool(args_schema=ListCoursesInput) +@serialize_pydantic_return +async def list_courses( + auth_type: str, + auth_data: dict[str, Any], + user_id: str, +) -> ListCoursesOutput: + """List all courses associated with a given user.""" + err = _validate_auth(auth_data) + if err: + return ListCoursesOutput(success=False, error=err) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base}/users/{user_id}/courses", + headers=_headers(auth_data), + ) + if response.status_code != 200: + return ListCoursesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListCoursesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCoursesOutput(success=False, error=f"Call failed: {exc}") + + courses = [ + CourseSummary( + id=c.get("id"), + name=c.get("name"), + course_code=c.get("course_code"), + workflow_state=c.get("workflow_state"), + enrollment_term_id=c.get("enrollment_term_id"), + ) + for c in data + if isinstance(c, dict) + ] + return ListCoursesOutput(success=True, courses=courses) + + +@tool(args_schema=SearchCourseContentInput) +@serialize_pydantic_return +async def search_course_content( + auth_type: str, + auth_data: dict[str, Any], + course_id: str, + query: str, +) -> SearchCourseContentOutput: + """Search for content in a course using Canvas smart search.""" + err = _validate_auth(auth_data) + if err: + return SearchCourseContentOutput(success=False, error=err) + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base}/courses/{course_id}/smartsearch", + headers=_headers(auth_data), + params={"q": query}, + ) + if response.status_code != 200: + return SearchCourseContentOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchCourseContentOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchCourseContentOutput(success=False, error=f"Call failed: {exc}") + + results = [ + SearchResultItem( + content_id=r.get("content_id"), + content_type=r.get("content_type"), + title=r.get("title"), + body=r.get("body"), + html_url=r.get("html_url"), + distance=r.get("distance"), + readable_type=r.get("readable_type"), + relevance=r.get("relevance"), + ) + for r in (data if isinstance(data, list) else data.get("results", [])) + if isinstance(r, dict) + ] + return SearchCourseContentOutput(success=True, results=results) + + +@tool(args_schema=UpdateAssignmentInput) +@serialize_pydantic_return +async def update_assignment( + auth_type: str, + auth_data: dict[str, Any], + course_id: str, + assignment_id: str, + name: str | None = None, + description: str | None = None, + submission_type: str | None = None, + notify_of_update: bool | None = None, + points_possible: int | None = None, + grading_type: str | None = None, + due_at: str | None = None, + omit_from_final_grade: bool | None = None, + allowed_attempts: int | None = None, +) -> UpdateAssignmentOutput: + """Update an existing assignment in a course.""" + err = _validate_auth(auth_data) + if err: + return UpdateAssignmentOutput(success=False, error=err) + + assignment_body: dict[str, Any] = {} + if name is not None: + assignment_body["name"] = name + if description is not None: + assignment_body["description"] = description + if submission_type is not None: + assignment_body["submission_types"] = [submission_type] + if notify_of_update is not None: + assignment_body["notify_of_update"] = notify_of_update + if points_possible is not None: + assignment_body["points_possible"] = points_possible + if grading_type is not None: + assignment_body["grading_type"] = grading_type + if due_at is not None: + assignment_body["due_at"] = due_at + if omit_from_final_grade is not None: + assignment_body["omit_from_final_grade"] = omit_from_final_grade + if allowed_attempts is not None: + assignment_body["allowed_attempts"] = allowed_attempts + + if not assignment_body: + return UpdateAssignmentOutput( + success=False, + error="At least one field to update must be provided.", + ) + + base = _base_url(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put( + f"{base}/courses/{course_id}/assignments/{assignment_id}", + headers=_headers(auth_data), + json={"assignment": assignment_body}, + ) + if response.status_code != 200: + return UpdateAssignmentOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return UpdateAssignmentOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateAssignmentOutput(success=False, error=f"Call failed: {exc}") + + a = data + return UpdateAssignmentOutput( + success=True, + assignment=AssignmentSummary( + id=a.get("id"), + name=a.get("name"), + description=a.get("description"), + due_at=a.get("due_at"), + points_possible=a.get("points_possible"), + grading_type=a.get("grading_type"), + submission_types=a.get("submission_types") or [], + course_id=a.get("course_id"), + allowed_attempts=a.get("allowed_attempts"), + omit_from_final_grade=a.get("omit_from_final_grade"), + ), + ) diff --git a/src/modulex_integrations/tools/datadog/README.md b/src/modulex_integrations/tools/datadog/README.md new file mode 100644 index 0000000..cbc466e --- /dev/null +++ b/src/modulex_integrations/tools/datadog/README.md @@ -0,0 +1,41 @@ +# Datadog + +Infrastructure monitoring, log management, and application performance platform via the Datadog REST API (`api.{region}/api`). + +## Authentication + +### API Key Authentication + +- Go to [Organization Settings > API Keys](https://app.datadoghq.com/organization-settings/api-keys) and create or copy an API key. +- Go to [Organization Settings > Application Keys](https://app.datadoghq.com/organization-settings/application-keys) and create or copy an Application key. +- Required env vars: `DATADOG_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) and `DATADOG_APPLICATION_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`). +- Both keys are required for all API calls except region detection (`get_account_info` only needs the API key). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `get_account_info` | Detect the Datadog region for the connected account by validating the API key across all regions | (none) | +| `get_metric_data` | Query time-series metric data for analyzing trends and system performance | `region`, `query`, `from_ts`, `to_ts` | +| `post_metric_data` | Post custom time-series metric data points to Datadog | `region`, `metric`, `points` | +| `search_dashboards` | List and search Datadog dashboards with their IDs, titles, and URLs | `region` | +| `search_events` | Search Datadog events including monitor state changes, deployment markers, and error spikes | `region` | +| `search_hosts` | Search monitored infrastructure hosts with filtering by tag, name, or partial match | `region` | +| `search_incidents` | Search Datadog incidents by state, severity, and metadata | `region` | +| `search_logs` | Search Datadog logs matching a query with support for facets and time ranges | `region`, `query` | +| `search_metrics` | List available Datadog metric names, optionally filtered by host | `region` | +| `search_monitors` | Search Datadog monitors (alerting rules) including status, thresholds, and conditions | `region` | +| `search_services` | List services from Datadog Service Catalog with ownership, metadata, and team info | `region` | + +Every tool takes additional `api_key` and `application_key` parameters that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Rate limits**: Datadog enforces per-endpoint rate limits; default is 300 requests/minute for most endpoints, 60/min for logs search, and 120/hour for metric submission. +- **Pagination**: Most list endpoints support pagination via `count`/`start` or `page`/`page_size` parameters. +- **Regions**: The API base URL varies by account region. Use `get_account_info` to auto-detect the correct region before calling other tools. +- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/datadog/__init__.py b/src/modulex_integrations/tools/datadog/__init__.py new file mode 100644 index 0000000..000820f --- /dev/null +++ b/src/modulex_integrations/tools/datadog/__init__.py @@ -0,0 +1,45 @@ +"""Datadog integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.datadog.manifest import manifest +from modulex_integrations.tools.datadog.tools import ( + get_account_info, + get_metric_data, + post_metric_data, + search_dashboards, + search_events, + search_hosts, + search_incidents, + search_logs, + search_metrics, + search_monitors, + search_services, +) + +TOOLS = ( + get_account_info, + get_metric_data, + post_metric_data, + search_dashboards, + search_events, + search_hosts, + search_incidents, + search_logs, + search_metrics, + search_monitors, + search_services, +) + +__all__ = [ + "TOOLS", + "get_account_info", + "get_metric_data", + "manifest", + "post_metric_data", + "search_dashboards", + "search_events", + "search_hosts", + "search_incidents", + "search_logs", + "search_metrics", + "search_monitors", + "search_services", +] diff --git a/src/modulex_integrations/tools/datadog/dependencies.toml b/src/modulex_integrations/tools/datadog/dependencies.toml new file mode 100644 index 0000000..5e9b954 --- /dev/null +++ b/src/modulex_integrations/tools/datadog/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the datadog integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/datadog/manifest.py b/src/modulex_integrations/tools/datadog/manifest.py new file mode 100644 index 0000000..d24d5fb --- /dev/null +++ b/src/modulex_integrations/tools/datadog/manifest.py @@ -0,0 +1,326 @@ +"""Datadog integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="datadog", + display_name="Datadog", + description="Infrastructure monitoring, log management, and application performance platform", + version="1.0.0", + author="ModuleX", + logo="modulex:datadog-themed", + app_url="https://www.datadoghq.com", + categories=["Monitoring & Observability", "Developer Tools & Infrastructure"], + actions=[ + ActionDefinition( + name="get_account_info", + description="Detect the Datadog region for the connected account by validating the API key across all regions", + parameters={}, + ), + ActionDefinition( + name="get_metric_data", + description="Query time-series metric data for analyzing trends and system performance", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "query": ParameterDef( + type="string", + description="Metric query string (e.g. avg:system.cpu.user{*} or sum:my.metric{env:prod} by {host})", + required=True, + ), + "from_ts": ParameterDef( + type="integer", + description="Start of the query window as POSIX timestamp in seconds", + required=True, + ), + "to_ts": ParameterDef( + type="integer", + description="End of the query window as POSIX timestamp in seconds", + required=True, + ), + }, + ), + ActionDefinition( + name="post_metric_data", + description="Post custom time-series metric data points to Datadog", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "metric": ParameterDef( + type="string", + description="The name of the timeseries metric", + required=True, + ), + "points": ParameterDef( + type="object", + description="Points as a JSON object where keys are Unix timestamps (seconds) and values are numeric (e.g. {\"1640995200\": 1.0})", + required=True, + ), + }, + ), + ActionDefinition( + name="search_dashboards", + description="List and search Datadog dashboards with their IDs, titles, and URLs", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "filter_shared": ParameterDef( + type="boolean", + description="If true, only return dashboards that are shared", + ), + "count": ParameterDef( + type="integer", + description="Maximum number of dashboards to return", + ), + "start": ParameterDef( + type="integer", + description="Offset for pagination", + ), + }, + ), + ActionDefinition( + name="search_events", + description="Search Datadog events including monitor state changes, deployment markers, and error spikes", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "start": ParameterDef( + type="integer", + description="POSIX timestamp (seconds) for the start of the query window; defaults to 24 hours ago", + ), + "end": ParameterDef( + type="integer", + description="POSIX timestamp (seconds) for the end of the query window; defaults to now", + ), + "priority": ParameterDef( + type="string", + description="Filter by event priority: normal or low", + ), + "sources": ParameterDef( + type="string", + description="Comma-separated list of sources to filter events (e.g. nagios,hudson)", + ), + "tags": ParameterDef( + type="string", + description="Comma-separated list of tags to filter events (e.g. env:prod,role:db)", + ), + }, + ), + ActionDefinition( + name="search_hosts", + description="Search monitored infrastructure hosts with filtering by tag, name, or partial match", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "filter": ParameterDef( + type="string", + description="Filter hosts by name, alias, or tag (e.g. env:production or host:web-01)", + ), + "sort_field": ParameterDef( + type="string", + description="Field to sort hosts by: status, apps, cpu, iowait, or load", + ), + "sort_dir": ParameterDef( + type="string", + description="Direction of sort: asc or desc", + ), + "count": ParameterDef( + type="integer", + description="Number of hosts to return (max 1000)", + ), + }, + ), + ActionDefinition( + name="search_incidents", + description="Search Datadog incidents by state, severity, and metadata", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "query": ParameterDef( + type="string", + description="Search query to filter incidents using field:value syntax (e.g. state:active or severity:SEV-1)", + ), + "page_size": ParameterDef( + type="integer", + description="Number of incidents per page (default 10)", + ), + "page_offset": ParameterDef( + type="integer", + description="Offset for pagination", + ), + }, + ), + ActionDefinition( + name="search_logs", + description="Search Datadog logs matching a query with support for facets and time ranges", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "query": ParameterDef( + type="string", + description="Search query following log search syntax (e.g. service:web-app status:error)", + required=True, + default="*", + ), + "from_time": ParameterDef( + type="string", + description="Minimum timestamp for logs; supports date math (now-15m), ISO-8601, or epoch ms; defaults to 15 minutes ago", + ), + "to_time": ParameterDef( + type="string", + description="Maximum timestamp for logs; supports date math (now), ISO-8601, or epoch ms; defaults to now", + ), + "indexes": ParameterDef( + type="array", + description="List of log index names to search (defaults to all indexes); element type: string", + ), + "limit": ParameterDef( + type="integer", + description="Maximum number of logs to return per page (default 10, max 1000)", + ), + "sort": ParameterDef( + type="string", + description="Sort order for results: -timestamp (newest first) or timestamp (oldest first)", + ), + }, + ), + ActionDefinition( + name="search_metrics", + description="List available Datadog metric names, optionally filtered by host", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "host": ParameterDef( + type="string", + description="Filter metrics by host name", + ), + }, + ), + ActionDefinition( + name="search_monitors", + description="Search Datadog monitors (alerting rules) including status, thresholds, and conditions", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "query": ParameterDef( + type="string", + description="Filter monitors by name, tag, or other attributes (e.g. tag:env:production or type:metric)", + ), + "tags": ParameterDef( + type="string", + description="Comma-separated list of tags to filter monitors (e.g. env:prod,team:backend)", + ), + "page": ParameterDef( + type="integer", + description="Page number to return (0-indexed)", + ), + "page_size": ParameterDef( + type="integer", + description="Number of monitors per page (default 100)", + ), + }, + ), + ActionDefinition( + name="search_services", + description="List services from Datadog Service Catalog with ownership, metadata, and team info", + parameters={ + "region": ParameterDef( + type="string", + description="The regional site for the Datadog account (e.g. datadoghq.com, us3.datadoghq.com, us5.datadoghq.com, datadoghq.eu, ddog-gov.com)", + required=True, + ), + "page_size": ParameterDef( + type="integer", + description="Number of services per page (default 10)", + ), + "page_number": ParameterDef( + type="integer", + description="Page number (0-indexed)", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="Datadog API Keys", + description="Authenticate using your Datadog API key and Application key", + setup_instructions=[ + "Go to https://app.datadoghq.com/organization-settings/api-keys and sign in", + "Copy or create a new API key", + "Go to https://app.datadoghq.com/organization-settings/application-keys", + "Copy or create a new Application key", + "Paste both keys below", + ], + setup_environment_variables=[ + EnvVar( + name="DATADOG_API_KEY", + display_name="API Key", + description="Your Datadog API key from Organization Settings > API Keys", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://app.datadoghq.com/organization-settings/api-keys", + ), + EnvVar( + name="DATADOG_APPLICATION_KEY", + display_name="Application Key", + description="Your Datadog Application key from Organization Settings > Application Keys", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://app.datadoghq.com/organization-settings/application-keys", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.datadoghq.com/api/v1/validate", + method="GET", + headers={"DD-API-KEY": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["valid"], + ), + cost_level="free", + description="Validates the API key against the US1 region", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/datadog/outputs.py b/src/modulex_integrations/tools/datadog/outputs.py new file mode 100644 index 0000000..d88fc45 --- /dev/null +++ b/src/modulex_integrations/tools/datadog/outputs.py @@ -0,0 +1,98 @@ +"""Pydantic response models for the datadog integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "GetAccountInfoOutput", + "GetMetricDataOutput", + "PostMetricDataOutput", + "SearchDashboardsOutput", + "SearchEventsOutput", + "SearchHostsOutput", + "SearchIncidentsOutput", + "SearchLogsOutput", + "SearchMetricsOutput", + "SearchMonitorsOutput", + "SearchServicesOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class GetAccountInfoOutput(_Base): + success: bool + error: str | None = None + region: str | None = None + label: str | None = None + api_url: str | None = None + + +class GetMetricDataOutput(_Base): + success: bool + error: str | None = None + series: list[dict[str, Any]] = Field(default_factory=list) + from_date: int | None = None + to_date: int | None = None + query: str | None = None + + +class PostMetricDataOutput(_Base): + success: bool + error: str | None = None + errors: list[str] = Field(default_factory=list) + + +class SearchDashboardsOutput(_Base): + success: bool + error: str | None = None + dashboards: list[dict[str, Any]] = Field(default_factory=list) + + +class SearchEventsOutput(_Base): + success: bool + error: str | None = None + events: list[dict[str, Any]] = Field(default_factory=list) + + +class SearchHostsOutput(_Base): + success: bool + error: str | None = None + host_list: list[dict[str, Any]] = Field(default_factory=list) + total_matching: int | None = None + + +class SearchIncidentsOutput(_Base): + success: bool + error: str | None = None + incidents: list[dict[str, Any]] = Field(default_factory=list) + + +class SearchLogsOutput(_Base): + success: bool + error: str | None = None + logs: list[dict[str, Any]] = Field(default_factory=list) + + +class SearchMetricsOutput(_Base): + success: bool + error: str | None = None + metrics: list[str] = Field(default_factory=list) + + +class SearchMonitorsOutput(_Base): + success: bool + error: str | None = None + monitors: list[dict[str, Any]] = Field(default_factory=list) + + +class SearchServicesOutput(_Base): + success: bool + error: str | None = None + services: list[dict[str, Any]] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/datadog/tests/__init__.py b/src/modulex_integrations/tools/datadog/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/datadog/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/datadog/tests/test_datadog.py b/src/modulex_integrations/tools/datadog/tests/test_datadog.py new file mode 100644 index 0000000..1c6a93d --- /dev/null +++ b/src/modulex_integrations/tools/datadog/tests/test_datadog.py @@ -0,0 +1,279 @@ +"""Happy-path tests for every datadog @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.datadog import ( + TOOLS, + get_account_info, + get_metric_data, + manifest, + post_metric_data, + search_dashboards, + search_events, + search_hosts, + search_incidents, + search_logs, + search_metrics, + search_monitors, + search_services, +) +from modulex_integrations.tools.datadog.outputs import ( + GetAccountInfoOutput, + GetMetricDataOutput, + PostMetricDataOutput, + SearchDashboardsOutput, + SearchEventsOutput, + SearchHostsOutput, + SearchIncidentsOutput, + SearchLogsOutput, + SearchMetricsOutput, + SearchMonitorsOutput, + SearchServicesOutput, +) + +_API_KEY = "fake-api-key" +_APP_KEY = "fake-application-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, application_key=_APP_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_11_actions(self) -> None: + assert len(manifest.actions) == 11 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_account_info(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/validate", + json={"valid": True}, + ) + + result_dict = await get_account_info.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetAccountInfoOutput.model_validate(result_dict) + assert result.success is True + assert result.region == "datadoghq.com" + + +@pytest.mark.asyncio +async def test_get_metric_data(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/query?query=avg%3Asystem.cpu.user%7B%2A%7D&from=1640995200&to=1640998800", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "series": [{"metric": "system.cpu.user", "pointlist": [[1640995200, 42.0]]}], + "from_date": 1640995200, + "to_date": 1640998800, + "query": "avg:system.cpu.user{*}", + }, + ) + + result_dict = await get_metric_data.ainvoke( + _args(region="datadoghq.com", query="avg:system.cpu.user{*}", from_ts=1640995200, to_ts=1640998800) + ) + + assert isinstance(result_dict, dict) + result = GetMetricDataOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_post_metric_data(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url="https://api.datadoghq.com/api/v2/series", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "errors": [], + }, + ) + + result_dict = await post_metric_data.ainvoke( + _args(region="datadoghq.com", metric="custom.metric", points={"1640995200": 1.0}) + ) + + assert isinstance(result_dict, dict) + result = PostMetricDataOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_dashboards(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/dashboard", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "dashboards": [{"id": "abc-123", "title": "My Dashboard"}], + }, + ) + + result_dict = await search_dashboards.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchDashboardsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_events(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/events?start=1640995200&end=1640998800", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "events": [{"id": 1, "title": "Test event"}], + }, + ) + + result_dict = await search_events.ainvoke( + _args(region="datadoghq.com", start=1640995200, end=1640998800) + ) + + assert isinstance(result_dict, dict) + result = SearchEventsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_hosts(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/hosts", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "host_list": [{"name": "web-01", "id": 12345}], + "total_matching": 1, + }, + ) + + result_dict = await search_hosts.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchHostsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_incidents(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v2/incidents", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": [{"id": "inc-1", "type": "incidents"}], + }, + ) + + result_dict = await search_incidents.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchIncidentsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_logs(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url="https://api.datadoghq.com/api/v2/logs/events/search", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": [{"id": "log-1", "type": "log"}], + }, + ) + + result_dict = await search_logs.ainvoke( + _args(region="datadoghq.com", query="service:web status:error") + ) + + assert isinstance(result_dict, dict) + result = SearchLogsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_metrics(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/metrics?from=1", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "metrics": ["system.cpu.user", "system.mem.used"], + }, + ) + + result_dict = await search_metrics.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchMetricsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_monitors(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v1/monitor", + json=[ + # TODO: fill in a representative response shape from the upstream API docs + {"id": 1, "name": "CPU monitor", "type": "metric alert"}, + ], + ) + + result_dict = await search_monitors.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchMonitorsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_services(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url="https://api.datadoghq.com/api/v2/services/definitions", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": [{"id": "svc-1", "type": "service-definition"}], + }, + ) + + result_dict = await search_services.ainvoke(_args(region="datadoghq.com")) + + assert isinstance(result_dict, dict) + result = SearchServicesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_account_info_empty_credentials() -> None: + """Failure path: empty API key returns success=False without hitting the network.""" + result_dict = await get_account_info.ainvoke({"api_key": "", "application_key": ""}) + + assert isinstance(result_dict, dict) + result = GetAccountInfoOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "empty" in result.error.lower() or "credential" in result.error.lower() diff --git a/src/modulex_integrations/tools/datadog/tools.py b/src/modulex_integrations/tools/datadog/tools.py new file mode 100644 index 0000000..c2272cc --- /dev/null +++ b/src/modulex_integrations/tools/datadog/tools.py @@ -0,0 +1,685 @@ +"""Datadog LangChain @tool functions.""" +from __future__ import annotations + +import time +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.datadog.outputs import ( + GetAccountInfoOutput, + GetMetricDataOutput, + PostMetricDataOutput, + SearchDashboardsOutput, + SearchEventsOutput, + SearchHostsOutput, + SearchIncidentsOutput, + SearchLogsOutput, + SearchMetricsOutput, + SearchMonitorsOutput, + SearchServicesOutput, +) + +__all__ = [ + "get_account_info", + "get_metric_data", + "post_metric_data", + "search_dashboards", + "search_events", + "search_hosts", + "search_incidents", + "search_logs", + "search_metrics", + "search_monitors", + "search_services", +] + +_REGIONS = [ + ("datadoghq.com", "US1 - East"), + ("us3.datadoghq.com", "US3 - West"), + ("us5.datadoghq.com", "US5 - West"), + ("datadoghq.eu", "EU1 - Frankfurt"), + ("ddog-gov.com", "US1-FED - GovCloud"), + ("ap1.datadoghq.com", "AP1 - Tokyo"), +] + +_TIMEOUT = 30.0 + + +def _headers(api_key: str, application_key: str) -> dict[str, str]: + return { + "DD-API-KEY": api_key, + "DD-APPLICATION-KEY": application_key, + "Content-Type": "application/json", + } + + +def _api_url(region: str) -> str: + return f"https://api.{region}/api" + + +# --- Input schemas -------------------------------------------------------- + + +class GetAccountInfoInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + + +class GetMetricDataInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + query: str = Field(description="Metric query string (e.g. avg:system.cpu.user{*})") + from_ts: int = Field(description="Start of the query window as POSIX timestamp in seconds") + to_ts: int = Field(description="End of the query window as POSIX timestamp in seconds") + + +class PostMetricDataInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + metric: str = Field(description="The name of the timeseries metric") + points: dict[str, float] = Field(description="Points as JSON object where keys are Unix timestamps and values are numeric") + + +class SearchDashboardsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + filter_shared: bool | None = Field(default=None, description="If true, only return shared dashboards") + count: int | None = Field(default=None, description="Maximum number of dashboards to return") + start: int | None = Field(default=None, description="Offset for pagination") + + +class SearchEventsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + start: int | None = Field(default=None, description="POSIX timestamp (seconds) for start of query window; defaults to 24 hours ago") + end: int | None = Field(default=None, description="POSIX timestamp (seconds) for end of query window; defaults to now") + priority: str | None = Field(default=None, description="Filter by event priority: normal or low") + sources: str | None = Field(default=None, description="Comma-separated list of sources (e.g. nagios,hudson)") + tags: str | None = Field(default=None, description="Comma-separated list of tags (e.g. env:prod,role:db)") + + +class SearchHostsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + filter: str | None = Field(default=None, description="Filter hosts by name, alias, or tag") + sort_field: str | None = Field(default=None, description="Field to sort by: status, apps, cpu, iowait, or load") + sort_dir: str | None = Field(default=None, description="Direction of sort: asc or desc") + count: int | None = Field(default=None, description="Number of hosts to return (max 1000)") + + +class SearchIncidentsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + query: str | None = Field(default=None, description="Search query using field:value syntax (e.g. state:active)") + page_size: int | None = Field(default=None, description="Number of incidents per page (default 10)") + page_offset: int | None = Field(default=None, description="Offset for pagination") + + +class SearchLogsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + query: str = Field(default="*", description="Search query following log search syntax (e.g. service:web-app status:error)") + from_time: str | None = Field(default=None, description="Minimum timestamp; supports date math (now-15m), ISO-8601, or epoch ms") + to_time: str | None = Field(default=None, description="Maximum timestamp; supports date math (now), ISO-8601, or epoch ms") + indexes: list[str] | None = Field(default=None, description="List of log index names to search") + limit: int | None = Field(default=None, description="Maximum number of logs to return (default 10, max 1000)") + sort: str | None = Field(default=None, description="Sort order: -timestamp (newest first) or timestamp (oldest first)") + + +class SearchMetricsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + host: str | None = Field(default=None, description="Filter metrics by host name") + + +class SearchMonitorsInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + query: str | None = Field(default=None, description="Filter monitors by name, tag, or attributes") + tags: str | None = Field(default=None, description="Comma-separated list of tags (e.g. env:prod,team:backend)") + page: int | None = Field(default=None, description="Page number to return (0-indexed)") + page_size: int | None = Field(default=None, description="Number of monitors per page (default 100)") + + +class SearchServicesInput(BaseModel): + api_key: str = Field(description="Datadog API key") + application_key: str = Field(description="Datadog Application key") + region: str = Field(description="The regional site for the Datadog account (e.g. datadoghq.com)") + page_size: int | None = Field(default=None, description="Number of services per page (default 10)") + page_number: int | None = Field(default=None, description="Page number (0-indexed)") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=GetAccountInfoInput) +@serialize_pydantic_return +async def get_account_info( + api_key: str, + application_key: str, +) -> GetAccountInfoOutput: + """Detect the Datadog region for the connected account by validating the API key across all regions.""" + if not api_key or not api_key.strip(): + return GetAccountInfoOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + for region_domain, label in _REGIONS: + try: + response = await client.get( + f"https://api.{region_domain}/api/v1/validate", + headers={"DD-API-KEY": api_key}, + ) + if response.status_code == 200: + return GetAccountInfoOutput( + success=True, + region=region_domain, + label=label, + api_url=f"https://api.{region_domain}", + ) + except httpx.RequestError: + continue + except httpx.TimeoutException: + return GetAccountInfoOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetAccountInfoOutput(success=False, error=f"Call failed: {exc}") + return GetAccountInfoOutput( + success=False, + error="Could not validate API key against any known Datadog region.", + ) + + +@tool(args_schema=GetMetricDataInput) +@serialize_pydantic_return +async def get_metric_data( + api_key: str, + application_key: str, + region: str, + query: str, + from_ts: int, + to_ts: int, +) -> GetMetricDataOutput: + """Query time-series metric data for analyzing trends and system performance.""" + if not api_key or not api_key.strip(): + return GetMetricDataOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/query", + headers=_headers(api_key, application_key), + params={"query": query, "from": from_ts, "to": to_ts}, + ) + if response.status_code != 200: + return GetMetricDataOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetMetricDataOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetMetricDataOutput(success=False, error=f"Call failed: {exc}") + return GetMetricDataOutput( + success=True, + series=data.get("series", []), + from_date=data.get("from_date"), + to_date=data.get("to_date"), + query=data.get("query"), + ) + + +@tool(args_schema=PostMetricDataInput) +@serialize_pydantic_return +async def post_metric_data( + api_key: str, + application_key: str, + region: str, + metric: str, + points: dict[str, float], +) -> PostMetricDataOutput: + """Post custom time-series metric data points to Datadog.""" + if not api_key or not api_key.strip(): + return PostMetricDataOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + series_points = [ + {"timestamp": int(ts), "value": val} + for ts, val in points.items() + ] + payload = { + "series": [ + { + "metric": metric, + "type": 0, + "points": series_points, + } + ] + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_api_url(region)}/v2/series", + headers=_headers(api_key, application_key), + json=payload, + ) + if response.status_code not in (200, 202): + return PostMetricDataOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return PostMetricDataOutput(success=False, error="Request timed out.") + except Exception as exc: + return PostMetricDataOutput(success=False, error=f"Call failed: {exc}") + return PostMetricDataOutput( + success=True, + errors=data.get("errors", []), + ) + + +@tool(args_schema=SearchDashboardsInput) +@serialize_pydantic_return +async def search_dashboards( + api_key: str, + application_key: str, + region: str, + filter_shared: bool | None = None, + count: int | None = None, + start: int | None = None, +) -> SearchDashboardsOutput: + """List and search Datadog dashboards with their IDs, titles, and URLs.""" + if not api_key or not api_key.strip(): + return SearchDashboardsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if filter_shared is not None: + params["filter[shared]"] = str(filter_shared).lower() + if count is not None: + params["count"] = count + if start is not None: + params["start"] = start + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/dashboard", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchDashboardsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchDashboardsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchDashboardsOutput(success=False, error=f"Call failed: {exc}") + return SearchDashboardsOutput( + success=True, + dashboards=data.get("dashboards", []), + ) + + +@tool(args_schema=SearchEventsInput) +@serialize_pydantic_return +async def search_events( + api_key: str, + application_key: str, + region: str, + start: int | None = None, + end: int | None = None, + priority: str | None = None, + sources: str | None = None, + tags: str | None = None, +) -> SearchEventsOutput: + """Search Datadog events including monitor state changes, deployment markers, and error spikes.""" + if not api_key or not api_key.strip(): + return SearchEventsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + now = int(time.time()) + effective_end = end if end is not None else now + effective_start = start if start is not None else now - 86400 + params: dict[str, Any] = { + "start": effective_start, + "end": effective_end, + } + if priority: + params["priority"] = priority + if sources: + params["sources"] = sources + if tags: + params["tags"] = tags + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/events", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchEventsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchEventsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchEventsOutput(success=False, error=f"Call failed: {exc}") + return SearchEventsOutput( + success=True, + events=data.get("events", []), + ) + + +@tool(args_schema=SearchHostsInput) +@serialize_pydantic_return +async def search_hosts( + api_key: str, + application_key: str, + region: str, + filter: str | None = None, + sort_field: str | None = None, + sort_dir: str | None = None, + count: int | None = None, +) -> SearchHostsOutput: + """Search monitored infrastructure hosts with filtering by tag, name, or partial match.""" + if not api_key or not api_key.strip(): + return SearchHostsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if filter: + params["filter"] = filter + if sort_field: + params["sort_field"] = sort_field + if sort_dir: + params["sort_dir"] = sort_dir + if count is not None: + params["count"] = count + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/hosts", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchHostsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchHostsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchHostsOutput(success=False, error=f"Call failed: {exc}") + return SearchHostsOutput( + success=True, + host_list=data.get("host_list", []), + total_matching=data.get("total_matching"), + ) + + +@tool(args_schema=SearchIncidentsInput) +@serialize_pydantic_return +async def search_incidents( + api_key: str, + application_key: str, + region: str, + query: str | None = None, + page_size: int | None = None, + page_offset: int | None = None, +) -> SearchIncidentsOutput: + """Search Datadog incidents by state, severity, and metadata.""" + if not api_key or not api_key.strip(): + return SearchIncidentsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if query: + params["filter[query]"] = query + if page_size is not None: + params["page[size]"] = page_size + if page_offset is not None: + params["page[offset]"] = page_offset + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v2/incidents", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchIncidentsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchIncidentsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchIncidentsOutput(success=False, error=f"Call failed: {exc}") + return SearchIncidentsOutput( + success=True, + incidents=data.get("data", []), + ) + + +@tool(args_schema=SearchLogsInput) +@serialize_pydantic_return +async def search_logs( + api_key: str, + application_key: str, + region: str, + query: str = "*", + from_time: str | None = None, + to_time: str | None = None, + indexes: list[str] | None = None, + limit: int | None = None, + sort: str | None = None, +) -> SearchLogsOutput: + """Search Datadog logs matching a query with support for facets and time ranges.""" + if not api_key or not api_key.strip(): + return SearchLogsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = { + "filter": {"query": query}, + } + if from_time or to_time: + time_range: dict[str, str] = {} + if from_time: + time_range["from"] = from_time + if to_time: + time_range["to"] = to_time + body["filter"]["from"] = time_range.get("from", "now-15m") + body["filter"]["to"] = time_range.get("to", "now") + if indexes: + body["filter"]["indexes"] = indexes + if limit is not None: + body["page"] = {"limit": limit} + if sort: + body["sort"] = sort + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_api_url(region)}/v2/logs/events/search", + headers=_headers(api_key, application_key), + json=body, + ) + if response.status_code != 200: + return SearchLogsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchLogsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchLogsOutput(success=False, error=f"Call failed: {exc}") + return SearchLogsOutput( + success=True, + logs=data.get("data", []), + ) + + +@tool(args_schema=SearchMetricsInput) +@serialize_pydantic_return +async def search_metrics( + api_key: str, + application_key: str, + region: str, + host: str | None = None, +) -> SearchMetricsOutput: + """List available Datadog metric names, optionally filtered by host.""" + if not api_key or not api_key.strip(): + return SearchMetricsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {"from": "1"} + if host: + params["host"] = host + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/metrics", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchMetricsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchMetricsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchMetricsOutput(success=False, error=f"Call failed: {exc}") + return SearchMetricsOutput( + success=True, + metrics=data.get("metrics", []), + ) + + +@tool(args_schema=SearchMonitorsInput) +@serialize_pydantic_return +async def search_monitors( + api_key: str, + application_key: str, + region: str, + query: str | None = None, + tags: str | None = None, + page: int | None = None, + page_size: int | None = None, +) -> SearchMonitorsOutput: + """Search Datadog monitors (alerting rules) including status, thresholds, and conditions.""" + if not api_key or not api_key.strip(): + return SearchMonitorsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if query: + params["query"] = query + if tags: + params["monitor_tags"] = tags + if page is not None: + params["page"] = page + if page_size is not None: + params["page_size"] = page_size + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v1/monitor", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchMonitorsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchMonitorsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchMonitorsOutput(success=False, error=f"Call failed: {exc}") + monitors = data if isinstance(data, list) else data.get("monitors", []) + return SearchMonitorsOutput( + success=True, + monitors=monitors, + ) + + +@tool(args_schema=SearchServicesInput) +@serialize_pydantic_return +async def search_services( + api_key: str, + application_key: str, + region: str, + page_size: int | None = None, + page_number: int | None = None, +) -> SearchServicesOutput: + """List services from Datadog Service Catalog with ownership, metadata, and team info.""" + if not api_key or not api_key.strip(): + return SearchServicesOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if page_size is not None: + params["page[size]"] = page_size + if page_number is not None: + params["page[number]"] = page_number + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_api_url(region)}/v2/services/definitions", + headers=_headers(api_key, application_key), + params=params, + ) + if response.status_code != 200: + return SearchServicesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchServicesOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchServicesOutput(success=False, error=f"Call failed: {exc}") + return SearchServicesOutput( + success=True, + services=data.get("data", []), + ) diff --git a/src/modulex_integrations/tools/freshdesk/README.md b/src/modulex_integrations/tools/freshdesk/README.md new file mode 100644 index 0000000..95c8089 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/README.md @@ -0,0 +1,75 @@ +# Freshdesk + +Customer support helpdesk platform for managing tickets, contacts, agents, and knowledge base articles via the Freshdesk REST API (`{domain}.freshdesk.com/api/v2`). + +## Authentication + +### API Key Authentication + +- Log in to your Freshdesk account, click your profile picture, and go to **Profile Settings**. Your API key is displayed on the right side. +- Required env vars: + - `FRESHDESK_DOMAIN` (format: `mycompany` — the subdomain from `mycompany.freshdesk.com`) + - `FRESHDESK_API_KEY` (format: `xXxXxXxXxXxXxXxXxXxX`) +- The API uses HTTP Basic Auth where the API key is the username and `X` is the password. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_ticket` | Create a new support ticket in Freshdesk | `subject`, `description`, `email` | +| `get_ticket` | Retrieve a specific ticket by its ID | `ticket_id` | +| `update_ticket` | Update an existing ticket's properties | `ticket_id` | +| `list_all_tickets` | List tickets in Freshdesk with optional filtering | | +| `close_ticket` | Close a ticket by setting its status to Closed (5) | `ticket_id` | +| `add_note_to_ticket` | Add a private or public note to a ticket | `ticket_id`, `body` | +| `add_ticket_tags` | Add tags to an existing ticket | `ticket_id`, `tags` | +| `remove_ticket_tags` | Remove tags from an existing ticket | `ticket_id`, `tags` | +| `set_ticket_tags` | Replace all tags on a ticket with the specified set | `ticket_id`, `tags` | +| `set_ticket_priority` | Set the priority of a ticket | `ticket_id`, `priority` | +| `set_ticket_status` | Set the status of a ticket | `ticket_id`, `status` | +| `assign_ticket_to_agent` | Assign a ticket to a specific agent | `ticket_id`, `agent_id` | +| `assign_ticket_to_group` | Assign a ticket to a specific group | `ticket_id`, `group_id` | +| `create_contact` | Create a new contact in Freshdesk | `email`, `name` | +| `get_contact` | Retrieve a contact by their ID | `contact_id` | +| `update_contact` | Update an existing contact's properties | `contact_id` | +| `create_company` | Create a new company in Freshdesk | `name` | +| `create_agent` | Create a new agent in Freshdesk | `email`, `ticket_scope` | +| `update_agent` | Update an existing agent's properties | `agent_id` | +| `get_agent` | Retrieve a single agent by their ID | `agent_id` | +| `list_agents` | List all agents in Freshdesk with optional filtering | | +| `create_reply` | Create a reply to a ticket | `ticket_id`, `body` | +| `forward_ticket` | Forward a ticket to an external email address | `ticket_id`, `body`, `to_emails` | +| `reply_to_forward` | Reply to a previously forwarded ticket email | `ticket_id`, `body`, `to_emails` | +| `create_thread` | Create a collaboration thread on a ticket | `ticket_id`, `type`, `email_config_id` | +| `create_message_for_thread` | Create a message in a collaboration thread | `ticket_id`, `thread_id`, `body` | +| `list_ticket_conversations` | List all conversations (notes, replies) for a ticket | `ticket_id` | +| `list_ticket_fields` | List all ticket fields configured in Freshdesk | | +| `create_ticket_field` | Create a new custom ticket field | `label`, `label_for_customers`, `type` | +| `update_ticket_field` | Update a custom ticket field | `ticket_field_id` | +| `create_solution_article` | Create a knowledge base article in a folder | `folder_id`, `title`, `description`, `status` | +| `get_solution_article` | Retrieve a knowledge base article by its ID | `article_id` | +| `update_solution_article` | Update a knowledge base article | `article_id` | +| `delete_solution_article` | Delete a knowledge base article | `article_id` | +| `search_solution_article` | Search knowledge base articles by keyword | `term` | +| `list_solution_categories` | List all knowledge base solution categories | | +| `list_category_folders` | List all folders within a solution category | `category_id` | +| `list_folder_articles` | List all articles within a solution folder | `folder_id` | +| `list_all_folders` | List all canned response folders | | +| `list_folder_canned_responses` | List all canned responses in a specific folder | `canned_response_folder_id` | +| `get_canned_response` | Retrieve a specific canned response by ID | `canned_response_id` | +| `get_folder_canned_responses` | Get detailed canned responses from a folder | `canned_response_folder_id` | +| `list_companies` | List all companies in Freshdesk | | +| `list_email_configs` | List all email configurations | | +| `list_roles` | List all agent roles | | + +Every tool takes additional `domain` and `api_key` parameters that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Rate limits**: Freshdesk applies per-plan rate limits. Free plans: 50 calls/min. Growth and above: 200-400+ calls/min depending on plan. +- **Pagination**: List endpoints return up to 100 results per page by default. +- **Error model**: non-2xx responses are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/freshdesk/__init__.py b/src/modulex_integrations/tools/freshdesk/__init__.py new file mode 100644 index 0000000..e413d20 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/__init__.py @@ -0,0 +1,147 @@ +"""Freshdesk integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.freshdesk.manifest import manifest +from modulex_integrations.tools.freshdesk.tools import ( + add_note_to_ticket, + add_ticket_tags, + assign_ticket_to_agent, + assign_ticket_to_group, + close_ticket, + create_agent, + create_company, + create_contact, + create_message_for_thread, + create_reply, + create_solution_article, + create_thread, + create_ticket, + create_ticket_field, + delete_solution_article, + forward_ticket, + get_agent, + get_canned_response, + get_contact, + get_folder_canned_responses, + get_solution_article, + get_ticket, + list_agents, + list_all_folders, + list_all_tickets, + list_category_folders, + list_companies, + list_email_configs, + list_folder_articles, + list_folder_canned_responses, + list_roles, + list_solution_categories, + list_ticket_conversations, + list_ticket_fields, + remove_ticket_tags, + reply_to_forward, + search_solution_article, + set_ticket_priority, + set_ticket_status, + set_ticket_tags, + update_agent, + update_contact, + update_solution_article, + update_ticket, + update_ticket_field, +) + +TOOLS = ( + create_ticket, + get_ticket, + update_ticket, + list_all_tickets, + close_ticket, + add_note_to_ticket, + add_ticket_tags, + remove_ticket_tags, + set_ticket_tags, + set_ticket_priority, + set_ticket_status, + assign_ticket_to_agent, + assign_ticket_to_group, + create_contact, + get_contact, + update_contact, + create_company, + create_agent, + update_agent, + get_agent, + list_agents, + create_reply, + forward_ticket, + reply_to_forward, + create_thread, + create_message_for_thread, + list_ticket_conversations, + list_ticket_fields, + create_ticket_field, + update_ticket_field, + create_solution_article, + get_solution_article, + update_solution_article, + delete_solution_article, + search_solution_article, + list_solution_categories, + list_category_folders, + list_folder_articles, + list_all_folders, + list_folder_canned_responses, + get_canned_response, + get_folder_canned_responses, + list_companies, + list_email_configs, + list_roles, +) + +__all__ = [ + "TOOLS", + "add_note_to_ticket", + "add_ticket_tags", + "assign_ticket_to_agent", + "assign_ticket_to_group", + "close_ticket", + "create_agent", + "create_company", + "create_contact", + "create_message_for_thread", + "create_reply", + "create_solution_article", + "create_thread", + "create_ticket", + "create_ticket_field", + "delete_solution_article", + "forward_ticket", + "get_agent", + "get_canned_response", + "get_contact", + "get_folder_canned_responses", + "get_solution_article", + "get_ticket", + "list_agents", + "list_all_folders", + "list_all_tickets", + "list_category_folders", + "list_companies", + "list_email_configs", + "list_folder_articles", + "list_folder_canned_responses", + "list_roles", + "list_solution_categories", + "list_ticket_conversations", + "list_ticket_fields", + "manifest", + "remove_ticket_tags", + "reply_to_forward", + "search_solution_article", + "set_ticket_priority", + "set_ticket_status", + "set_ticket_tags", + "update_agent", + "update_contact", + "update_solution_article", + "update_ticket", + "update_ticket_field", +] diff --git a/src/modulex_integrations/tools/freshdesk/dependencies.toml b/src/modulex_integrations/tools/freshdesk/dependencies.toml new file mode 100644 index 0000000..c6ba52d --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the freshdesk integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/freshdesk/manifest.py b/src/modulex_integrations/tools/freshdesk/manifest.py new file mode 100644 index 0000000..9e179b6 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/manifest.py @@ -0,0 +1,857 @@ +"""Freshdesk integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="freshdesk", + display_name="Freshdesk", + description="Customer support helpdesk platform for managing tickets, contacts, agents, and knowledge base articles via the Freshdesk REST API.", + version="1.0.0", + author="ModuleX", + logo="modulex:freshdesk-themed", + app_url="https://freshdesk.com", + categories=["Customer Support", "Helpdesk", "Productivity & Collaboration"], + actions=[ + ActionDefinition( + name="create_ticket", + description="Create a new support ticket in Freshdesk", + parameters={ + "subject": ParameterDef( + type="string", + description="Subject of the ticket", + required=True, + ), + "description": ParameterDef( + type="string", + description="HTML content of the ticket", + required=True, + ), + "email": ParameterDef( + type="string", + description="Email address of the requester", + required=True, + ), + "priority": ParameterDef( + type="integer", + description="Priority of the ticket: 1 (Low), 2 (Medium), 3 (High), 4 (Urgent)", + default=1, + ), + "status": ParameterDef( + type="integer", + description="Status of the ticket: 2 (Open), 3 (Pending), 4 (Resolved), 5 (Closed)", + default=2, + ), + "company_id": ParameterDef( + type="integer", + description="ID of the company to associate with the ticket", + ), + }, + ), + ActionDefinition( + name="get_ticket", + description="Retrieve a specific ticket by its ID", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="update_ticket", + description="Update an existing ticket's properties", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to update", + required=True, + ), + "subject": ParameterDef( + type="string", + description="New subject for the ticket", + ), + "description": ParameterDef( + type="string", + description="New HTML content for the ticket", + ), + "priority": ParameterDef( + type="integer", + description="Priority: 1 (Low), 2 (Medium), 3 (High), 4 (Urgent)", + ), + "status": ParameterDef( + type="integer", + description="Status: 2 (Open), 3 (Pending), 4 (Resolved), 5 (Closed)", + ), + "group_id": ParameterDef( + type="integer", + description="ID of the group to assign the ticket to", + ), + "responder_id": ParameterDef( + type="integer", + description="ID of the agent to assign the ticket to", + ), + }, + ), + ActionDefinition( + name="list_all_tickets", + description="List tickets in Freshdesk with optional filtering", + parameters={ + "filter": ParameterDef( + type="string", + description="Predefined filter: new_and_my_open, watching, spam, deleted, all_tickets", + ), + "requester_id": ParameterDef( + type="integer", + description="Filter tickets by requester ID", + ), + "email": ParameterDef( + type="string", + description="Filter tickets by requester email", + ), + "company_id": ParameterDef( + type="integer", + description="Filter tickets by company ID", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="close_ticket", + description="Close a ticket by setting its status to Closed (5)", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to close", + required=True, + ), + }, + ), + ActionDefinition( + name="add_note_to_ticket", + description="Add a private or public note to a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to add a note to", + required=True, + ), + "body": ParameterDef( + type="string", + description="Content of the note in HTML format", + required=True, + ), + "private": ParameterDef( + type="boolean", + description="Whether the note is private (true) or public (false)", + default=True, + ), + "notify_emails": ParameterDef( + type="array", + description="List of email addresses to notify about this note", + ), + }, + ), + ActionDefinition( + name="add_ticket_tags", + description="Add tags to an existing ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "tags": ParameterDef( + type="array", + description="List of tag names to add to the ticket", + required=True, + ), + }, + ), + ActionDefinition( + name="remove_ticket_tags", + description="Remove tags from an existing ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "tags": ParameterDef( + type="array", + description="List of tag names to remove from the ticket", + required=True, + ), + }, + ), + ActionDefinition( + name="set_ticket_tags", + description="Replace all tags on a ticket with the specified set", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "tags": ParameterDef( + type="array", + description="List of tag names to set on the ticket (replaces existing tags)", + required=True, + ), + }, + ), + ActionDefinition( + name="set_ticket_priority", + description="Set the priority of a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "priority": ParameterDef( + type="integer", + description="Priority: 1 (Low), 2 (Medium), 3 (High), 4 (Urgent)", + required=True, + ), + }, + ), + ActionDefinition( + name="set_ticket_status", + description="Set the status of a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "status": ParameterDef( + type="integer", + description="Status: 2 (Open), 3 (Pending), 4 (Resolved), 5 (Closed)", + required=True, + ), + }, + ), + ActionDefinition( + name="assign_ticket_to_agent", + description="Assign a ticket to a specific agent", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "agent_id": ParameterDef( + type="integer", + description="ID of the agent to assign the ticket to", + required=True, + ), + }, + ), + ActionDefinition( + name="assign_ticket_to_group", + description="Assign a ticket to a specific group", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "group_id": ParameterDef( + type="integer", + description="ID of the group to assign the ticket to", + required=True, + ), + }, + ), + ActionDefinition( + name="create_contact", + description="Create a new contact in Freshdesk", + parameters={ + "email": ParameterDef( + type="string", + description="Email address of the contact", + required=True, + ), + "name": ParameterDef( + type="string", + description="Name of the contact", + required=True, + ), + "phone": ParameterDef( + type="string", + description="Phone number of the contact", + ), + "company_id": ParameterDef( + type="integer", + description="ID of the company to associate with the contact", + ), + }, + ), + ActionDefinition( + name="get_contact", + description="Retrieve a contact by their ID", + parameters={ + "contact_id": ParameterDef( + type="integer", + description="ID of the contact to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="update_contact", + description="Update an existing contact's properties", + parameters={ + "contact_id": ParameterDef( + type="integer", + description="ID of the contact to update", + required=True, + ), + "name": ParameterDef( + type="string", + description="Updated name of the contact", + ), + "email": ParameterDef( + type="string", + description="Updated email address", + ), + "phone": ParameterDef( + type="string", + description="Updated phone number", + ), + "company_id": ParameterDef( + type="integer", + description="ID of the company to associate with the contact", + ), + }, + ), + ActionDefinition( + name="create_company", + description="Create a new company in Freshdesk", + parameters={ + "name": ParameterDef( + type="string", + description="Name of the company", + required=True, + ), + "domains": ParameterDef( + type="array", + description="List of domain names associated with the company (e.g. ['example.com'])", + ), + "description": ParameterDef( + type="string", + description="Description of the company", + ), + }, + ), + ActionDefinition( + name="create_agent", + description="Create a new agent in Freshdesk", + parameters={ + "email": ParameterDef( + type="string", + description="Email address of the agent", + required=True, + ), + "ticket_scope": ParameterDef( + type="integer", + description="Ticket permission: 1 (Global Access), 2 (Group Access), 3 (Restricted Access)", + required=True, + ), + "occasional": ParameterDef( + type="boolean", + description="Set to true if this is an occasional agent", + ), + "agent_type": ParameterDef( + type="integer", + description="Type: 1 (Support Agent), 2 (Field Agent), 3 (Collaborator)", + ), + }, + ), + ActionDefinition( + name="update_agent", + description="Update an existing agent's properties", + parameters={ + "agent_id": ParameterDef( + type="integer", + description="ID of the agent to update", + required=True, + ), + "email": ParameterDef( + type="string", + description="Updated email address", + ), + "ticket_scope": ParameterDef( + type="integer", + description="Ticket permission: 1 (Global Access), 2 (Group Access), 3 (Restricted Access)", + ), + "occasional": ParameterDef( + type="boolean", + description="Set to true if this is an occasional agent", + ), + }, + ), + ActionDefinition( + name="get_agent", + description="Retrieve a single agent by their ID", + parameters={ + "agent_id": ParameterDef( + type="integer", + description="ID of the agent to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="list_agents", + description="List all agents in Freshdesk with optional filtering", + parameters={ + "email": ParameterDef( + type="string", + description="Filter agents by email address", + ), + "state": ParameterDef( + type="string", + description="Filter by state: fulltime, occasional", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="create_reply", + description="Create a reply to a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to reply to", + required=True, + ), + "body": ParameterDef( + type="string", + description="Content of the reply in HTML format", + required=True, + ), + "cc_emails": ParameterDef( + type="array", + description="List of email addresses to CC", + ), + "bcc_emails": ParameterDef( + type="array", + description="List of email addresses to BCC", + ), + }, + ), + ActionDefinition( + name="forward_ticket", + description="Forward a ticket to an external email address", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to forward", + required=True, + ), + "body": ParameterDef( + type="string", + description="Content of the forward in HTML format", + required=True, + ), + "to_emails": ParameterDef( + type="array", + description="List of email addresses to forward to", + required=True, + ), + "cc_emails": ParameterDef( + type="array", + description="List of email addresses to CC", + ), + "bcc_emails": ParameterDef( + type="array", + description="List of email addresses to BCC", + ), + }, + ), + ActionDefinition( + name="reply_to_forward", + description="Reply to a previously forwarded ticket email", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "body": ParameterDef( + type="string", + description="Content of the reply in HTML format", + required=True, + ), + "to_emails": ParameterDef( + type="array", + description="List of email addresses to reply to", + required=True, + ), + }, + ), + ActionDefinition( + name="create_thread", + description="Create a collaboration thread on a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket to create the thread for", + required=True, + ), + "type": ParameterDef( + type="string", + description="Type of thread: forward, discussion", + required=True, + ), + "email_config_id": ParameterDef( + type="integer", + description="ID of the email config to use for the thread", + required=True, + ), + }, + ), + ActionDefinition( + name="create_message_for_thread", + description="Create a message in a collaboration thread", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "thread_id": ParameterDef( + type="string", + description="ID of the thread to post the message in", + required=True, + ), + "body": ParameterDef( + type="string", + description="Content of the message in HTML format", + required=True, + ), + "subject": ParameterDef( + type="string", + description="Subject of the email", + ), + }, + ), + ActionDefinition( + name="list_ticket_conversations", + description="List all conversations (notes, replies) for a ticket", + parameters={ + "ticket_id": ParameterDef( + type="integer", + description="ID of the ticket", + required=True, + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="list_ticket_fields", + description="List all ticket fields configured in Freshdesk", + parameters={ + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="create_ticket_field", + description="Create a new custom ticket field", + parameters={ + "label": ParameterDef( + type="string", + description="Display name of the ticket field", + required=True, + ), + "label_for_customers": ParameterDef( + type="string", + description="Label for the field as seen by customers", + required=True, + ), + "type": ParameterDef( + type="string", + description="Field type: custom_dropdown, custom_checkbox, custom_text, custom_paragraph, custom_number, custom_date, custom_decimal, custom_url", + required=True, + ), + }, + ), + ActionDefinition( + name="update_ticket_field", + description="Update a custom ticket field", + parameters={ + "ticket_field_id": ParameterDef( + type="string", + description="ID of the ticket field to update", + required=True, + ), + "label": ParameterDef( + type="string", + description="Updated display name", + ), + "label_for_customers": ParameterDef( + type="string", + description="Updated label for customers", + ), + }, + ), + ActionDefinition( + name="create_solution_article", + description="Create a knowledge base article in a folder", + parameters={ + "folder_id": ParameterDef( + type="integer", + description="ID of the folder to create the article in", + required=True, + ), + "title": ParameterDef( + type="string", + description="Title of the article", + required=True, + ), + "description": ParameterDef( + type="string", + description="HTML content of the article", + required=True, + ), + "status": ParameterDef( + type="integer", + description="Status: 1 (Draft), 2 (Published)", + required=True, + ), + "tags": ParameterDef( + type="array", + description="List of tags for the article", + ), + }, + ), + ActionDefinition( + name="get_solution_article", + description="Retrieve a knowledge base article by its ID", + parameters={ + "article_id": ParameterDef( + type="integer", + description="ID of the article to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="update_solution_article", + description="Update a knowledge base article", + parameters={ + "article_id": ParameterDef( + type="integer", + description="ID of the article to update", + required=True, + ), + "title": ParameterDef( + type="string", + description="Updated title", + ), + "description": ParameterDef( + type="string", + description="Updated HTML content", + ), + "status": ParameterDef( + type="integer", + description="Status: 1 (Draft), 2 (Published)", + ), + "tags": ParameterDef( + type="array", + description="Updated tags for the article", + ), + }, + ), + ActionDefinition( + name="delete_solution_article", + description="Delete a knowledge base article", + parameters={ + "article_id": ParameterDef( + type="integer", + description="ID of the article to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="search_solution_article", + description="Search knowledge base articles by keyword", + parameters={ + "term": ParameterDef( + type="string", + description="Search keyword to find matching articles", + required=True, + ), + }, + ), + ActionDefinition( + name="list_solution_categories", + description="List all knowledge base solution categories", + parameters={}, + ), + ActionDefinition( + name="list_category_folders", + description="List all folders within a solution category", + parameters={ + "category_id": ParameterDef( + type="integer", + description="ID of the solution category", + required=True, + ), + }, + ), + ActionDefinition( + name="list_folder_articles", + description="List all articles within a solution folder", + parameters={ + "folder_id": ParameterDef( + type="integer", + description="ID of the solution folder", + required=True, + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="list_all_folders", + description="List all canned response folders", + parameters={ + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="list_folder_canned_responses", + description="List all canned responses in a specific folder", + parameters={ + "canned_response_folder_id": ParameterDef( + type="integer", + description="ID of the canned response folder", + required=True, + ), + }, + ), + ActionDefinition( + name="get_canned_response", + description="Retrieve a specific canned response by ID", + parameters={ + "canned_response_id": ParameterDef( + type="integer", + description="ID of the canned response", + required=True, + ), + }, + ), + ActionDefinition( + name="get_folder_canned_responses", + description="Get detailed canned responses from a folder", + parameters={ + "canned_response_folder_id": ParameterDef( + type="integer", + description="ID of the canned response folder", + required=True, + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=100, + ), + }, + ), + ActionDefinition( + name="list_companies", + description="List all companies in Freshdesk", + parameters={}, + ), + ActionDefinition( + name="list_email_configs", + description="List all email configurations", + parameters={}, + ), + ActionDefinition( + name="list_roles", + description="List all agent roles", + parameters={}, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Freshdesk API key and subdomain", + setup_instructions=[ + "Log in to your Freshdesk account", + "Click your profile picture in the top right corner", + "Go to Profile Settings", + "Your API key is displayed on the right side of the page", + ], + setup_environment_variables=[ + EnvVar( + name="FRESHDESK_DOMAIN", + display_name="Freshdesk Domain", + description="Your Freshdesk subdomain (e.g. 'mycompany' for mycompany.freshdesk.com)", + required=True, + sensitive=False, + sample_format="mycompany", + about_url="https://support.freshdesk.com/en/support/solutions/articles/215517-how-to-find-your-freshdesk-domain-name", + ), + EnvVar( + name="FRESHDESK_API_KEY", + display_name="Freshdesk API Key", + description="Your Freshdesk API key from Profile Settings", + required=True, + sensitive=True, + sample_format="xXxXxXxXxXxXxXxXxXxX", + about_url="https://support.freshdesk.com/en/support/solutions/articles/215517-how-to-find-your-api-key", + ), + ], + test_endpoint=TestEndpoint( + url="https://{domain}.freshdesk.com/api/v2/tickets?per_page=1", + method="GET", + headers={"Authorization": "Basic {api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Validates credentials by listing one ticket", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/freshdesk/outputs.py b/src/modulex_integrations/tools/freshdesk/outputs.py new file mode 100644 index 0000000..e4c7583 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/outputs.py @@ -0,0 +1,329 @@ +"""Pydantic response models for the freshdesk integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddNoteToTicketOutput", + "AddTicketTagsOutput", + "AssignTicketToAgentOutput", + "AssignTicketToGroupOutput", + "CloseTicketOutput", + "CreateAgentOutput", + "CreateCompanyOutput", + "CreateContactOutput", + "CreateMessageForThreadOutput", + "CreateReplyOutput", + "CreateSolutionArticleOutput", + "CreateThreadOutput", + "CreateTicketFieldOutput", + "CreateTicketOutput", + "DeleteSolutionArticleOutput", + "ForwardTicketOutput", + "GetAgentOutput", + "GetCannedResponseOutput", + "GetContactOutput", + "GetFolderCannedResponsesOutput", + "GetSolutionArticleOutput", + "GetTicketOutput", + "ListAgentsOutput", + "ListAllFoldersOutput", + "ListAllTicketsOutput", + "ListCategoryFoldersOutput", + "ListCompaniesOutput", + "ListEmailConfigsOutput", + "ListFolderArticlesOutput", + "ListFolderCannedResponsesOutput", + "ListRolesOutput", + "ListSolutionCategoriesOutput", + "ListTicketConversationsOutput", + "ListTicketFieldsOutput", + "RemoveTicketTagsOutput", + "ReplyToForwardOutput", + "SearchSolutionArticleOutput", + "SetTicketPriorityOutput", + "SetTicketStatusOutput", + "SetTicketTagsOutput", + "UpdateAgentOutput", + "UpdateContactOutput", + "UpdateSolutionArticleOutput", + "UpdateTicketFieldOutput", + "UpdateTicketOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ListAllTicketsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class CloseTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class AddNoteToTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class AddTicketTagsOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class RemoveTicketTagsOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class SetTicketTagsOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class SetTicketPriorityOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class SetTicketStatusOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class AssignTicketToAgentOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class AssignTicketToGroupOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateContactOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetContactOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateContactOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateCompanyOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateAgentOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateAgentOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetAgentOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ListAgentsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class CreateReplyOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ForwardTicketOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ReplyToForwardOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateThreadOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateMessageForThreadOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ListTicketConversationsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListTicketFieldsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class CreateTicketFieldOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateTicketFieldOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateSolutionArticleOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetSolutionArticleOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateSolutionArticleOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class DeleteSolutionArticleOutput(_Base): + success: bool + error: str | None = None + + +class SearchSolutionArticleOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListSolutionCategoriesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListCategoryFoldersOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListFolderArticlesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListAllFoldersOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListFolderCannedResponsesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class GetCannedResponseOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetFolderCannedResponsesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListCompaniesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListEmailConfigsOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) + + +class ListRolesOutput(_Base): + success: bool + error: str | None = None + items: list[dict[str, Any]] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/freshdesk/tests/__init__.py b/src/modulex_integrations/tools/freshdesk/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/freshdesk/tests/test_freshdesk.py b/src/modulex_integrations/tools/freshdesk/tests/test_freshdesk.py new file mode 100644 index 0000000..e436b4c --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/tests/test_freshdesk.py @@ -0,0 +1,622 @@ +"""Happy-path tests for every freshdesk @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.freshdesk import ( + TOOLS, + add_note_to_ticket, + add_ticket_tags, + assign_ticket_to_agent, + assign_ticket_to_group, + close_ticket, + create_agent, + create_company, + create_contact, + create_message_for_thread, + create_reply, + create_solution_article, + create_thread, + create_ticket, + create_ticket_field, + delete_solution_article, + forward_ticket, + get_agent, + get_canned_response, + get_contact, + get_folder_canned_responses, + get_solution_article, + get_ticket, + list_agents, + list_all_folders, + list_all_tickets, + list_category_folders, + list_companies, + list_email_configs, + list_folder_articles, + list_folder_canned_responses, + list_roles, + list_solution_categories, + list_ticket_conversations, + list_ticket_fields, + manifest, + remove_ticket_tags, + reply_to_forward, + search_solution_article, + set_ticket_priority, + set_ticket_status, + set_ticket_tags, + update_agent, + update_contact, + update_solution_article, + update_ticket, + update_ticket_field, +) +from modulex_integrations.tools.freshdesk.outputs import ( + AddNoteToTicketOutput, + AddTicketTagsOutput, + AssignTicketToAgentOutput, + AssignTicketToGroupOutput, + CloseTicketOutput, + CreateAgentOutput, + CreateCompanyOutput, + CreateContactOutput, + CreateMessageForThreadOutput, + CreateReplyOutput, + CreateSolutionArticleOutput, + CreateThreadOutput, + CreateTicketFieldOutput, + CreateTicketOutput, + DeleteSolutionArticleOutput, + ForwardTicketOutput, + GetAgentOutput, + GetCannedResponseOutput, + GetContactOutput, + GetFolderCannedResponsesOutput, + GetSolutionArticleOutput, + GetTicketOutput, + ListAgentsOutput, + ListAllFoldersOutput, + ListAllTicketsOutput, + ListCategoryFoldersOutput, + ListCompaniesOutput, + ListEmailConfigsOutput, + ListFolderArticlesOutput, + ListFolderCannedResponsesOutput, + ListRolesOutput, + ListSolutionCategoriesOutput, + ListTicketConversationsOutput, + ListTicketFieldsOutput, + RemoveTicketTagsOutput, + ReplyToForwardOutput, + SearchSolutionArticleOutput, + SetTicketPriorityOutput, + SetTicketStatusOutput, + SetTicketTagsOutput, + UpdateAgentOutput, + UpdateContactOutput, + UpdateSolutionArticleOutput, + UpdateTicketFieldOutput, + UpdateTicketOutput, +) + +_DOMAIN = "testcompany" +_API_KEY = "fake-api-key" + +API = "https://testcompany.freshdesk.com/api/v2" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(domain=_DOMAIN, api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_45_actions(self) -> None: + assert len(manifest.actions) == 45 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/tickets", + json={ + # TODO: fill in a representative response shape from upstream API docs + "id": 1, + "subject": "Test", + "status": 2, + "priority": 1, + }, + status_code=201, + ) + + result_dict = await create_ticket.ainvoke( + _args(subject="Test", description="

Body

", email="user@example.com") + ) + + assert isinstance(result_dict, dict) + result = CreateTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/tickets/1", + json={ + # TODO: fill in representative response + "id": 1, + "subject": "Test", + "status": 2, + }, + ) + + result_dict = await get_ticket.ainvoke(_args(ticket_id=1)) + + assert isinstance(result_dict, dict) + result = GetTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_all_tickets(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/tickets?per_page=100", + json=[{"id": 1}, {"id": 2}], + ) + + result_dict = await list_all_tickets.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListAllTicketsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 2 + + +@pytest.mark.asyncio +async def test_close_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/tickets/1", + json={"id": 1, "status": 5}, + ) + + result_dict = await close_ticket.ainvoke(_args(ticket_id=1)) + + assert isinstance(result_dict, dict) + result = CloseTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_contact(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/contacts", + json={"id": 100, "name": "John", "email": "john@example.com"}, + status_code=201, + ) + + result_dict = await create_contact.ainvoke( + _args(email="john@example.com", name="John") + ) + + assert isinstance(result_dict, dict) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_agents(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/agents?per_page=100", + json=[{"id": 1, "contact": {"email": "agent@co.com"}}], + ) + + result_dict = await list_agents.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListAgentsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 1 + + +@pytest.mark.asyncio +async def test_create_reply(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/tickets/1/reply", + json={"id": 50, "body": "

Reply

"}, + status_code=201, + ) + + result_dict = await create_reply.ainvoke( + _args(ticket_id=1, body="

Reply

") + ) + + assert isinstance(result_dict, dict) + result = CreateReplyOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_solution_categories(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/solutions/categories", + json=[{"id": 1, "name": "FAQ"}], + ) + + result_dict = await list_solution_categories.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListSolutionCategoriesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_companies(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/companies", + json=[{"id": 1, "name": "Acme"}], + ) + + result_dict = await list_companies.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListCompaniesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "subject": "Updated"}) + result_dict = await update_ticket.ainvoke(_args(ticket_id=1, subject="Updated")) + assert isinstance(result_dict, dict) + result = UpdateTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_add_note_to_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/tickets/1/notes", json={"id": 10, "body": "note"}, status_code=201) + result_dict = await add_note_to_ticket.ainvoke(_args(ticket_id=1, body="note")) + assert isinstance(result_dict, dict) + result = AddNoteToTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_add_ticket_tags(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/tickets/1", json={"id": 1, "tags": ["old"]}) + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "tags": ["old", "new"]}) + result_dict = await add_ticket_tags.ainvoke(_args(ticket_id=1, tags=["new"])) + assert isinstance(result_dict, dict) + result = AddTicketTagsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_remove_ticket_tags(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/tickets/1", json={"id": 1, "tags": ["a", "b"]}) + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "tags": ["a"]}) + result_dict = await remove_ticket_tags.ainvoke(_args(ticket_id=1, tags=["b"])) + assert isinstance(result_dict, dict) + result = RemoveTicketTagsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_set_ticket_tags(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "tags": ["x"]}) + result_dict = await set_ticket_tags.ainvoke(_args(ticket_id=1, tags=["x"])) + assert isinstance(result_dict, dict) + result = SetTicketTagsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_set_ticket_priority(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "priority": 3}) + result_dict = await set_ticket_priority.ainvoke(_args(ticket_id=1, priority=3)) + assert isinstance(result_dict, dict) + result = SetTicketPriorityOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_set_ticket_status(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "status": 3}) + result_dict = await set_ticket_status.ainvoke(_args(ticket_id=1, status=3)) + assert isinstance(result_dict, dict) + result = SetTicketStatusOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_assign_ticket_to_agent(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "responder_id": 5}) + result_dict = await assign_ticket_to_agent.ainvoke(_args(ticket_id=1, agent_id=5)) + assert isinstance(result_dict, dict) + result = AssignTicketToAgentOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_assign_ticket_to_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/tickets/1", json={"id": 1, "group_id": 2}) + result_dict = await assign_ticket_to_group.ainvoke(_args(ticket_id=1, group_id=2)) + assert isinstance(result_dict, dict) + result = AssignTicketToGroupOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_contact(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/contacts/1", json={"id": 1, "name": "Jane"}) + result_dict = await get_contact.ainvoke(_args(contact_id=1)) + assert isinstance(result_dict, dict) + result = GetContactOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_contact(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/contacts/1", json={"id": 1, "name": "Updated"}) + result_dict = await update_contact.ainvoke(_args(contact_id=1, name="Updated")) + assert isinstance(result_dict, dict) + result = UpdateContactOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_company(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/companies", json={"id": 1, "name": "Corp"}, status_code=201) + result_dict = await create_company.ainvoke(_args(name="Corp")) + assert isinstance(result_dict, dict) + result = CreateCompanyOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_agent(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/agents", json={"id": 1}, status_code=201) + result_dict = await create_agent.ainvoke(_args(email="a@b.com", ticket_scope=1)) + assert isinstance(result_dict, dict) + result = CreateAgentOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_agent(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/agents/1", json={"id": 1}) + result_dict = await update_agent.ainvoke(_args(agent_id=1, ticket_scope=2)) + assert isinstance(result_dict, dict) + result = UpdateAgentOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_agent(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/agents/1", json={"id": 1}) + result_dict = await get_agent.ainvoke(_args(agent_id=1)) + assert isinstance(result_dict, dict) + result = GetAgentOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_forward_ticket(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/tickets/1/forward", json={"id": 1}, status_code=201) + result_dict = await forward_ticket.ainvoke(_args(ticket_id=1, body="

FW

", to_emails=["x@y.com"])) + assert isinstance(result_dict, dict) + result = ForwardTicketOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_reply_to_forward(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/tickets/1/reply_to_forward", json={"id": 1}, status_code=201) + result_dict = await reply_to_forward.ainvoke(_args(ticket_id=1, body="

Re

", to_emails=["x@y.com"])) + assert isinstance(result_dict, dict) + result = ReplyToForwardOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_thread(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/collaboration/threads", json={"id": "t1"}, status_code=201) + result_dict = await create_thread.ainvoke(_args(ticket_id=1, type="discussion", email_config_id=10)) + assert isinstance(result_dict, dict) + result = CreateThreadOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_message_for_thread(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/collaboration/messages", json={"id": "m1"}, status_code=201) + result_dict = await create_message_for_thread.ainvoke(_args(ticket_id=1, thread_id="t1", body="

msg

")) + assert isinstance(result_dict, dict) + result = CreateMessageForThreadOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_ticket_conversations(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/tickets/1/conversations?per_page=100", json=[{"id": 1}]) + result_dict = await list_ticket_conversations.ainvoke(_args(ticket_id=1)) + assert isinstance(result_dict, dict) + result = ListTicketConversationsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_ticket_fields(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/ticket_fields?per_page=100", json=[{"id": 1}]) + result_dict = await list_ticket_fields.ainvoke(_args()) + assert isinstance(result_dict, dict) + result = ListTicketFieldsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_ticket_field(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/admin/ticket_fields", json={"id": 1}, status_code=201) + result_dict = await create_ticket_field.ainvoke(_args(label="Custom", label_for_customers="Custom", type="custom_text")) + assert isinstance(result_dict, dict) + result = CreateTicketFieldOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_ticket_field(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/admin/ticket_fields/1", json={"id": 1}) + result_dict = await update_ticket_field.ainvoke(_args(ticket_field_id="1", label="Renamed")) + assert isinstance(result_dict, dict) + result = UpdateTicketFieldOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_solution_article(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="POST", url=f"{API}/solutions/folders/1/articles", json={"id": 1}, status_code=201) + result_dict = await create_solution_article.ainvoke(_args(folder_id=1, title="Art", description="

body

", status=2)) + assert isinstance(result_dict, dict) + result = CreateSolutionArticleOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_solution_article(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/solutions/articles/1", json={"id": 1, "title": "Art"}) + result_dict = await get_solution_article.ainvoke(_args(article_id=1)) + assert isinstance(result_dict, dict) + result = GetSolutionArticleOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_solution_article(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="PUT", url=f"{API}/solutions/articles/1", json={"id": 1}) + result_dict = await update_solution_article.ainvoke(_args(article_id=1, title="Updated")) + assert isinstance(result_dict, dict) + result = UpdateSolutionArticleOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_delete_solution_article(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="DELETE", url=f"{API}/solutions/articles/1", status_code=204) + result_dict = await delete_solution_article.ainvoke(_args(article_id=1)) + assert isinstance(result_dict, dict) + result = DeleteSolutionArticleOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_search_solution_article(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/search/solutions?term=help", json=[{"id": 1}]) + result_dict = await search_solution_article.ainvoke(_args(term="help")) + assert isinstance(result_dict, dict) + result = SearchSolutionArticleOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_category_folders(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/solutions/categories/1/folders", json=[{"id": 1}]) + result_dict = await list_category_folders.ainvoke(_args(category_id=1)) + assert isinstance(result_dict, dict) + result = ListCategoryFoldersOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_folder_articles(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/solutions/folders/1/articles?per_page=100", json=[{"id": 1}]) + result_dict = await list_folder_articles.ainvoke(_args(folder_id=1)) + assert isinstance(result_dict, dict) + result = ListFolderArticlesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_all_folders(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/canned_response_folders?per_page=100", json=[{"id": 1}]) + result_dict = await list_all_folders.ainvoke(_args()) + assert isinstance(result_dict, dict) + result = ListAllFoldersOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_folder_canned_responses(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/canned_response_folders/1", json={"canned_responses": [{"id": 1}]}) + result_dict = await list_folder_canned_responses.ainvoke(_args(canned_response_folder_id=1)) + assert isinstance(result_dict, dict) + result = ListFolderCannedResponsesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_canned_response(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/canned_responses/1", json={"id": 1, "title": "Hello"}) + result_dict = await get_canned_response.ainvoke(_args(canned_response_id=1)) + assert isinstance(result_dict, dict) + result = GetCannedResponseOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_folder_canned_responses(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/canned_response_folders/1/responses?per_page=100", json=[{"id": 1}]) + result_dict = await get_folder_canned_responses.ainvoke(_args(canned_response_folder_id=1)) + assert isinstance(result_dict, dict) + result = GetFolderCannedResponsesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_email_configs(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/email_configs", json=[{"id": 1}]) + result_dict = await list_email_configs.ainvoke(_args()) + assert isinstance(result_dict, dict) + result = ListEmailConfigsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_roles(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response(method="GET", url=f"{API}/roles", json=[{"id": 1}]) + result_dict = await list_roles.ainvoke(_args()) + assert isinstance(result_dict, dict) + result = ListRolesOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_ticket_validates_empty_credentials() -> None: + result_dict = await create_ticket.ainvoke( + {"domain": "", "api_key": "", "subject": "x", "description": "x", "email": "x@x.com"} + ) + result = CreateTicketOutput.model_validate(result_dict) + assert result.success is False + assert "domain" in (result.error or "").lower() diff --git a/src/modulex_integrations/tools/freshdesk/tools.py b/src/modulex_integrations/tools/freshdesk/tools.py new file mode 100644 index 0000000..954dc42 --- /dev/null +++ b/src/modulex_integrations/tools/freshdesk/tools.py @@ -0,0 +1,1833 @@ +"""Freshdesk LangChain @tool functions.""" +from __future__ import annotations + +import base64 +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.freshdesk.outputs import ( + AddNoteToTicketOutput, + AddTicketTagsOutput, + AssignTicketToAgentOutput, + AssignTicketToGroupOutput, + CloseTicketOutput, + CreateAgentOutput, + CreateCompanyOutput, + CreateContactOutput, + CreateMessageForThreadOutput, + CreateReplyOutput, + CreateSolutionArticleOutput, + CreateThreadOutput, + CreateTicketFieldOutput, + CreateTicketOutput, + DeleteSolutionArticleOutput, + ForwardTicketOutput, + GetAgentOutput, + GetCannedResponseOutput, + GetContactOutput, + GetFolderCannedResponsesOutput, + GetSolutionArticleOutput, + GetTicketOutput, + ListAgentsOutput, + ListAllFoldersOutput, + ListAllTicketsOutput, + ListCategoryFoldersOutput, + ListCompaniesOutput, + ListEmailConfigsOutput, + ListFolderArticlesOutput, + ListFolderCannedResponsesOutput, + ListRolesOutput, + ListSolutionCategoriesOutput, + ListTicketConversationsOutput, + ListTicketFieldsOutput, + RemoveTicketTagsOutput, + ReplyToForwardOutput, + SearchSolutionArticleOutput, + SetTicketPriorityOutput, + SetTicketStatusOutput, + SetTicketTagsOutput, + UpdateAgentOutput, + UpdateContactOutput, + UpdateSolutionArticleOutput, + UpdateTicketFieldOutput, + UpdateTicketOutput, +) + +__all__ = [ + "add_note_to_ticket", + "add_ticket_tags", + "assign_ticket_to_agent", + "assign_ticket_to_group", + "close_ticket", + "create_agent", + "create_company", + "create_contact", + "create_message_for_thread", + "create_reply", + "create_solution_article", + "create_thread", + "create_ticket", + "create_ticket_field", + "delete_solution_article", + "forward_ticket", + "get_agent", + "get_canned_response", + "get_contact", + "get_folder_canned_responses", + "get_solution_article", + "get_ticket", + "list_agents", + "list_all_folders", + "list_all_tickets", + "list_category_folders", + "list_companies", + "list_email_configs", + "list_folder_articles", + "list_folder_canned_responses", + "list_roles", + "list_solution_categories", + "list_ticket_conversations", + "list_ticket_fields", + "remove_ticket_tags", + "reply_to_forward", + "search_solution_article", + "set_ticket_priority", + "set_ticket_status", + "set_ticket_tags", + "update_agent", + "update_contact", + "update_solution_article", + "update_ticket", + "update_ticket_field", +] + +_TIMEOUT = 30.0 + + +def _base_url(domain: str) -> str: + return f"https://{domain}.freshdesk.com/api/v2" + + +def _headers(api_key: str) -> dict[str, str]: + encoded = base64.b64encode(f"{api_key}:X".encode()).decode() + return { + "Authorization": f"Basic {encoded}", + "Content-Type": "application/json", + } + + +def _check_creds(domain: str, api_key: str) -> str | None: + if not domain or not domain.strip(): + return "Freshdesk domain is empty. Please configure a valid credential." + if not api_key or not api_key.strip(): + return "API key is empty. Please configure a valid credential." + return None + + +# --- Input schemas -------------------------------------------------------- + + +class CreateTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + subject: str = Field(description="Subject of the ticket") + description: str = Field(description="HTML content of the ticket") + email: str = Field(description="Email address of the requester") + priority: int = Field(default=1, description="Priority: 1 (Low), 2 (Medium), 3 (High), 4 (Urgent)") + status: int = Field(default=2, description="Status: 2 (Open), 3 (Pending), 4 (Resolved), 5 (Closed)") + company_id: int | None = Field(default=None, description="ID of the company") + + +class GetTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket to retrieve") + + +class UpdateTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket to update") + subject: str | None = Field(default=None, description="New subject") + description: str | None = Field(default=None, description="New HTML content") + priority: int | None = Field(default=None, description="Priority: 1-4") + status: int | None = Field(default=None, description="Status: 2-5") + group_id: int | None = Field(default=None, description="Group ID to assign to") + responder_id: int | None = Field(default=None, description="Agent ID to assign to") + + +class ListAllTicketsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + filter: str | None = Field(default=None, description="Predefined filter") + requester_id: int | None = Field(default=None, description="Filter by requester ID") + email: str | None = Field(default=None, description="Filter by email") + company_id: int | None = Field(default=None, description="Filter by company ID") + max_results: int = Field(default=100, description="Maximum results to return") + + +class CloseTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket to close") + + +class AddNoteToTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + body: str = Field(description="Content of the note in HTML format") + private: bool = Field(default=True, description="Whether the note is private") + notify_emails: list[str] | None = Field(default=None, description="Emails to notify") + + +class AddTicketTagsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + tags: list[str] = Field(description="Tags to add") + + +class RemoveTicketTagsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + tags: list[str] = Field(description="Tags to remove") + + +class SetTicketTagsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + tags: list[str] = Field(description="Tags to set (replaces existing)") + + +class SetTicketPriorityInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + priority: int = Field(description="Priority: 1 (Low), 2 (Medium), 3 (High), 4 (Urgent)") + + +class SetTicketStatusInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + status: int = Field(description="Status: 2 (Open), 3 (Pending), 4 (Resolved), 5 (Closed)") + + +class AssignTicketToAgentInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + agent_id: int = Field(description="ID of the agent") + + +class AssignTicketToGroupInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + group_id: int = Field(description="ID of the group") + + +class CreateContactInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + email: str = Field(description="Email address of the contact") + name: str = Field(description="Name of the contact") + phone: str | None = Field(default=None, description="Phone number") + company_id: int | None = Field(default=None, description="Company ID") + + +class GetContactInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + contact_id: int = Field(description="ID of the contact") + + +class UpdateContactInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + contact_id: int = Field(description="ID of the contact to update") + name: str | None = Field(default=None, description="Updated name") + email: str | None = Field(default=None, description="Updated email") + phone: str | None = Field(default=None, description="Updated phone") + company_id: int | None = Field(default=None, description="Company ID") + + +class CreateCompanyInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + name: str = Field(description="Name of the company") + domains: list[str] | None = Field(default=None, description="Domain names") + description: str | None = Field(default=None, description="Description") + + +class CreateAgentInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + email: str = Field(description="Email of the agent") + ticket_scope: int = Field(description="Ticket permission: 1 (Global), 2 (Group), 3 (Restricted)") + occasional: bool | None = Field(default=None, description="Occasional agent flag") + agent_type: int | None = Field(default=None, description="Type: 1 (Support), 2 (Field), 3 (Collaborator)") + + +class UpdateAgentInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + agent_id: int = Field(description="ID of the agent to update") + email: str | None = Field(default=None, description="Updated email") + ticket_scope: int | None = Field(default=None, description="Ticket permission") + occasional: bool | None = Field(default=None, description="Occasional agent flag") + + +class GetAgentInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + agent_id: int = Field(description="ID of the agent") + + +class ListAgentsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + email: str | None = Field(default=None, description="Filter by email") + state: str | None = Field(default=None, description="Filter: fulltime, occasional") + max_results: int = Field(default=100, description="Maximum results") + + +class CreateReplyInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + body: str = Field(description="Reply content in HTML format") + cc_emails: list[str] | None = Field(default=None, description="CC email addresses") + bcc_emails: list[str] | None = Field(default=None, description="BCC email addresses") + + +class ForwardTicketInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + body: str = Field(description="Forward content in HTML format") + to_emails: list[str] = Field(description="Email addresses to forward to") + cc_emails: list[str] | None = Field(default=None, description="CC email addresses") + bcc_emails: list[str] | None = Field(default=None, description="BCC email addresses") + + +class ReplyToForwardInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + body: str = Field(description="Reply content in HTML format") + to_emails: list[str] = Field(description="Email addresses to reply to") + + +class CreateThreadInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + type: str = Field(description="Thread type: forward, discussion") + email_config_id: int = Field(description="Email config ID") + + +class CreateMessageForThreadInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + thread_id: str = Field(description="ID of the thread") + body: str = Field(description="Message content in HTML format") + subject: str | None = Field(default=None, description="Subject of the email") + + +class ListTicketConversationsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_id: int = Field(description="ID of the ticket") + max_results: int = Field(default=100, description="Maximum results") + + +class ListTicketFieldsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + max_results: int = Field(default=100, description="Maximum results") + + +class CreateTicketFieldInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + label: str = Field(description="Display name of the field") + label_for_customers: str = Field(description="Label seen by customers") + type: str = Field(description="Field type: custom_dropdown, custom_checkbox, custom_text, etc.") + + +class UpdateTicketFieldInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + ticket_field_id: str = Field(description="ID of the ticket field") + label: str | None = Field(default=None, description="Updated display name") + label_for_customers: str | None = Field(default=None, description="Updated label for customers") + + +class CreateSolutionArticleInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + folder_id: int = Field(description="ID of the folder") + title: str = Field(description="Title of the article") + description: str = Field(description="HTML content of the article") + status: int = Field(description="Status: 1 (Draft), 2 (Published)") + tags: list[str] | None = Field(default=None, description="Tags for the article") + + +class GetSolutionArticleInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + article_id: int = Field(description="ID of the article") + + +class UpdateSolutionArticleInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + article_id: int = Field(description="ID of the article") + title: str | None = Field(default=None, description="Updated title") + description: str | None = Field(default=None, description="Updated HTML content") + status: int | None = Field(default=None, description="Status: 1 (Draft), 2 (Published)") + tags: list[str] | None = Field(default=None, description="Updated tags") + + +class DeleteSolutionArticleInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + article_id: int = Field(description="ID of the article to delete") + + +class SearchSolutionArticleInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + term: str = Field(description="Search keyword") + + +class ListSolutionCategoriesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + + +class ListCategoryFoldersInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + category_id: int = Field(description="ID of the category") + + +class ListFolderArticlesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + folder_id: int = Field(description="ID of the folder") + max_results: int = Field(default=100, description="Maximum results") + + +class ListAllFoldersInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + max_results: int = Field(default=100, description="Maximum results") + + +class ListFolderCannedResponsesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + canned_response_folder_id: int = Field(description="ID of the folder") + + +class GetCannedResponseInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + canned_response_id: int = Field(description="ID of the canned response") + + +class GetFolderCannedResponsesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + canned_response_folder_id: int = Field(description="ID of the folder") + max_results: int = Field(default=100, description="Maximum results") + + +class ListCompaniesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + + +class ListEmailConfigsInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + + +class ListRolesInput(BaseModel): + domain: str = Field(description="Freshdesk subdomain") + api_key: str = Field(description="Freshdesk API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateTicketInput) +@serialize_pydantic_return +async def create_ticket( + domain: str, + api_key: str, + subject: str, + description: str, + email: str, + priority: int = 1, + status: int = 2, + company_id: int | None = None, +) -> CreateTicketOutput: + """Create a new support ticket in Freshdesk""" + if err := _check_creds(domain, api_key): + return CreateTicketOutput(success=False, error=err) + payload: dict[str, Any] = { + "subject": subject, + "description": description, + "email": email, + "priority": priority, + "status": status, + } + if company_id is not None: + payload["company_id"] = company_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/tickets", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetTicketInput) +@serialize_pydantic_return +async def get_ticket( + domain: str, + api_key: str, + ticket_id: int, +) -> GetTicketOutput: + """Retrieve a specific ticket by its ID""" + if err := _check_creds(domain, api_key): + return GetTicketOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return GetTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return GetTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=UpdateTicketInput) +@serialize_pydantic_return +async def update_ticket( + domain: str, + api_key: str, + ticket_id: int, + subject: str | None = None, + description: str | None = None, + priority: int | None = None, + status: int | None = None, + group_id: int | None = None, + responder_id: int | None = None, +) -> UpdateTicketOutput: + """Update an existing ticket's properties""" + if err := _check_creds(domain, api_key): + return UpdateTicketOutput(success=False, error=err) + payload: dict[str, Any] = {} + if subject is not None: + payload["subject"] = subject + if description is not None: + payload["description"] = description + if priority is not None: + payload["priority"] = priority + if status is not None: + payload["status"] = status + if group_id is not None: + payload["group_id"] = group_id + if responder_id is not None: + payload["responder_id"] = responder_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code != 200: + return UpdateTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return UpdateTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return UpdateTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListAllTicketsInput) +@serialize_pydantic_return +async def list_all_tickets( + domain: str, + api_key: str, + filter: str | None = None, + requester_id: int | None = None, + email: str | None = None, + company_id: int | None = None, + max_results: int = 100, +) -> ListAllTicketsOutput: + """List tickets in Freshdesk with optional filtering""" + if err := _check_creds(domain, api_key): + return ListAllTicketsOutput(success=False, error=err) + params: dict[str, Any] = {"per_page": min(max_results, 100)} + if filter is not None: + params["filter"] = filter + if requester_id is not None: + params["requester_id"] = requester_id + if email is not None: + params["email"] = email + if company_id is not None: + params["company_id"] = company_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/tickets", + headers=_headers(api_key), + params=params, + ) + if resp.status_code != 200: + return ListAllTicketsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListAllTicketsOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListAllTicketsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAllTicketsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CloseTicketInput) +@serialize_pydantic_return +async def close_ticket( + domain: str, + api_key: str, + ticket_id: int, +) -> CloseTicketOutput: + """Close a ticket by setting its status to Closed (5)""" + if err := _check_creds(domain, api_key): + return CloseTicketOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"status": 5}, + ) + if resp.status_code != 200: + return CloseTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CloseTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CloseTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return CloseTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=AddNoteToTicketInput) +@serialize_pydantic_return +async def add_note_to_ticket( + domain: str, + api_key: str, + ticket_id: int, + body: str, + private: bool = True, + notify_emails: list[str] | None = None, +) -> AddNoteToTicketOutput: + """Add a private or public note to a ticket""" + if err := _check_creds(domain, api_key): + return AddNoteToTicketOutput(success=False, error=err) + payload: dict[str, Any] = {"body": body, "private": private} + if notify_emails: + payload["notify_emails"] = notify_emails + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/tickets/{ticket_id}/notes", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return AddNoteToTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return AddNoteToTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return AddNoteToTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddNoteToTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=AddTicketTagsInput) +@serialize_pydantic_return +async def add_ticket_tags( + domain: str, + api_key: str, + ticket_id: int, + tags: list[str], +) -> AddTicketTagsOutput: + """Add tags to an existing ticket""" + if err := _check_creds(domain, api_key): + return AddTicketTagsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return AddTicketTagsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + existing_tags: list[str] = resp.json().get("tags", []) + merged = list(set(existing_tags + tags)) + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"tags": merged}, + ) + if resp.status_code != 200: + return AddTicketTagsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return AddTicketTagsOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return AddTicketTagsOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddTicketTagsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=RemoveTicketTagsInput) +@serialize_pydantic_return +async def remove_ticket_tags( + domain: str, + api_key: str, + ticket_id: int, + tags: list[str], +) -> RemoveTicketTagsOutput: + """Remove tags from an existing ticket""" + if err := _check_creds(domain, api_key): + return RemoveTicketTagsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return RemoveTicketTagsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + existing_tags: list[str] = resp.json().get("tags", []) + remaining = [t for t in existing_tags if t not in tags] + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"tags": remaining}, + ) + if resp.status_code != 200: + return RemoveTicketTagsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return RemoveTicketTagsOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return RemoveTicketTagsOutput(success=False, error="Request timed out.") + except Exception as exc: + return RemoveTicketTagsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=SetTicketTagsInput) +@serialize_pydantic_return +async def set_ticket_tags( + domain: str, + api_key: str, + ticket_id: int, + tags: list[str], +) -> SetTicketTagsOutput: + """Replace all tags on a ticket with the specified set""" + if err := _check_creds(domain, api_key): + return SetTicketTagsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"tags": tags}, + ) + if resp.status_code != 200: + return SetTicketTagsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return SetTicketTagsOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return SetTicketTagsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SetTicketTagsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=SetTicketPriorityInput) +@serialize_pydantic_return +async def set_ticket_priority( + domain: str, + api_key: str, + ticket_id: int, + priority: int, +) -> SetTicketPriorityOutput: + """Set the priority of a ticket""" + if err := _check_creds(domain, api_key): + return SetTicketPriorityOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"priority": priority}, + ) + if resp.status_code != 200: + return SetTicketPriorityOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return SetTicketPriorityOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return SetTicketPriorityOutput(success=False, error="Request timed out.") + except Exception as exc: + return SetTicketPriorityOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=SetTicketStatusInput) +@serialize_pydantic_return +async def set_ticket_status( + domain: str, + api_key: str, + ticket_id: int, + status: int, +) -> SetTicketStatusOutput: + """Set the status of a ticket""" + if err := _check_creds(domain, api_key): + return SetTicketStatusOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"status": status}, + ) + if resp.status_code != 200: + return SetTicketStatusOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return SetTicketStatusOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return SetTicketStatusOutput(success=False, error="Request timed out.") + except Exception as exc: + return SetTicketStatusOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=AssignTicketToAgentInput) +@serialize_pydantic_return +async def assign_ticket_to_agent( + domain: str, + api_key: str, + ticket_id: int, + agent_id: int, +) -> AssignTicketToAgentOutput: + """Assign a ticket to a specific agent""" + if err := _check_creds(domain, api_key): + return AssignTicketToAgentOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"responder_id": agent_id}, + ) + if resp.status_code != 200: + return AssignTicketToAgentOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return AssignTicketToAgentOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return AssignTicketToAgentOutput(success=False, error="Request timed out.") + except Exception as exc: + return AssignTicketToAgentOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=AssignTicketToGroupInput) +@serialize_pydantic_return +async def assign_ticket_to_group( + domain: str, + api_key: str, + ticket_id: int, + group_id: int, +) -> AssignTicketToGroupOutput: + """Assign a ticket to a specific group""" + if err := _check_creds(domain, api_key): + return AssignTicketToGroupOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/tickets/{ticket_id}", + headers=_headers(api_key), + json={"group_id": group_id}, + ) + if resp.status_code != 200: + return AssignTicketToGroupOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return AssignTicketToGroupOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return AssignTicketToGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return AssignTicketToGroupOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateContactInput) +@serialize_pydantic_return +async def create_contact( + domain: str, + api_key: str, + email: str, + name: str, + phone: str | None = None, + company_id: int | None = None, +) -> CreateContactOutput: + """Create a new contact in Freshdesk""" + if err := _check_creds(domain, api_key): + return CreateContactOutput(success=False, error=err) + payload: dict[str, Any] = {"email": email, "name": name} + if phone is not None: + payload["phone"] = phone + if company_id is not None: + payload["company_id"] = company_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/contacts", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateContactOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateContactOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateContactOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateContactOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetContactInput) +@serialize_pydantic_return +async def get_contact( + domain: str, + api_key: str, + contact_id: int, +) -> GetContactOutput: + """Retrieve a contact by their ID""" + if err := _check_creds(domain, api_key): + return GetContactOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/contacts/{contact_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return GetContactOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetContactOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return GetContactOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetContactOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=UpdateContactInput) +@serialize_pydantic_return +async def update_contact( + domain: str, + api_key: str, + contact_id: int, + name: str | None = None, + email: str | None = None, + phone: str | None = None, + company_id: int | None = None, +) -> UpdateContactOutput: + """Update an existing contact's properties""" + if err := _check_creds(domain, api_key): + return UpdateContactOutput(success=False, error=err) + payload: dict[str, Any] = {} + if name is not None: + payload["name"] = name + if email is not None: + payload["email"] = email + if phone is not None: + payload["phone"] = phone + if company_id is not None: + payload["company_id"] = company_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/contacts/{contact_id}", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code != 200: + return UpdateContactOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return UpdateContactOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return UpdateContactOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateContactOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateCompanyInput) +@serialize_pydantic_return +async def create_company( + domain: str, + api_key: str, + name: str, + domains: list[str] | None = None, + description: str | None = None, +) -> CreateCompanyOutput: + """Create a new company in Freshdesk""" + if err := _check_creds(domain, api_key): + return CreateCompanyOutput(success=False, error=err) + payload: dict[str, Any] = {"name": name} + if domains is not None: + payload["domains"] = domains + if description is not None: + payload["description"] = description + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/companies", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateCompanyOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateCompanyOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateCompanyOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateCompanyOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateAgentInput) +@serialize_pydantic_return +async def create_agent( + domain: str, + api_key: str, + email: str, + ticket_scope: int, + occasional: bool | None = None, + agent_type: int | None = None, +) -> CreateAgentOutput: + """Create a new agent in Freshdesk""" + if err := _check_creds(domain, api_key): + return CreateAgentOutput(success=False, error=err) + payload: dict[str, Any] = {"email": email, "ticket_scope": ticket_scope} + if occasional is not None: + payload["occasional"] = occasional + if agent_type is not None: + payload["agent_type"] = agent_type + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/agents", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateAgentOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateAgentOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateAgentOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateAgentOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=UpdateAgentInput) +@serialize_pydantic_return +async def update_agent( + domain: str, + api_key: str, + agent_id: int, + email: str | None = None, + ticket_scope: int | None = None, + occasional: bool | None = None, +) -> UpdateAgentOutput: + """Update an existing agent's properties""" + if err := _check_creds(domain, api_key): + return UpdateAgentOutput(success=False, error=err) + payload: dict[str, Any] = {} + if email is not None: + payload["email"] = email + if ticket_scope is not None: + payload["ticket_scope"] = ticket_scope + if occasional is not None: + payload["occasional"] = occasional + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/agents/{agent_id}", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code != 200: + return UpdateAgentOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return UpdateAgentOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return UpdateAgentOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateAgentOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetAgentInput) +@serialize_pydantic_return +async def get_agent( + domain: str, + api_key: str, + agent_id: int, +) -> GetAgentOutput: + """Retrieve a single agent by their ID""" + if err := _check_creds(domain, api_key): + return GetAgentOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/agents/{agent_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return GetAgentOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetAgentOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return GetAgentOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetAgentOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListAgentsInput) +@serialize_pydantic_return +async def list_agents( + domain: str, + api_key: str, + email: str | None = None, + state: str | None = None, + max_results: int = 100, +) -> ListAgentsOutput: + """List all agents in Freshdesk with optional filtering""" + if err := _check_creds(domain, api_key): + return ListAgentsOutput(success=False, error=err) + params: dict[str, Any] = {"per_page": min(max_results, 100)} + if email is not None: + params["email"] = email + if state is not None: + params["state"] = state + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/agents", + headers=_headers(api_key), + params=params, + ) + if resp.status_code != 200: + return ListAgentsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListAgentsOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListAgentsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAgentsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateReplyInput) +@serialize_pydantic_return +async def create_reply( + domain: str, + api_key: str, + ticket_id: int, + body: str, + cc_emails: list[str] | None = None, + bcc_emails: list[str] | None = None, +) -> CreateReplyOutput: + """Create a reply to a ticket""" + if err := _check_creds(domain, api_key): + return CreateReplyOutput(success=False, error=err) + payload: dict[str, Any] = {"body": body} + if cc_emails: + payload["cc_emails"] = cc_emails + if bcc_emails: + payload["bcc_emails"] = bcc_emails + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/tickets/{ticket_id}/reply", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateReplyOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateReplyOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateReplyOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateReplyOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ForwardTicketInput) +@serialize_pydantic_return +async def forward_ticket( + domain: str, + api_key: str, + ticket_id: int, + body: str, + to_emails: list[str], + cc_emails: list[str] | None = None, + bcc_emails: list[str] | None = None, +) -> ForwardTicketOutput: + """Forward a ticket to an external email address""" + if err := _check_creds(domain, api_key): + return ForwardTicketOutput(success=False, error=err) + payload: dict[str, Any] = {"body": body, "to_emails": to_emails} + if cc_emails: + payload["cc_emails"] = cc_emails + if bcc_emails: + payload["bcc_emails"] = bcc_emails + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/tickets/{ticket_id}/forward", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return ForwardTicketOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ForwardTicketOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return ForwardTicketOutput(success=False, error="Request timed out.") + except Exception as exc: + return ForwardTicketOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ReplyToForwardInput) +@serialize_pydantic_return +async def reply_to_forward( + domain: str, + api_key: str, + ticket_id: int, + body: str, + to_emails: list[str], +) -> ReplyToForwardOutput: + """Reply to a previously forwarded ticket email""" + if err := _check_creds(domain, api_key): + return ReplyToForwardOutput(success=False, error=err) + payload: dict[str, Any] = {"body": body, "to_emails": to_emails} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/tickets/{ticket_id}/reply_to_forward", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return ReplyToForwardOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ReplyToForwardOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return ReplyToForwardOutput(success=False, error="Request timed out.") + except Exception as exc: + return ReplyToForwardOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateThreadInput) +@serialize_pydantic_return +async def create_thread( + domain: str, + api_key: str, + ticket_id: int, + type: str, + email_config_id: int, +) -> CreateThreadOutput: + """Create a collaboration thread on a ticket""" + if err := _check_creds(domain, api_key): + return CreateThreadOutput(success=False, error=err) + payload: dict[str, Any] = { + "type": type, + "ticket_id": ticket_id, + "email_config_id": email_config_id, + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/collaboration/threads", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateThreadOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateThreadOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateThreadOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateThreadOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateMessageForThreadInput) +@serialize_pydantic_return +async def create_message_for_thread( + domain: str, + api_key: str, + ticket_id: int, + thread_id: str, + body: str, + subject: str | None = None, +) -> CreateMessageForThreadOutput: + """Create a message in a collaboration thread""" + if err := _check_creds(domain, api_key): + return CreateMessageForThreadOutput(success=False, error=err) + payload: dict[str, Any] = { + "ticket_id": ticket_id, + "thread_id": thread_id, + "body": body, + } + if subject is not None: + payload["subject"] = subject + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/collaboration/messages", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateMessageForThreadOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateMessageForThreadOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateMessageForThreadOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateMessageForThreadOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListTicketConversationsInput) +@serialize_pydantic_return +async def list_ticket_conversations( + domain: str, + api_key: str, + ticket_id: int, + max_results: int = 100, +) -> ListTicketConversationsOutput: + """List all conversations (notes, replies) for a ticket""" + if err := _check_creds(domain, api_key): + return ListTicketConversationsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/tickets/{ticket_id}/conversations", + headers=_headers(api_key), + params={"per_page": min(max_results, 100)}, + ) + if resp.status_code != 200: + return ListTicketConversationsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListTicketConversationsOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListTicketConversationsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListTicketConversationsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListTicketFieldsInput) +@serialize_pydantic_return +async def list_ticket_fields( + domain: str, + api_key: str, + max_results: int = 100, +) -> ListTicketFieldsOutput: + """List all ticket fields configured in Freshdesk""" + if err := _check_creds(domain, api_key): + return ListTicketFieldsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/ticket_fields", + headers=_headers(api_key), + params={"per_page": min(max_results, 100)}, + ) + if resp.status_code != 200: + return ListTicketFieldsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListTicketFieldsOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListTicketFieldsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListTicketFieldsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateTicketFieldInput) +@serialize_pydantic_return +async def create_ticket_field( + domain: str, + api_key: str, + label: str, + label_for_customers: str, + type: str, +) -> CreateTicketFieldOutput: + """Create a new custom ticket field""" + if err := _check_creds(domain, api_key): + return CreateTicketFieldOutput(success=False, error=err) + payload: dict[str, Any] = { + "label": label, + "label_for_customers": label_for_customers, + "type": type, + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/admin/ticket_fields", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateTicketFieldOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateTicketFieldOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateTicketFieldOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateTicketFieldOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=UpdateTicketFieldInput) +@serialize_pydantic_return +async def update_ticket_field( + domain: str, + api_key: str, + ticket_field_id: str, + label: str | None = None, + label_for_customers: str | None = None, +) -> UpdateTicketFieldOutput: + """Update a custom ticket field""" + if err := _check_creds(domain, api_key): + return UpdateTicketFieldOutput(success=False, error=err) + payload: dict[str, Any] = {} + if label is not None: + payload["label"] = label + if label_for_customers is not None: + payload["label_for_customers"] = label_for_customers + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/admin/ticket_fields/{ticket_field_id}", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code != 200: + return UpdateTicketFieldOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return UpdateTicketFieldOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return UpdateTicketFieldOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateTicketFieldOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=CreateSolutionArticleInput) +@serialize_pydantic_return +async def create_solution_article( + domain: str, + api_key: str, + folder_id: int, + title: str, + description: str, + status: int, + tags: list[str] | None = None, +) -> CreateSolutionArticleOutput: + """Create a knowledge base article in a folder""" + if err := _check_creds(domain, api_key): + return CreateSolutionArticleOutput(success=False, error=err) + payload: dict[str, Any] = { + "title": title, + "description": description, + "status": status, + } + if tags: + payload["tags"] = tags + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{_base_url(domain)}/solutions/folders/{folder_id}/articles", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code not in (200, 201): + return CreateSolutionArticleOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return CreateSolutionArticleOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return CreateSolutionArticleOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateSolutionArticleOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetSolutionArticleInput) +@serialize_pydantic_return +async def get_solution_article( + domain: str, + api_key: str, + article_id: int, +) -> GetSolutionArticleOutput: + """Retrieve a knowledge base article by its ID""" + if err := _check_creds(domain, api_key): + return GetSolutionArticleOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/solutions/articles/{article_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return GetSolutionArticleOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetSolutionArticleOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return GetSolutionArticleOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetSolutionArticleOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=UpdateSolutionArticleInput) +@serialize_pydantic_return +async def update_solution_article( + domain: str, + api_key: str, + article_id: int, + title: str | None = None, + description: str | None = None, + status: int | None = None, + tags: list[str] | None = None, +) -> UpdateSolutionArticleOutput: + """Update a knowledge base article""" + if err := _check_creds(domain, api_key): + return UpdateSolutionArticleOutput(success=False, error=err) + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if status is not None: + payload["status"] = status + if tags is not None: + payload["tags"] = tags + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.put( + f"{_base_url(domain)}/solutions/articles/{article_id}", + headers=_headers(api_key), + json=payload, + ) + if resp.status_code != 200: + return UpdateSolutionArticleOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return UpdateSolutionArticleOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return UpdateSolutionArticleOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateSolutionArticleOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=DeleteSolutionArticleInput) +@serialize_pydantic_return +async def delete_solution_article( + domain: str, + api_key: str, + article_id: int, +) -> DeleteSolutionArticleOutput: + """Delete a knowledge base article""" + if err := _check_creds(domain, api_key): + return DeleteSolutionArticleOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.delete( + f"{_base_url(domain)}/solutions/articles/{article_id}", + headers=_headers(api_key), + ) + if resp.status_code not in (200, 204): + return DeleteSolutionArticleOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return DeleteSolutionArticleOutput(success=True) + except httpx.TimeoutException: + return DeleteSolutionArticleOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteSolutionArticleOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=SearchSolutionArticleInput) +@serialize_pydantic_return +async def search_solution_article( + domain: str, + api_key: str, + term: str, +) -> SearchSolutionArticleOutput: + """Search knowledge base articles by keyword""" + if err := _check_creds(domain, api_key): + return SearchSolutionArticleOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/search/solutions", + headers=_headers(api_key), + params={"term": term}, + ) + if resp.status_code != 200: + return SearchSolutionArticleOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return SearchSolutionArticleOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return SearchSolutionArticleOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchSolutionArticleOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListSolutionCategoriesInput) +@serialize_pydantic_return +async def list_solution_categories( + domain: str, + api_key: str, +) -> ListSolutionCategoriesOutput: + """List all knowledge base solution categories""" + if err := _check_creds(domain, api_key): + return ListSolutionCategoriesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/solutions/categories", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListSolutionCategoriesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListSolutionCategoriesOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListSolutionCategoriesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListSolutionCategoriesOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListCategoryFoldersInput) +@serialize_pydantic_return +async def list_category_folders( + domain: str, + api_key: str, + category_id: int, +) -> ListCategoryFoldersOutput: + """List all folders within a solution category""" + if err := _check_creds(domain, api_key): + return ListCategoryFoldersOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/solutions/categories/{category_id}/folders", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListCategoryFoldersOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListCategoryFoldersOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListCategoryFoldersOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCategoryFoldersOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListFolderArticlesInput) +@serialize_pydantic_return +async def list_folder_articles( + domain: str, + api_key: str, + folder_id: int, + max_results: int = 100, +) -> ListFolderArticlesOutput: + """List all articles within a solution folder""" + if err := _check_creds(domain, api_key): + return ListFolderArticlesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/solutions/folders/{folder_id}/articles", + headers=_headers(api_key), + params={"per_page": min(max_results, 100)}, + ) + if resp.status_code != 200: + return ListFolderArticlesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListFolderArticlesOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListFolderArticlesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListFolderArticlesOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListAllFoldersInput) +@serialize_pydantic_return +async def list_all_folders( + domain: str, + api_key: str, + max_results: int = 100, +) -> ListAllFoldersOutput: + """List all canned response folders""" + if err := _check_creds(domain, api_key): + return ListAllFoldersOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/canned_response_folders", + headers=_headers(api_key), + params={"per_page": min(max_results, 100)}, + ) + if resp.status_code != 200: + return ListAllFoldersOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListAllFoldersOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListAllFoldersOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAllFoldersOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListFolderCannedResponsesInput) +@serialize_pydantic_return +async def list_folder_canned_responses( + domain: str, + api_key: str, + canned_response_folder_id: int, +) -> ListFolderCannedResponsesOutput: + """List all canned responses in a specific folder""" + if err := _check_creds(domain, api_key): + return ListFolderCannedResponsesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/canned_response_folders/{canned_response_folder_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListFolderCannedResponsesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + data = resp.json() + items = data.get("canned_responses", []) if isinstance(data, dict) else data + return ListFolderCannedResponsesOutput(success=True, items=items) + except httpx.TimeoutException: + return ListFolderCannedResponsesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListFolderCannedResponsesOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetCannedResponseInput) +@serialize_pydantic_return +async def get_canned_response( + domain: str, + api_key: str, + canned_response_id: int, +) -> GetCannedResponseOutput: + """Retrieve a specific canned response by ID""" + if err := _check_creds(domain, api_key): + return GetCannedResponseOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/canned_responses/{canned_response_id}", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return GetCannedResponseOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetCannedResponseOutput(success=True, data=resp.json()) + except httpx.TimeoutException: + return GetCannedResponseOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetCannedResponseOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=GetFolderCannedResponsesInput) +@serialize_pydantic_return +async def get_folder_canned_responses( + domain: str, + api_key: str, + canned_response_folder_id: int, + max_results: int = 100, +) -> GetFolderCannedResponsesOutput: + """Get detailed canned responses from a folder""" + if err := _check_creds(domain, api_key): + return GetFolderCannedResponsesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/canned_response_folders/{canned_response_folder_id}/responses", + headers=_headers(api_key), + params={"per_page": min(max_results, 100)}, + ) + if resp.status_code != 200: + return GetFolderCannedResponsesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return GetFolderCannedResponsesOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return GetFolderCannedResponsesOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetFolderCannedResponsesOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListCompaniesInput) +@serialize_pydantic_return +async def list_companies( + domain: str, + api_key: str, +) -> ListCompaniesOutput: + """List all companies in Freshdesk""" + if err := _check_creds(domain, api_key): + return ListCompaniesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/companies", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListCompaniesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListCompaniesOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListCompaniesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCompaniesOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListEmailConfigsInput) +@serialize_pydantic_return +async def list_email_configs( + domain: str, + api_key: str, +) -> ListEmailConfigsOutput: + """List all email configurations""" + if err := _check_creds(domain, api_key): + return ListEmailConfigsOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/email_configs", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListEmailConfigsOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListEmailConfigsOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListEmailConfigsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListEmailConfigsOutput(success=False, error=f"Call failed: {exc}") + + +@tool(args_schema=ListRolesInput) +@serialize_pydantic_return +async def list_roles( + domain: str, + api_key: str, +) -> ListRolesOutput: + """List all agent roles""" + if err := _check_creds(domain, api_key): + return ListRolesOutput(success=False, error=err) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get( + f"{_base_url(domain)}/roles", + headers=_headers(api_key), + ) + if resp.status_code != 200: + return ListRolesOutput(success=False, error=f"API error ({resp.status_code}): {resp.text}") + return ListRolesOutput(success=True, items=resp.json()) + except httpx.TimeoutException: + return ListRolesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListRolesOutput(success=False, error=f"Call failed: {exc}") diff --git a/src/modulex_integrations/tools/help_scout/README.md b/src/modulex_integrations/tools/help_scout/README.md new file mode 100644 index 0000000..2684f24 --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/README.md @@ -0,0 +1,39 @@ +# Help Scout + +Customer support helpdesk platform integration against the Help Scout Mailbox API v2 (`api.helpscout.net/v2`). + +## Authentication + +### OAuth2 Authentication + +- Register an OAuth app at the [Help Scout developer console](https://developer.helpscout.com/mailbox-api/overview/authentication/). +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required env vars (custom app only): + - `HELP_SCOUT_OAUTH2_CLIENT_ID` — OAuth App Client ID + - `HELP_SCOUT_OAUTH2_CLIENT_SECRET` — OAuth App Client Secret +- Help Scout does not use granular OAuth scopes; access is controlled at the app level. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `add_note` | Adds a note to an existing conversation in Help Scout | `conversation_id`, `text` | +| `create_customer` | Creates a new customer record in Help Scout | _(all optional)_ | +| `get_conversation_details` | Retrieves the details of a specific conversation | `conversation_id` | +| `get_conversation_threads` | Retrieves the threads of a specific conversation | `conversation_id` | +| `get_tag_by_id` | Gets a tag by its ID | `tag_id` | +| `list_tags` | Lists all tags in Help Scout | _(none)_ | +| `send_reply` | Sends a reply to a conversation (sends an actual email to the customer) | `conversation_id`, `customer_id`, `text`, `draft` | +| `update_conversation` | Updates a conversation using a specified operation | `conversation_id`, `operation`, `value` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth2 credential. + +## Limits & Quotas + +- Help Scout API rate limit: 400 requests per minute per OAuth app. +- Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Retry-After`) are returned on every response. +- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/help_scout/__init__.py b/src/modulex_integrations/tools/help_scout/__init__.py new file mode 100644 index 0000000..d775155 --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/__init__.py @@ -0,0 +1,36 @@ +"""Help Scout integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.help_scout.manifest import manifest +from modulex_integrations.tools.help_scout.tools import ( + add_note, + create_customer, + get_conversation_details, + get_conversation_threads, + get_tag_by_id, + list_tags, + send_reply, + update_conversation, +) + +TOOLS = ( + add_note, + create_customer, + get_conversation_details, + get_conversation_threads, + get_tag_by_id, + list_tags, + send_reply, + update_conversation, +) + +__all__ = [ + "TOOLS", + "add_note", + "create_customer", + "get_conversation_details", + "get_conversation_threads", + "get_tag_by_id", + "list_tags", + "manifest", + "send_reply", + "update_conversation", +] diff --git a/src/modulex_integrations/tools/help_scout/dependencies.toml b/src/modulex_integrations/tools/help_scout/dependencies.toml new file mode 100644 index 0000000..5ca3d2c --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the help_scout integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/help_scout/manifest.py b/src/modulex_integrations/tools/help_scout/manifest.py new file mode 100644 index 0000000..e0fb26e --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/manifest.py @@ -0,0 +1,290 @@ +"""Help Scout integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="help_scout", + display_name="Help Scout", + description="Customer support helpdesk platform with shared inboxes, knowledge base, and live chat", + version="1.0.0", + author="ModuleX", + logo="modulex:help_scout-themed", + app_url="https://www.helpscout.com", + categories=["Customer Support", "Communication"], + actions=[ + ActionDefinition( + name="add_note", + description="Adds a note to an existing conversation in Help Scout", + parameters={ + "conversation_id": ParameterDef( + type="string", + description="The unique identifier of the conversation", + required=True, + ), + "text": ParameterDef( + type="string", + description="The content of the note", + required=True, + ), + "user_id": ParameterDef( + type="string", + description="The unique identifier of the user creating the note", + ), + }, + ), + ActionDefinition( + name="create_customer", + description="Creates a new customer record in Help Scout", + parameters={ + "first_name": ParameterDef( + type="string", + description="First name of the customer (1-40 characters)", + ), + "last_name": ParameterDef( + type="string", + description="Last name of the customer (1-40 characters)", + ), + "phone": ParameterDef( + type="string", + description="Phone number for the new customer", + ), + "photo_url": ParameterDef( + type="string", + description="URL of the customer's photo (max 200 characters)", + ), + "job_title": ParameterDef( + type="string", + description="Job title (max 60 characters)", + ), + "photo_type": ParameterDef( + type="string", + description="Type of photo: unknown, gravatar, twitter, facebook, googleprofile, googleplus, linkedin, instagram", + ), + "background": ParameterDef( + type="string", + description="Notes field content (max 200 characters)", + ), + "location": ParameterDef( + type="string", + description="Location of the customer (max 60 characters)", + ), + "organization": ParameterDef( + type="string", + description="Organization name (max 60 characters)", + ), + "gender": ParameterDef( + type="string", + description="Gender: male, female, unknown", + ), + "age": ParameterDef( + type="string", + description="Customer's age", + ), + "emails": ParameterDef( + type="array", + description="List of email entries as JSON objects with 'type' and 'value' fields", + ), + "phones": ParameterDef( + type="array", + description="List of phone entries as JSON objects with 'type' and 'value' fields", + ), + "chats": ParameterDef( + type="array", + description="List of chat entries as JSON objects with 'type' and 'value' fields", + ), + "social_profiles": ParameterDef( + type="array", + description="List of social profile entries as JSON objects with 'type' and 'value' fields", + ), + "websites": ParameterDef( + type="array", + description="List of website entries as JSON objects with 'value' field", + ), + "address_city": ParameterDef( + type="string", + description="City of the customer's address", + ), + "address_state": ParameterDef( + type="string", + description="State of the customer's address", + ), + "address_postal_code": ParameterDef( + type="string", + description="Postal code of the customer's address", + ), + "address_country": ParameterDef( + type="string", + description="ISO 3166 Alpha-2 country code for the customer's address", + ), + "address_lines": ParameterDef( + type="array", + description="List of address line strings", + ), + "properties": ParameterDef( + type="array", + description="List of property entries as JSON objects", + ), + }, + ), + ActionDefinition( + name="get_conversation_details", + description="Retrieves the details of a specific conversation", + parameters={ + "conversation_id": ParameterDef( + type="string", + description="The unique identifier of the conversation", + required=True, + ), + "embed": ParameterDef( + type="boolean", + description="If true, the response will include the threads of the conversation", + ), + }, + ), + ActionDefinition( + name="get_conversation_threads", + description="Retrieves the threads of a specific conversation", + parameters={ + "conversation_id": ParameterDef( + type="string", + description="The unique identifier of the conversation", + required=True, + ), + "page": ParameterDef( + type="integer", + description="Page number to retrieve (25 threads per page)", + default=1, + ), + }, + ), + ActionDefinition( + name="get_tag_by_id", + description="Gets a tag by its ID", + parameters={ + "tag_id": ParameterDef( + type="string", + description="The unique identifier of the tag", + required=True, + ), + }, + ), + ActionDefinition( + name="list_tags", + description="Lists all tags in Help Scout", + parameters={ + "page": ParameterDef( + type="integer", + description="The page number to return (defaults to 1)", + default=1, + ), + }, + ), + ActionDefinition( + name="send_reply", + description="Sends a reply to a conversation (sends an actual email to the customer)", + parameters={ + "conversation_id": ParameterDef( + type="string", + description="The unique identifier of the conversation", + required=True, + ), + "customer_id": ParameterDef( + type="string", + description="The unique identifier of the customer", + required=True, + ), + "text": ParameterDef( + type="string", + description="The content of the reply", + required=True, + ), + "draft": ParameterDef( + type="boolean", + description="If true, a draft reply is created instead of sending", + default=False, + required=True, + ), + }, + ), + ActionDefinition( + name="update_conversation", + description="Updates a conversation using a specified operation", + parameters={ + "conversation_id": ParameterDef( + type="string", + description="The unique identifier of the conversation", + required=True, + ), + "operation": ParameterDef( + type="string", + description="Operation to perform: Change subject, Change customer, Publish draft, Move conversation to another inbox, Change conversation status, Change conversation owner, Un-assign conversation", + required=True, + ), + "value": ParameterDef( + type="string", + description="Value for the operation (string for subject/status, number for customer/mailboxId/assignTo, 'true'/'false' for draft)", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Help Scout OAuth2 (recommended)", + setup_environment_variables=[ + EnvVar( + name="HELP_SCOUT_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Help Scout OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://developer.helpscout.com/mailbox-api/overview/authentication/", + ), + EnvVar( + name="HELP_SCOUT_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Help Scout OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://developer.helpscout.com/mailbox-api/overview/authentication/", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://secure.helpscout.net/authentication/authorizeClientApplication", + token_url="https://api.helpscout.net/v2/oauth2/token", + scopes=[], + ), + test_endpoint=TestEndpoint( + url="https://api.helpscout.net/v2/users/me", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["id"], + ), + cost_level="free", + description="Validates OAuth token by fetching the authenticated user", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/help_scout/outputs.py b/src/modulex_integrations/tools/help_scout/outputs.py new file mode 100644 index 0000000..54ca6a5 --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/outputs.py @@ -0,0 +1,127 @@ +"""Pydantic response models for the help_scout integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddNoteOutput", + "ConversationDetail", + "CreateCustomerOutput", + "GetConversationDetailsOutput", + "GetConversationThreadsOutput", + "GetTagByIdOutput", + "ListTagsOutput", + "PaginationInfo", + "SendReplyOutput", + "TagItem", + "ThreadItem", + "UpdateConversationOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class PaginationInfo(_Base): + size: int | None = None + total_elements: int | None = None + total_pages: int | None = None + number: int | None = None + + +class TagItem(_Base): + id: int | None = None + name: str | None = None + slug: str | None = None + color: str | None = None + created_at: str | None = None + updated_at: str | None = None + ticket_count: int | None = None + + +class ThreadItem(_Base): + id: int | None = None + type: str | None = None + status: str | None = None + state: str | None = None + body: str | None = None + source: dict[str, Any] | None = None + customer: dict[str, Any] | None = None + created_by: dict[str, Any] | None = None + assigned_to: dict[str, Any] | None = None + created_at: str | None = None + + +class ConversationDetail(_Base): + id: int | None = None + number: int | None = None + subject: str | None = None + status: str | None = None + mailbox_id: int | None = None + primary_customer: dict[str, Any] | None = None + threads: list[dict[str, Any]] = Field(default_factory=list) + tags: list[dict[str, Any]] = Field(default_factory=list) + created_at: str | None = None + updated_at: str | None = None + closed_at: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class AddNoteOutput(_Base): + success: bool + error: str | None = None + conversation_id: str | None = None + + +class CreateCustomerOutput(_Base): + success: bool + error: str | None = None + customer_id: str | None = None + + +class GetConversationDetailsOutput(_Base): + success: bool + error: str | None = None + conversation: ConversationDetail | None = None + + +class GetConversationThreadsOutput(_Base): + success: bool + error: str | None = None + threads: list[ThreadItem] = Field(default_factory=list) + pagination: PaginationInfo | None = None + + +class GetTagByIdOutput(_Base): + success: bool + error: str | None = None + tag: TagItem | None = None + + +class ListTagsOutput(_Base): + success: bool + error: str | None = None + tags: list[TagItem] = Field(default_factory=list) + pagination: PaginationInfo | None = None + + +class SendReplyOutput(_Base): + success: bool + error: str | None = None + conversation_id: str | None = None + + +class UpdateConversationOutput(_Base): + success: bool + error: str | None = None + conversation_id: str | None = None diff --git a/src/modulex_integrations/tools/help_scout/tests/__init__.py b/src/modulex_integrations/tools/help_scout/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/help_scout/tests/test_help_scout.py b/src/modulex_integrations/tools/help_scout/tests/test_help_scout.py new file mode 100644 index 0000000..bd6322f --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/tests/test_help_scout.py @@ -0,0 +1,277 @@ +"""Happy-path tests for every help_scout @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.help_scout import ( + TOOLS, + add_note, + create_customer, + get_conversation_details, + get_conversation_threads, + get_tag_by_id, + list_tags, + manifest, + send_reply, + update_conversation, +) +from modulex_integrations.tools.help_scout.outputs import ( + AddNoteOutput, + CreateCustomerOutput, + GetConversationDetailsOutput, + GetConversationThreadsOutput, + GetTagByIdOutput, + ListTagsOutput, + SendReplyOutput, + UpdateConversationOutput, +) + +API = "https://api.helpscout.net/v2" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_8_actions(self) -> None: + assert len(manifest.actions) == 8 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_note(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/conversations/123/notes", + status_code=201, + text="", + ) + + result_dict = await add_note.ainvoke(_args(conversation_id="123", text="A note")) + + assert isinstance(result_dict, dict) + result = AddNoteOutput.model_validate(result_dict) + assert result.success is True + assert result.conversation_id == "123" + + +@pytest.mark.asyncio +async def test_create_customer(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/customers", + status_code=201, + text="", + headers={"Resource-Id": "456"}, + ) + + result_dict = await create_customer.ainvoke(_args(first_name="Jane", last_name="Doe")) + + assert isinstance(result_dict, dict) + result = CreateCustomerOutput.model_validate(result_dict) + assert result.success is True + assert result.customer_id == "456" + + +@pytest.mark.asyncio +async def test_get_conversation_details(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/conversations/789", + json={ + "id": 789, + "number": 100, + "subject": "Test Subject", + "status": "active", + "mailboxId": 1, + "primaryCustomer": {"id": 10, "email": "test@example.com"}, + "tags": [], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-02T00:00:00Z", + # TODO: fill in a more complete response shape from upstream API docs + }, + ) + + result_dict = await get_conversation_details.ainvoke(_args(conversation_id="789")) + + assert isinstance(result_dict, dict) + result = GetConversationDetailsOutput.model_validate(result_dict) + assert result.success is True + assert result.conversation is not None + assert result.conversation.id == 789 + assert result.conversation.subject == "Test Subject" + + +@pytest.mark.asyncio +async def test_get_conversation_threads(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/conversations/789/threads?page=1", + json={ + "_embedded": { + "threads": [ + { + "id": 1, + "type": "customer", + "status": "active", + "state": "published", + "body": "Hello", + "createdAt": "2024-01-01T00:00:00Z", + }, + ], + }, + "page": { + "size": 25, + "totalElements": 1, + "totalPages": 1, + "number": 1, + }, + }, + ) + + result_dict = await get_conversation_threads.ainvoke(_args(conversation_id="789")) + + assert isinstance(result_dict, dict) + result = GetConversationThreadsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.threads) == 1 + assert result.threads[0].body == "Hello" + assert result.pagination is not None + assert result.pagination.total_elements == 1 + + +@pytest.mark.asyncio +async def test_get_tag_by_id(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/tags/42", + json={ + "id": 42, + "name": "urgent", + "slug": "urgent", + "color": "#FF0000", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": None, + "ticketCount": 5, + }, + ) + + result_dict = await get_tag_by_id.ainvoke(_args(tag_id="42")) + + assert isinstance(result_dict, dict) + result = GetTagByIdOutput.model_validate(result_dict) + assert result.success is True + assert result.tag is not None + assert result.tag.name == "urgent" + assert result.tag.ticket_count == 5 + + +@pytest.mark.asyncio +async def test_list_tags(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/tags?page=1", + json={ + "_embedded": { + "tags": [ + { + "id": 1, + "name": "bug", + "slug": "bug", + "color": "#FF0000", + "createdAt": "2024-01-01T00:00:00Z", + "ticketCount": 10, + }, + ], + }, + "page": { + "size": 50, + "totalElements": 1, + "totalPages": 1, + "number": 1, + }, + }, + ) + + result_dict = await list_tags.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListTagsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.tags) == 1 + assert result.tags[0].name == "bug" + + +@pytest.mark.asyncio +async def test_send_reply(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/conversations/123/reply", + status_code=201, + text="", + ) + + result_dict = await send_reply.ainvoke( + _args(conversation_id="123", customer_id="456", text="Reply text", draft=False) + ) + + assert isinstance(result_dict, dict) + result = SendReplyOutput.model_validate(result_dict) + assert result.success is True + assert result.conversation_id == "123" + + +@pytest.mark.asyncio +async def test_update_conversation(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/conversations/123", + status_code=204, + text="", + ) + + result_dict = await update_conversation.ainvoke( + _args(conversation_id="123", operation="Change subject", value="New Subject") + ) + + assert isinstance(result_dict, dict) + result = UpdateConversationOutput.model_validate(result_dict) + assert result.success is True + assert result.conversation_id == "123" + + +# --- Failure-path test ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_note_missing_credential(): # type: ignore[no-untyped-def] + """Empty credential returns error without hitting the wire.""" + result_dict = await add_note.ainvoke( + _args(auth_data={}, conversation_id="123", text="A note") + ) + + assert isinstance(result_dict, dict) + result = AddNoteOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access token" in result.error.lower() diff --git a/src/modulex_integrations/tools/help_scout/tools.py b/src/modulex_integrations/tools/help_scout/tools.py new file mode 100644 index 0000000..46afc22 --- /dev/null +++ b/src/modulex_integrations/tools/help_scout/tools.py @@ -0,0 +1,569 @@ +"""Help Scout LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.help_scout.outputs import ( + AddNoteOutput, + ConversationDetail, + CreateCustomerOutput, + GetConversationDetailsOutput, + GetConversationThreadsOutput, + GetTagByIdOutput, + ListTagsOutput, + PaginationInfo, + SendReplyOutput, + TagItem, + ThreadItem, + UpdateConversationOutput, +) + +__all__ = [ + "add_note", + "create_customer", + "get_conversation_details", + "get_conversation_threads", + "get_tag_by_id", + "list_tags", + "send_reply", + "update_conversation", +] + +_BASE_URL = "https://api.helpscout.net/v2" +_TIMEOUT = 30.0 + +_CONVERSATION_OPERATIONS: dict[str, dict[str, str]] = { + "Change subject": {"op": "replace", "path": "/subject"}, + "Change customer": {"op": "replace", "path": "/primaryCustomer.id"}, + "Publish draft": {"op": "replace", "path": "/draft"}, + "Move conversation to another inbox": {"op": "move", "path": "/mailboxId"}, + "Change conversation status": {"op": "replace", "path": "/status"}, + "Change conversation owner": {"op": "replace", "path": "/assignTo"}, + "Un-assign conversation": {"op": "remove", "path": "/assignTo"}, +} + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + headers: dict[str, str] = {"Content-Type": "application/json"} + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class AddNoteInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + conversation_id: str = Field(description="The unique identifier of the conversation") + text: str = Field(description="The content of the note") + user_id: str | None = Field(default=None, description="The unique identifier of the user creating the note") + + +class CreateCustomerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + first_name: str | None = Field(default=None, description="First name of the customer (1-40 characters)") + last_name: str | None = Field(default=None, description="Last name of the customer (1-40 characters)") + phone: str | None = Field(default=None, description="Phone number for the new customer") + photo_url: str | None = Field(default=None, description="URL of the customer's photo (max 200 characters)") + job_title: str | None = Field(default=None, description="Job title (max 60 characters)") + photo_type: str | None = Field(default=None, description="Type of photo: unknown, gravatar, twitter, facebook, googleprofile, googleplus, linkedin, instagram") + background: str | None = Field(default=None, description="Notes field content (max 200 characters)") + location: str | None = Field(default=None, description="Location of the customer (max 60 characters)") + organization: str | None = Field(default=None, description="Organization name (max 60 characters)") + gender: str | None = Field(default=None, description="Gender: male, female, unknown") + age: str | None = Field(default=None, description="Customer's age") + emails: list[dict[str, str]] | None = Field(default=None, description="List of email entries with 'type' and 'value' fields") + phones: list[dict[str, str]] | None = Field(default=None, description="List of phone entries with 'type' and 'value' fields") + chats: list[dict[str, str]] | None = Field(default=None, description="List of chat entries with 'type' and 'value' fields") + social_profiles: list[dict[str, str]] | None = Field(default=None, description="List of social profile entries with 'type' and 'value' fields") + websites: list[dict[str, str]] | None = Field(default=None, description="List of website entries with 'value' field") + address_city: str | None = Field(default=None, description="City of the customer's address") + address_state: str | None = Field(default=None, description="State of the customer's address") + address_postal_code: str | None = Field(default=None, description="Postal code of the customer's address") + address_country: str | None = Field(default=None, description="ISO 3166 Alpha-2 country code") + address_lines: list[str] | None = Field(default=None, description="List of address line strings") + properties: list[dict[str, Any]] | None = Field(default=None, description="List of property entries as JSON objects") + + +class GetConversationDetailsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + conversation_id: str = Field(description="The unique identifier of the conversation") + embed: bool | None = Field(default=None, description="If true, include threads in the response") + + +class GetConversationThreadsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + conversation_id: str = Field(description="The unique identifier of the conversation") + page: int = Field(default=1, description="Page number to retrieve (25 threads per page)") + + +class GetTagByIdInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + tag_id: str = Field(description="The unique identifier of the tag") + + +class ListTagsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + page: int = Field(default=1, description="The page number to return") + + +class SendReplyInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + conversation_id: str = Field(description="The unique identifier of the conversation") + customer_id: str = Field(description="The unique identifier of the customer") + text: str = Field(description="The content of the reply") + draft: bool = Field(default=False, description="If true, a draft reply is created instead of sending") + + +class UpdateConversationInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + conversation_id: str = Field(description="The unique identifier of the conversation") + operation: str = Field(description="Operation to perform: Change subject, Change customer, Publish draft, Move conversation to another inbox, Change conversation status, Change conversation owner, Un-assign conversation") + value: str = Field(description="Value for the operation") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=AddNoteInput) +@serialize_pydantic_return +async def add_note( + auth_type: str, + auth_data: dict[str, Any], + conversation_id: str, + text: str, + user_id: str | None = None, +) -> AddNoteOutput: + """Adds a note to an existing conversation in Help Scout""" + if not auth_data.get("access_token"): + return AddNoteOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + body: dict[str, Any] = {"text": text} + if user_id: + body["user"] = user_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/conversations/{conversation_id}/notes", + headers=headers, + json=body, + ) + if response.status_code not in (200, 201): + return AddNoteOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return AddNoteOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddNoteOutput(success=False, error=f"Call failed: {exc}") + return AddNoteOutput(success=True, conversation_id=conversation_id) + + +@tool(args_schema=CreateCustomerInput) +@serialize_pydantic_return +async def create_customer( + auth_type: str, + auth_data: dict[str, Any], + first_name: str | None = None, + last_name: str | None = None, + phone: str | None = None, + photo_url: str | None = None, + job_title: str | None = None, + photo_type: str | None = None, + background: str | None = None, + location: str | None = None, + organization: str | None = None, + gender: str | None = None, + age: str | None = None, + emails: list[dict[str, str]] | None = None, + phones: list[dict[str, str]] | None = None, + chats: list[dict[str, str]] | None = None, + social_profiles: list[dict[str, str]] | None = None, + websites: list[dict[str, str]] | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_postal_code: str | None = None, + address_country: str | None = None, + address_lines: list[str] | None = None, + properties: list[dict[str, Any]] | None = None, +) -> CreateCustomerOutput: + """Creates a new customer record in Help Scout""" + if not auth_data.get("access_token"): + return CreateCustomerOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + body: dict[str, Any] = {} + if first_name is not None: + body["firstName"] = first_name + if last_name is not None: + body["lastName"] = last_name + if phone is not None: + body["phone"] = phone + if photo_url is not None: + body["photoUrl"] = photo_url + if job_title is not None: + body["jobTitle"] = job_title + if photo_type is not None: + body["photoType"] = photo_type + if background is not None: + body["background"] = background + if location is not None: + body["location"] = location + if organization is not None: + body["organization"] = organization + if gender is not None: + body["gender"] = gender + if age is not None: + body["age"] = age + if emails is not None: + body["emails"] = emails + if phones is not None: + body["phones"] = phones + if chats is not None: + body["chats"] = chats + if social_profiles is not None: + body["socialProfiles"] = social_profiles + if websites is not None: + body["websites"] = websites + if properties is not None: + body["properties"] = properties + address: dict[str, Any] = {} + if address_city is not None: + address["city"] = address_city + if address_state is not None: + address["state"] = address_state + if address_postal_code is not None: + address["postalCode"] = address_postal_code + if address_country is not None: + address["country"] = address_country + if address_lines is not None: + address["lines"] = address_lines + if address: + body["address"] = address + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/customers", + headers=headers, + json=body, + ) + if response.status_code not in (200, 201): + return CreateCustomerOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return CreateCustomerOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateCustomerOutput(success=False, error=f"Call failed: {exc}") + resource_id = response.headers.get("Resource-Id") + return CreateCustomerOutput(success=True, customer_id=resource_id) + + +@tool(args_schema=GetConversationDetailsInput) +@serialize_pydantic_return +async def get_conversation_details( + auth_type: str, + auth_data: dict[str, Any], + conversation_id: str, + embed: bool | None = None, +) -> GetConversationDetailsOutput: + """Retrieves the details of a specific conversation""" + if not auth_data.get("access_token"): + return GetConversationDetailsOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, str] = {} + if embed: + params["embed"] = "threads" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/conversations/{conversation_id}", + headers=headers, + params=params, + ) + if response.status_code != 200: + return GetConversationDetailsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetConversationDetailsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetConversationDetailsOutput(success=False, error=f"Call failed: {exc}") + embedded = data.get("_embedded", {}) + return GetConversationDetailsOutput( + success=True, + conversation=ConversationDetail( + id=data.get("id"), + number=data.get("number"), + subject=data.get("subject"), + status=data.get("status"), + mailbox_id=data.get("mailboxId"), + primary_customer=data.get("primaryCustomer"), + threads=embedded.get("threads", []), + tags=data.get("tags", []), + created_at=data.get("createdAt"), + updated_at=data.get("updatedAt"), + closed_at=data.get("closedAt"), + ), + ) + + +@tool(args_schema=GetConversationThreadsInput) +@serialize_pydantic_return +async def get_conversation_threads( + auth_type: str, + auth_data: dict[str, Any], + conversation_id: str, + page: int = 1, +) -> GetConversationThreadsOutput: + """Retrieves the threads of a specific conversation""" + if not auth_data.get("access_token"): + return GetConversationThreadsOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, Any] = {"page": page} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/conversations/{conversation_id}/threads", + headers=headers, + params=params, + ) + if response.status_code != 200: + return GetConversationThreadsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetConversationThreadsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetConversationThreadsOutput(success=False, error=f"Call failed: {exc}") + embedded = data.get("_embedded", {}) + raw_threads = embedded.get("threads", []) + threads = [ + ThreadItem( + id=t.get("id"), + type=t.get("type"), + status=t.get("status"), + state=t.get("state"), + body=t.get("body"), + source=t.get("source"), + customer=t.get("customer"), + created_by=t.get("createdBy"), + assigned_to=t.get("assignedTo"), + created_at=t.get("createdAt"), + ) + for t in raw_threads + ] + page_info = data.get("page") + pagination = None + if page_info: + pagination = PaginationInfo( + size=page_info.get("size"), + total_elements=page_info.get("totalElements"), + total_pages=page_info.get("totalPages"), + number=page_info.get("number"), + ) + return GetConversationThreadsOutput( + success=True, + threads=threads, + pagination=pagination, + ) + + +@tool(args_schema=GetTagByIdInput) +@serialize_pydantic_return +async def get_tag_by_id( + auth_type: str, + auth_data: dict[str, Any], + tag_id: str, +) -> GetTagByIdOutput: + """Gets a tag by its ID""" + if not auth_data.get("access_token"): + return GetTagByIdOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/tags/{tag_id}", + headers=headers, + ) + if response.status_code != 200: + return GetTagByIdOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetTagByIdOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetTagByIdOutput(success=False, error=f"Call failed: {exc}") + return GetTagByIdOutput( + success=True, + tag=TagItem( + id=data.get("id"), + name=data.get("name"), + slug=data.get("slug"), + color=data.get("color"), + created_at=data.get("createdAt"), + updated_at=data.get("updatedAt"), + ticket_count=data.get("ticketCount"), + ), + ) + + +@tool(args_schema=ListTagsInput) +@serialize_pydantic_return +async def list_tags( + auth_type: str, + auth_data: dict[str, Any], + page: int = 1, +) -> ListTagsOutput: + """Lists all tags in Help Scout""" + if not auth_data.get("access_token"): + return ListTagsOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, Any] = {"page": page} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/tags", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListTagsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListTagsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListTagsOutput(success=False, error=f"Call failed: {exc}") + embedded = data.get("_embedded", {}) + raw_tags = embedded.get("tags", []) + tags = [ + TagItem( + id=t.get("id"), + name=t.get("name"), + slug=t.get("slug"), + color=t.get("color"), + created_at=t.get("createdAt"), + updated_at=t.get("updatedAt"), + ticket_count=t.get("ticketCount"), + ) + for t in raw_tags + ] + page_info = data.get("page") + pagination = None + if page_info: + pagination = PaginationInfo( + size=page_info.get("size"), + total_elements=page_info.get("totalElements"), + total_pages=page_info.get("totalPages"), + number=page_info.get("number"), + ) + return ListTagsOutput(success=True, tags=tags, pagination=pagination) + + +@tool(args_schema=SendReplyInput) +@serialize_pydantic_return +async def send_reply( + auth_type: str, + auth_data: dict[str, Any], + conversation_id: str, + customer_id: str, + text: str, + draft: bool = False, +) -> SendReplyOutput: + """Sends a reply to a conversation (sends an actual email to the customer)""" + if not auth_data.get("access_token"): + return SendReplyOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + body: dict[str, Any] = { + "customer": {"id": customer_id}, + "text": text, + "draft": draft, + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/conversations/{conversation_id}/reply", + headers=headers, + json=body, + ) + if response.status_code not in (200, 201): + return SendReplyOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return SendReplyOutput(success=False, error="Request timed out.") + except Exception as exc: + return SendReplyOutput(success=False, error=f"Call failed: {exc}") + return SendReplyOutput(success=True, conversation_id=conversation_id) + + +@tool(args_schema=UpdateConversationInput) +@serialize_pydantic_return +async def update_conversation( + auth_type: str, + auth_data: dict[str, Any], + conversation_id: str, + operation: str, + value: str, +) -> UpdateConversationOutput: + """Updates a conversation using a specified operation""" + if not auth_data.get("access_token"): + return UpdateConversationOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + op_config = _CONVERSATION_OPERATIONS.get(operation) + if not op_config: + return UpdateConversationOutput( + success=False, + error=f"Unknown operation: {operation}. Valid: {', '.join(_CONVERSATION_OPERATIONS.keys())}", + ) + patch_body: dict[str, Any] = { + "op": op_config["op"], + "path": op_config["path"], + "value": value, + } + if operation == "Un-assign conversation": + patch_body.pop("value", None) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/conversations/{conversation_id}", + headers=headers, + json=patch_body, + ) + if response.status_code not in (200, 204): + return UpdateConversationOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UpdateConversationOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateConversationOutput(success=False, error=f"Call failed: {exc}") + return UpdateConversationOutput(success=True, conversation_id=conversation_id) diff --git a/src/modulex_integrations/tools/insightly/README.md b/src/modulex_integrations/tools/insightly/README.md new file mode 100644 index 0000000..697d303 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/README.md @@ -0,0 +1,32 @@ +# Insightly + +CRM and project management platform for managing contacts, tasks, and sales pipelines via the Insightly REST API (`api.{pod}.insightly.com/v3.1`). + +## Authentication + +### Insightly API Key + +- Log in to your Insightly account and navigate to **User Settings > API** to find your API key. +- Required env vars: + - `INSIGHTLY_POD` — your Insightly pod/region identifier (e.g. `na1`, `au1`) found in your Insightly URL. + - `INSIGHTLY_API_KEY` — your Insightly API key (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). +- Authentication uses HTTP Basic Auth with the API key as the username and an empty password. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_contact` | Creates a new contact in Insightly | `first_name`, `last_name`, `email` | +| `create_task` | Creates a new task in Insightly | `title`, `status`, `due_date` | + +Every tool takes additional `pod` and `api_key` parameters that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Rate limits**: Insightly enforces per-plan rate limits (typically 10 requests/second for Professional plans, higher for Enterprise). +- **Pricing**: API access requires a paid Insightly plan (Professional or higher). +- **Error model**: non-2xx responses are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/insightly/__init__.py b/src/modulex_integrations/tools/insightly/__init__.py new file mode 100644 index 0000000..9df4cd1 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/__init__.py @@ -0,0 +1,18 @@ +"""Insightly integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.insightly.manifest import manifest +from modulex_integrations.tools.insightly.tools import ( + create_contact, + create_task, +) + +TOOLS = ( + create_contact, + create_task, +) + +__all__ = [ + "TOOLS", + "create_contact", + "create_task", + "manifest", +] diff --git a/src/modulex_integrations/tools/insightly/dependencies.toml b/src/modulex_integrations/tools/insightly/dependencies.toml new file mode 100644 index 0000000..b4cea7d --- /dev/null +++ b/src/modulex_integrations/tools/insightly/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the insightly integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/insightly/manifest.py b/src/modulex_integrations/tools/insightly/manifest.py new file mode 100644 index 0000000..b609079 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/manifest.py @@ -0,0 +1,145 @@ +"""Insightly integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="insightly", + display_name="Insightly", + description="CRM and project management platform for managing contacts, tasks, and sales pipelines", + version="1.0.0", + author="ModuleX", + logo="modulex:insightly-themed", + app_url="https://www.insightly.com", + categories=["CRM", "Sales", "Productivity & Collaboration"], + actions=[ + ActionDefinition( + name="create_contact", + description="Creates a new contact in Insightly", + parameters={ + "first_name": ParameterDef( + type="string", + description="The first name of the contact", + required=True, + ), + "last_name": ParameterDef( + type="string", + description="The last name of the contact", + required=True, + ), + "email": ParameterDef( + type="string", + description="The email address of the contact", + required=True, + ), + "title": ParameterDef( + type="string", + description="The title of the contact", + ), + "phone": ParameterDef( + type="string", + description="The phone number of the contact", + ), + "address_street": ParameterDef( + type="string", + description="The street address of the contact", + ), + "address_city": ParameterDef( + type="string", + description="The city of the contact", + ), + "address_state": ParameterDef( + type="string", + description="The state of the contact", + ), + "address_postcode": ParameterDef( + type="string", + description="The zip code/postcode of the contact", + ), + "address_country": ParameterDef( + type="string", + description="The country of the contact", + ), + }, + ), + ActionDefinition( + name="create_task", + description="Creates a new task in Insightly", + parameters={ + "title": ParameterDef( + type="string", + description="The title of the task", + required=True, + ), + "status": ParameterDef( + type="string", + description="The status of the task. Allowed values: Not Started, In Progress, Completed, Deferred, Waiting", + required=True, + ), + "due_date": ParameterDef( + type="string", + description="The due date of the task in YYYY-MM-DD format (e.g. 2023-08-20)", + required=True, + ), + "category_id": ParameterDef( + type="string", + description="Identifier of a task category", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="Insightly API Key", + description="Authenticate using your Insightly API key and pod identifier", + setup_instructions=[ + "Log in to your Insightly account", + "Go to User Settings > API", + "Copy your API key", + "Find your pod identifier from your Insightly URL (e.g. na1, au1)", + ], + setup_environment_variables=[ + EnvVar( + name="INSIGHTLY_POD", + display_name="Pod", + description="Your Insightly pod/region identifier (e.g. na1, au1) found in your Insightly URL", + required=True, + sensitive=False, + sample_format="na1", + about_url="https://support.insightly.com", + ), + EnvVar( + name="INSIGHTLY_API_KEY", + display_name="API Key", + description="Your Insightly API key from User Settings > API", + required=True, + sensitive=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://support.insightly.com", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.na1.insightly.com/v3.1/Users/Me", + method="GET", + headers={"Authorization": "Basic {api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["USER_ID"], + ), + cost_level="free", + description="Validates credentials by fetching the current user profile", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/insightly/outputs.py b/src/modulex_integrations/tools/insightly/outputs.py new file mode 100644 index 0000000..597ac02 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/outputs.py @@ -0,0 +1,36 @@ +"""Pydantic response models for the insightly integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = [ + "CreateContactOutput", + "CreateTaskOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateContactOutput(_Base): + success: bool + error: str | None = None + contact_id: int | None = None + first_name: str | None = None + last_name: str | None = None + email_address: str | None = None + title: str | None = None + phone: str | None = None + + +class CreateTaskOutput(_Base): + success: bool + error: str | None = None + task_id: int | None = None + title: str | None = None + status: str | None = None + due_date: str | None = None + category_id: int | None = None diff --git a/src/modulex_integrations/tools/insightly/tests/__init__.py b/src/modulex_integrations/tools/insightly/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/insightly/tests/test_insightly.py b/src/modulex_integrations/tools/insightly/tests/test_insightly.py new file mode 100644 index 0000000..40b15df --- /dev/null +++ b/src/modulex_integrations/tools/insightly/tests/test_insightly.py @@ -0,0 +1,122 @@ +"""Happy-path tests for every insightly @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.insightly import ( + TOOLS, + create_contact, + create_task, + manifest, +) +from modulex_integrations.tools.insightly.outputs import ( + CreateContactOutput, + CreateTaskOutput, +) + +API = "https://api.na1.insightly.com/v3.1" + +_POD = "na1" +_API_KEY = "fake-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(pod=_POD, api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_2_actions(self) -> None: + assert len(manifest.actions) == 2 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_contact(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/Contacts", + json={ + # TODO: fill in a representative response shape from the Insightly API docs + "CONTACT_ID": 12345, + "FIRST_NAME": "John", + "LAST_NAME": "Doe", + "TITLE": None, + }, + ) + + result_dict = await create_contact.ainvoke( + _args( + first_name="John", + last_name="Doe", + email="john@example.com", + ) + ) + + assert isinstance(result_dict, dict) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is True + assert result.contact_id == 12345 + assert result.first_name == "John" + + +@pytest.mark.asyncio +async def test_create_task(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/Tasks", + json={ + # TODO: fill in a representative response shape from the Insightly API docs + "TASK_ID": 67890, + "TITLE": "Follow up", + "STATUS": "Not Started", + "DUE_DATE": "2024-01-15", + "CATEGORY_ID": None, + }, + ) + + result_dict = await create_task.ainvoke( + _args( + title="Follow up", + status="Not Started", + due_date="2024-01-15", + ) + ) + + assert isinstance(result_dict, dict) + result = CreateTaskOutput.model_validate(result_dict) + assert result.success is True + assert result.task_id == 67890 + assert result.title == "Follow up" + + +@pytest.mark.asyncio +async def test_create_contact_validates_empty_api_key() -> None: + result_dict = await create_contact.ainvoke( + {"first_name": "X", "last_name": "Y", "email": "x@y.com", "pod": "na1", "api_key": ""} + ) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") + + +@pytest.mark.asyncio +async def test_create_task_validates_empty_api_key() -> None: + result_dict = await create_task.ainvoke( + {"title": "X", "status": "Not Started", "due_date": "2024-01-01", "pod": "na1", "api_key": ""} + ) + result = CreateTaskOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/insightly/tools.py b/src/modulex_integrations/tools/insightly/tools.py new file mode 100644 index 0000000..710f921 --- /dev/null +++ b/src/modulex_integrations/tools/insightly/tools.py @@ -0,0 +1,203 @@ +"""Insightly LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.insightly.outputs import ( + CreateContactOutput, + CreateTaskOutput, +) + +__all__ = [ + "create_contact", + "create_task", +] + + +def _base_url(pod: str) -> str: + return f"https://api.{pod}.insightly.com/v3.1" + + +# --- Input schemas -------------------------------------------------------- + + +class CreateContactInput(BaseModel): + first_name: str = Field(description="The first name of the contact") + last_name: str = Field(description="The last name of the contact") + email: str = Field(description="The email address of the contact") + pod: str = Field(description="Insightly pod/region identifier (e.g. na1, au1)") + api_key: str = Field(description="Insightly API key") + title: str | None = Field(default=None, description="The title of the contact") + phone: str | None = Field(default=None, description="The phone number of the contact") + address_street: str | None = Field(default=None, description="The street address of the contact") + address_city: str | None = Field(default=None, description="The city of the contact") + address_state: str | None = Field(default=None, description="The state of the contact") + address_postcode: str | None = Field(default=None, description="The zip code/postcode of the contact") + address_country: str | None = Field(default=None, description="The country of the contact") + + +class CreateTaskInput(BaseModel): + title: str = Field(description="The title of the task") + status: str = Field(description="The status of the task. Allowed values: Not Started, In Progress, Completed, Deferred, Waiting") + due_date: str = Field(description="The due date of the task in YYYY-MM-DD format (e.g. 2023-08-20)") + pod: str = Field(description="Insightly pod/region identifier (e.g. na1, au1)") + api_key: str = Field(description="Insightly API key") + category_id: str | None = Field(default=None, description="Identifier of a task category") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateContactInput) +@serialize_pydantic_return +async def create_contact( + first_name: str, + last_name: str, + email: str, + pod: str, + api_key: str, + title: str | None = None, + phone: str | None = None, + address_street: str | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_postcode: str | None = None, + address_country: str | None = None, +) -> CreateContactOutput: + """Creates a new contact in Insightly""" + if not api_key or not api_key.strip(): + return CreateContactOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not pod or not pod.strip(): + return CreateContactOutput( + success=False, + error="Pod identifier is empty. Please configure your Insightly pod.", + ) + + body: dict[str, Any] = { + "FIRST_NAME": first_name, + "LAST_NAME": last_name, + "CONTACTINFOS": [ + { + "TYPE": "EMAIL", + "LABEL": "Work", + "DETAIL": email, + }, + ], + } + if title: + body["TITLE"] = title + if phone: + body["CONTACTINFOS"].append( + { + "TYPE": "PHONE", + "LABEL": "Work", + "DETAIL": phone, + }, + ) + if any([address_street, address_city, address_state, address_postcode, address_country]): + body["ADDRESSES"] = [ + { + "ADDRESS_TYPE": "Work", + "STREET": address_street or "", + "CITY": address_city or "", + "STATE": address_state or "", + "POSTCODE": address_postcode or "", + "COUNTRY": address_country or "", + }, + ] + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_base_url(pod)}/Contacts", + auth=(api_key, ""), + headers={"Content-Type": "application/json"}, + json=body, + ) + if response.status_code not in (200, 201): + return CreateContactOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateContactOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateContactOutput(success=False, error=f"Call failed: {exc}") + + return CreateContactOutput( + success=True, + contact_id=data.get("CONTACT_ID"), + first_name=data.get("FIRST_NAME"), + last_name=data.get("LAST_NAME"), + email_address=email, + title=data.get("TITLE"), + phone=phone, + ) + + +@tool(args_schema=CreateTaskInput) +@serialize_pydantic_return +async def create_task( + title: str, + status: str, + due_date: str, + pod: str, + api_key: str, + category_id: str | None = None, +) -> CreateTaskOutput: + """Creates a new task in Insightly""" + if not api_key or not api_key.strip(): + return CreateTaskOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not pod or not pod.strip(): + return CreateTaskOutput( + success=False, + error="Pod identifier is empty. Please configure your Insightly pod.", + ) + + body: dict[str, Any] = { + "TITLE": title, + "STATUS": status, + "DUE_DATE": due_date, + } + if category_id: + body["CATEGORY_ID"] = int(category_id) + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_base_url(pod)}/Tasks", + auth=(api_key, ""), + headers={"Content-Type": "application/json"}, + json=body, + ) + if response.status_code not in (200, 201): + return CreateTaskOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateTaskOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateTaskOutput(success=False, error=f"Call failed: {exc}") + + return CreateTaskOutput( + success=True, + task_id=data.get("TASK_ID"), + title=data.get("TITLE"), + status=data.get("STATUS"), + due_date=data.get("DUE_DATE"), + category_id=data.get("CATEGORY_ID"), + ) diff --git a/src/modulex_integrations/tools/luma/README.md b/src/modulex_integrations/tools/luma/README.md new file mode 100644 index 0000000..6f3e3c3 --- /dev/null +++ b/src/modulex_integrations/tools/luma/README.md @@ -0,0 +1,36 @@ +# Luma + +Event management platform for creating, managing, and tracking events and guests via the Luma REST API (`public-api.luma.com/v1`). + +## Authentication + +### API Key Authentication + +- Sign in at [lu.ma](https://lu.ma) and navigate to your calendar settings or developer section. +- Generate or copy your API key. +- Required env var: `LUMA_API_KEY` (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). +- The key is sent via the `x-luma-api-key` header on every request. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_event` | Create an event on the connected Luma calendar | `name`, `start_at`, `timezone` | +| `get_event` | Get admin details for a Luma event by event ID | `event_id` | +| `list_events` | List events managed by the connected Luma calendar | | +| `get_guest` | Get detailed information for a Luma event guest by ID or email | `event_id`, `guest_id` | +| `get_guests` | List guests registered for, invited to, or waitlisted for a Luma event | `event_id` | +| `add_guests` | Add guests to a Luma event with status Going | `event_id`, `guests_json` | +| `list_ticket_types` | List ticket types for a Luma event | `event_id` | +| `send_invites` | Send email invitations for a Luma event | `event_id`, `guests_json` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- No documented public rate limits; Luma enforces server-side pagination maximums. +- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/luma/__init__.py b/src/modulex_integrations/tools/luma/__init__.py new file mode 100644 index 0000000..7e9a59c --- /dev/null +++ b/src/modulex_integrations/tools/luma/__init__.py @@ -0,0 +1,36 @@ +"""Luma integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.luma.manifest import manifest +from modulex_integrations.tools.luma.tools import ( + add_guests, + create_event, + get_event, + get_guest, + get_guests, + list_events, + list_ticket_types, + send_invites, +) + +TOOLS = ( + create_event, + get_event, + list_events, + get_guest, + get_guests, + add_guests, + list_ticket_types, + send_invites, +) + +__all__ = [ + "TOOLS", + "add_guests", + "create_event", + "get_event", + "get_guest", + "get_guests", + "list_events", + "list_ticket_types", + "manifest", + "send_invites", +] diff --git a/src/modulex_integrations/tools/luma/dependencies.toml b/src/modulex_integrations/tools/luma/dependencies.toml new file mode 100644 index 0000000..00e91dd --- /dev/null +++ b/src/modulex_integrations/tools/luma/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the luma integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/luma/manifest.py b/src/modulex_integrations/tools/luma/manifest.py new file mode 100644 index 0000000..c918555 --- /dev/null +++ b/src/modulex_integrations/tools/luma/manifest.py @@ -0,0 +1,306 @@ +"""Luma integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="luma", + display_name="Luma", + description="Event management platform for creating, managing, and tracking events and guests", + version="1.0.0", + author="ModuleX", + logo="modulex:luma-themed", + app_url="https://lu.ma", + categories=["Events", "Productivity & Collaboration", "Marketing"], + actions=[ + ActionDefinition( + name="create_event", + description="Create an event on the connected Luma calendar", + parameters={ + "name": ParameterDef( + type="string", + description="The event name", + required=True, + ), + "start_at": ParameterDef( + type="string", + description="The event start time as an ISO 8601 datetime, for example 2026-05-15T18:00:00Z", + required=True, + ), + "timezone": ParameterDef( + type="string", + description="The IANA timezone for the event, for example America/New_York", + required=True, + ), + "end_at": ParameterDef( + type="string", + description="The event end time as an ISO 8601 datetime", + ), + "description_md": ParameterDef( + type="string", + description="Markdown description for the event", + ), + "visibility": ParameterDef( + type="string", + description="Event visibility: public, members-only, or private", + ), + "slug": ParameterDef( + type="string", + description="Custom event URL slug", + ), + "meeting_url": ParameterDef( + type="string", + description="Online meeting URL for a virtual event", + ), + "cover_url": ParameterDef( + type="string", + description="Cover image URL uploaded to the Luma CDN", + ), + "max_capacity": ParameterDef( + type="integer", + description="Maximum number of registrations before the event is sold out", + ), + "can_register_for_multiple_tickets": ParameterDef( + type="boolean", + description="Whether guests can register for multiple tickets", + ), + "show_guest_list": ParameterDef( + type="boolean", + description="Whether approved guests can see who else is attending", + ), + "reminders_disabled": ParameterDef( + type="boolean", + description="Whether to disable default event reminders", + ), + "name_requirement": ParameterDef( + type="string", + description="How to collect guest names: full-name or first-last", + ), + "phone_number_requirement": ParameterDef( + type="string", + description="Phone number collection: optional or required", + ), + "tint_color": ParameterDef( + type="string", + description="A hex color like #bb2dc7 for the event theme", + ), + "coordinate_json": ParameterDef( + type="string", + description="JSON object with latitude and longitude for the event location", + ), + "geo_address_json": ParameterDef( + type="string", + description="JSON object with address details (type, place_id, description)", + ), + "registration_questions_json": ParameterDef( + type="string", + description="JSON array of registration question objects with label and required fields", + ), + "feedback_email_json": ParameterDef( + type="string", + description="JSON object with subject and message for the post-event feedback email", + ), + }, + ), + ActionDefinition( + name="get_event", + description="Get admin details for a Luma event by event ID", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + }, + ), + ActionDefinition( + name="list_events", + description="List events managed by the connected Luma calendar", + parameters={ + "after": ParameterDef( + type="string", + description="Return events starting after this ISO 8601 datetime", + ), + "before": ParameterDef( + type="string", + description="Return events starting before this ISO 8601 datetime", + ), + "pagination_cursor": ParameterDef( + type="string", + description="The next_cursor value from a previous list response", + ), + "pagination_limit": ParameterDef( + type="integer", + description="Number of items to request per page", + default=50, + ), + "status": ParameterDef( + type="string", + description="Calendar submission status: approved or pending", + ), + "sort_column": ParameterDef( + type="string", + description="Column to sort by (currently only start_at is supported)", + ), + "sort_direction": ParameterDef( + type="string", + description="Sort order: asc, desc, asc nulls last, or desc nulls last", + ), + }, + ), + ActionDefinition( + name="get_guest", + description="Get detailed information for a Luma event guest by ID or email", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + "guest_id": ParameterDef( + type="string", + description="Guest ID (gst-...), ticket key, guest key (g-...), or email address", + required=True, + ), + }, + ), + ActionDefinition( + name="get_guests", + description="List guests registered for, invited to, or waitlisted for a Luma event", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + "approval_status": ParameterDef( + type="string", + description="Filter by status: approved, session, pending_approval, invited, declined, or waitlist", + ), + "pagination_cursor": ParameterDef( + type="string", + description="The next_cursor value from a previous list response", + ), + "pagination_limit": ParameterDef( + type="integer", + description="Number of items to request per page", + default=50, + ), + "sort_column": ParameterDef( + type="string", + description="Guest field to sort by: name, email, created_at, registered_at, or checked_in_at", + ), + "sort_direction": ParameterDef( + type="string", + description="Sort order: asc, desc, asc nulls last, or desc nulls last", + ), + }, + ), + ActionDefinition( + name="add_guests", + description="Add guests to a Luma event with status Going", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + "guests_json": ParameterDef( + type="string", + description="JSON array of guests, each with at least an email field", + required=True, + ), + "ticket_json": ParameterDef( + type="string", + description="JSON object assigning one ticket type to each guest (mutually exclusive with tickets_json)", + ), + "tickets_json": ParameterDef( + type="string", + description="JSON array assigning multiple tickets to each guest (mutually exclusive with ticket_json)", + ), + }, + ), + ActionDefinition( + name="list_ticket_types", + description="List ticket types for a Luma event", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + "include_hidden": ParameterDef( + type="boolean", + description="Whether to include hidden ticket types", + default=False, + ), + }, + ), + ActionDefinition( + name="send_invites", + description="Send email invitations for a Luma event", + parameters={ + "event_id": ParameterDef( + type="string", + description="The Luma event ID (usually starts with evt-)", + required=True, + ), + "guests_json": ParameterDef( + type="string", + description="JSON array of guests to invite, each with at least an email field", + required=True, + ), + "message": ParameterDef( + type="string", + description="Optional invite message (max 200 characters)", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Luma API key", + setup_instructions=[ + "Go to https://lu.ma and sign in to your account", + "Navigate to your calendar settings or developer section", + "Generate or copy your API key", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="LUMA_API_KEY", + display_name="Luma API Key", + description="Your Luma API key", + required=True, + sensitive=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://docs.luma.com", + ), + ], + test_endpoint=TestEndpoint( + url="https://public-api.luma.com/v1/calendar/list-events", + method="GET", + headers={"x-luma-api-key": "{api_key}"}, + params={"pagination_limit": "1"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["entries"], + ), + cost_level="free", + description="Validates the API key by listing calendar events", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/luma/outputs.py b/src/modulex_integrations/tools/luma/outputs.py new file mode 100644 index 0000000..5fab813 --- /dev/null +++ b/src/modulex_integrations/tools/luma/outputs.py @@ -0,0 +1,74 @@ +"""Pydantic response models for the luma integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddGuestsOutput", + "CreateEventOutput", + "GetEventOutput", + "GetGuestOutput", + "GetGuestsOutput", + "ListEventsOutput", + "ListTicketTypesOutput", + "SendInvitesOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CreateEventOutput(_Base): + success: bool + error: str | None = None + event: dict[str, Any] | None = None + + +class GetEventOutput(_Base): + success: bool + error: str | None = None + event: dict[str, Any] | None = None + + +class ListEventsOutput(_Base): + success: bool + error: str | None = None + events: list[dict[str, Any]] = Field(default_factory=list) + has_more: bool | None = None + next_cursor: str | None = None + + +class GetGuestOutput(_Base): + success: bool + error: str | None = None + guest: dict[str, Any] | None = None + + +class GetGuestsOutput(_Base): + success: bool + error: str | None = None + guests: list[dict[str, Any]] = Field(default_factory=list) + has_more: bool | None = None + next_cursor: str | None = None + + +class AddGuestsOutput(_Base): + success: bool + error: str | None = None + guests: list[dict[str, Any]] = Field(default_factory=list) + + +class ListTicketTypesOutput(_Base): + success: bool + error: str | None = None + ticket_types: list[dict[str, Any]] = Field(default_factory=list) + + +class SendInvitesOutput(_Base): + success: bool + error: str | None = None diff --git a/src/modulex_integrations/tools/luma/tests/__init__.py b/src/modulex_integrations/tools/luma/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/luma/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/luma/tests/test_luma.py b/src/modulex_integrations/tools/luma/tests/test_luma.py new file mode 100644 index 0000000..f136413 --- /dev/null +++ b/src/modulex_integrations/tools/luma/tests/test_luma.py @@ -0,0 +1,232 @@ +"""Happy-path tests for every luma @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.luma import ( + TOOLS, + add_guests, + create_event, + get_event, + get_guest, + get_guests, + list_events, + list_ticket_types, + manifest, + send_invites, +) +from modulex_integrations.tools.luma.outputs import ( + AddGuestsOutput, + CreateEventOutput, + GetEventOutput, + GetGuestOutput, + GetGuestsOutput, + ListEventsOutput, + ListTicketTypesOutput, + SendInvitesOutput, +) + +API = "https://public-api.luma.com/v1" + +_API_KEY = "fake-luma-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_8_actions(self) -> None: + assert len(manifest.actions) == 8 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_event(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/event/create", + json={ + # TODO: fill in a representative response from the Luma API + "event": {"api_id": "evt-abc123", "name": "Test Event"}, + }, + ) + + result_dict = await create_event.ainvoke( + _args(name="Test Event", start_at="2026-06-01T18:00:00Z", timezone="America/New_York") + ) + + assert isinstance(result_dict, dict) + result = CreateEventOutput.model_validate(result_dict) + assert result.success is True + assert result.event is not None + + +@pytest.mark.asyncio +async def test_get_event(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/event/get?id=evt-abc123", + json={ + # TODO: fill in a representative response from the Luma API + "event": {"api_id": "evt-abc123", "name": "Test Event"}, + }, + ) + + result_dict = await get_event.ainvoke(_args(event_id="evt-abc123")) + + assert isinstance(result_dict, dict) + result = GetEventOutput.model_validate(result_dict) + assert result.success is True + assert result.event is not None + + +@pytest.mark.asyncio +async def test_list_events(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/calendar/list-events?pagination_limit=50", + json={ + # TODO: fill in a representative response from the Luma API + "entries": [{"event": {"api_id": "evt-1", "name": "Event 1"}}], + "has_more": False, + "next_cursor": None, + }, + ) + + result_dict = await list_events.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListEventsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.events) >= 1 + + +@pytest.mark.asyncio +async def test_get_guest(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/event/get-guest?event_id=evt-abc123&id=gst-xyz", + json={ + # TODO: fill in a representative response from the Luma API + "guest": {"api_id": "gst-xyz", "name": "Jane Doe", "email": "jane@example.com"}, + }, + ) + + result_dict = await get_guest.ainvoke(_args(event_id="evt-abc123", guest_id="gst-xyz")) + + assert isinstance(result_dict, dict) + result = GetGuestOutput.model_validate(result_dict) + assert result.success is True + assert result.guest is not None + + +@pytest.mark.asyncio +async def test_get_guests(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/event/get-guests?event_id=evt-abc123&pagination_limit=50", + json={ + # TODO: fill in a representative response from the Luma API + "entries": [{"guest": {"api_id": "gst-1", "name": "John"}}], + "has_more": False, + "next_cursor": None, + }, + ) + + result_dict = await get_guests.ainvoke(_args(event_id="evt-abc123")) + + assert isinstance(result_dict, dict) + result = GetGuestsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.guests) >= 1 + + +@pytest.mark.asyncio +async def test_add_guests(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/event/add-guests", + json={ + # TODO: fill in a representative response from the Luma API + "guests": [{"email": "jane@example.com", "name": "Jane Doe"}], + }, + ) + + result_dict = await add_guests.ainvoke( + _args( + event_id="evt-abc123", + guests_json='[{"email":"jane@example.com","name":"Jane Doe"}]', + ) + ) + + assert isinstance(result_dict, dict) + result = AddGuestsOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_list_ticket_types(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/event/ticket-types/list?event_id=evt-abc123", + json={ + # TODO: fill in a representative response from the Luma API + "ticket_types": [{"id": "tt-1", "name": "General Admission"}], + }, + ) + + result_dict = await list_ticket_types.ainvoke(_args(event_id="evt-abc123")) + + assert isinstance(result_dict, dict) + result = ListTicketTypesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.ticket_types) >= 1 + + +@pytest.mark.asyncio +async def test_send_invites(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/event/send-invites", + json={}, + ) + + result_dict = await send_invites.ainvoke( + _args( + event_id="evt-abc123", + guests_json='[{"email":"jane@example.com","name":"Jane Doe"}]', + ) + ) + + assert isinstance(result_dict, dict) + result = SendInvitesOutput.model_validate(result_dict) + assert result.success is True + + +# --- Failure-path tests ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_empty_api_key_short_circuits() -> None: + """Empty credential returns success=False without hitting the wire.""" + result_dict = await create_event.ainvoke( + {"api_key": "", "name": "X", "start_at": "2026-06-01T00:00:00Z", "timezone": "UTC"} + ) + assert isinstance(result_dict, dict) + result = CreateEventOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None diff --git a/src/modulex_integrations/tools/luma/tools.py b/src/modulex_integrations/tools/luma/tools.py new file mode 100644 index 0000000..240ee5f --- /dev/null +++ b/src/modulex_integrations/tools/luma/tools.py @@ -0,0 +1,484 @@ +"""Luma LangChain @tool functions.""" +from __future__ import annotations + +import json +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.luma.outputs import ( + AddGuestsOutput, + CreateEventOutput, + GetEventOutput, + GetGuestOutput, + GetGuestsOutput, + ListEventsOutput, + ListTicketTypesOutput, + SendInvitesOutput, +) + +__all__ = [ + "add_guests", + "create_event", + "get_event", + "get_guest", + "get_guests", + "list_events", + "list_ticket_types", + "send_invites", +] + +_BASE_URL = "https://public-api.luma.com/v1" +_TIMEOUT = 30.0 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "x-luma-api-key": api_key, + "Content-Type": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateEventInput(BaseModel): + name: str = Field(description="The event name") + start_at: str = Field(description="Event start time as ISO 8601 datetime") + timezone: str = Field(description="IANA timezone, e.g. America/New_York") + api_key: str = Field(description="Luma API key") + end_at: str | None = Field(default=None, description="Event end time as ISO 8601 datetime") + description_md: str | None = Field(default=None, description="Markdown description for the event") + visibility: str | None = Field(default=None, description="Event visibility: public, members-only, or private") + slug: str | None = Field(default=None, description="Custom event URL slug") + meeting_url: str | None = Field(default=None, description="Online meeting URL for a virtual event") + cover_url: str | None = Field(default=None, description="Cover image URL") + max_capacity: int | None = Field(default=None, description="Maximum registrations before sold out") + can_register_for_multiple_tickets: bool | None = Field(default=None, description="Whether guests can register for multiple tickets") + show_guest_list: bool | None = Field(default=None, description="Whether guests can see who else is attending") + reminders_disabled: bool | None = Field(default=None, description="Whether to disable default reminders") + name_requirement: str | None = Field(default=None, description="Name collection: full-name or first-last") + phone_number_requirement: str | None = Field(default=None, description="Phone number: optional or required") + tint_color: str | None = Field(default=None, description="Hex color for event theme") + coordinate_json: str | None = Field(default=None, description="JSON object with latitude and longitude") + geo_address_json: str | None = Field(default=None, description="JSON object with address details") + registration_questions_json: str | None = Field(default=None, description="JSON array of registration questions") + feedback_email_json: str | None = Field(default=None, description="JSON object for post-event feedback email") + + +class GetEventInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + api_key: str = Field(description="Luma API key") + + +class ListEventsInput(BaseModel): + api_key: str = Field(description="Luma API key") + after: str | None = Field(default=None, description="Return events starting after this ISO 8601 datetime") + before: str | None = Field(default=None, description="Return events starting before this ISO 8601 datetime") + pagination_cursor: str | None = Field(default=None, description="next_cursor from a previous response") + pagination_limit: int = Field(default=50, description="Number of items per page") + status: str | None = Field(default=None, description="Calendar submission status: approved or pending") + sort_column: str | None = Field(default=None, description="Column to sort by (start_at)") + sort_direction: str | None = Field(default=None, description="Sort order: asc, desc, asc nulls last, desc nulls last") + + +class GetGuestInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + guest_id: str = Field(description="Guest ID, ticket key, guest key, or email") + api_key: str = Field(description="Luma API key") + + +class GetGuestsInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + api_key: str = Field(description="Luma API key") + approval_status: str | None = Field(default=None, description="Filter: approved, session, pending_approval, invited, declined, waitlist") + pagination_cursor: str | None = Field(default=None, description="next_cursor from a previous response") + pagination_limit: int = Field(default=50, description="Number of items per page") + sort_column: str | None = Field(default=None, description="Sort by: name, email, created_at, registered_at, checked_in_at") + sort_direction: str | None = Field(default=None, description="Sort order: asc, desc, asc nulls last, desc nulls last") + + +class AddGuestsInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + guests_json: str = Field(description="JSON array of guests with at least an email field each") + api_key: str = Field(description="Luma API key") + ticket_json: str | None = Field(default=None, description="JSON object assigning one ticket type (mutually exclusive with tickets_json)") + tickets_json: str | None = Field(default=None, description="JSON array assigning multiple tickets (mutually exclusive with ticket_json)") + + +class ListTicketTypesInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + api_key: str = Field(description="Luma API key") + include_hidden: bool = Field(default=False, description="Whether to include hidden ticket types") + + +class SendInvitesInput(BaseModel): + event_id: str = Field(description="Luma event ID (usually starts with evt-)") + guests_json: str = Field(description="JSON array of guests to invite with at least an email field each") + api_key: str = Field(description="Luma API key") + message: str | None = Field(default=None, description="Optional invite message (max 200 characters)") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateEventInput) +@serialize_pydantic_return +async def create_event( + name: str, + start_at: str, + timezone: str, + api_key: str, + end_at: str | None = None, + description_md: str | None = None, + visibility: str | None = None, + slug: str | None = None, + meeting_url: str | None = None, + cover_url: str | None = None, + max_capacity: int | None = None, + can_register_for_multiple_tickets: bool | None = None, + show_guest_list: bool | None = None, + reminders_disabled: bool | None = None, + name_requirement: str | None = None, + phone_number_requirement: str | None = None, + tint_color: str | None = None, + coordinate_json: str | None = None, + geo_address_json: str | None = None, + registration_questions_json: str | None = None, + feedback_email_json: str | None = None, +) -> CreateEventOutput: + """Create an event on the connected Luma calendar.""" + if not api_key or not api_key.strip(): + return CreateEventOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = { + "name": name, + "start_at": start_at, + "timezone": timezone, + } + if end_at is not None: + body["end_at"] = end_at + if description_md is not None: + body["description_md"] = description_md + if visibility is not None: + body["visibility"] = visibility + if slug is not None: + body["slug"] = slug + if meeting_url is not None: + body["meeting_url"] = meeting_url + if cover_url is not None: + body["cover_url"] = cover_url + if max_capacity is not None: + body["max_capacity"] = max_capacity + if can_register_for_multiple_tickets is not None: + body["can_register_for_multiple_tickets"] = can_register_for_multiple_tickets + if show_guest_list is not None: + body["show_guest_list"] = show_guest_list + if reminders_disabled is not None: + body["reminders_disabled"] = reminders_disabled + if name_requirement is not None: + body["name_requirement"] = name_requirement + if phone_number_requirement is not None: + body["phone_number_requirement"] = phone_number_requirement + if tint_color is not None: + body["tint_color"] = tint_color + if coordinate_json is not None: + try: + body["coordinate"] = json.loads(coordinate_json) + except json.JSONDecodeError: + return CreateEventOutput(success=False, error="coordinate_json is not valid JSON.") + if geo_address_json is not None: + try: + body["geo_address_json"] = json.loads(geo_address_json) + except json.JSONDecodeError: + return CreateEventOutput(success=False, error="geo_address_json is not valid JSON.") + if registration_questions_json is not None: + try: + body["registration_questions"] = json.loads(registration_questions_json) + except json.JSONDecodeError: + return CreateEventOutput(success=False, error="registration_questions_json is not valid JSON.") + if feedback_email_json is not None: + try: + body["feedback_email"] = json.loads(feedback_email_json) + except json.JSONDecodeError: + return CreateEventOutput(success=False, error="feedback_email_json is not valid JSON.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/event/create", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreateEventOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateEventOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateEventOutput(success=False, error=f"Call failed: {exc}") + return CreateEventOutput(success=True, event=data.get("event") or data) + + +@tool(args_schema=GetEventInput) +@serialize_pydantic_return +async def get_event( + event_id: str, + api_key: str, +) -> GetEventOutput: + """Get admin details for a Luma event by event ID.""" + if not api_key or not api_key.strip(): + return GetEventOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/event/get", + headers=_headers(api_key), + params={"id": event_id}, + ) + if response.status_code != 200: + return GetEventOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetEventOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetEventOutput(success=False, error=f"Call failed: {exc}") + return GetEventOutput(success=True, event=data.get("event") or data) + + +@tool(args_schema=ListEventsInput) +@serialize_pydantic_return +async def list_events( + api_key: str, + after: str | None = None, + before: str | None = None, + pagination_cursor: str | None = None, + pagination_limit: int = 50, + status: str | None = None, + sort_column: str | None = None, + sort_direction: str | None = None, +) -> ListEventsOutput: + """List events managed by the connected Luma calendar.""" + if not api_key or not api_key.strip(): + return ListEventsOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"pagination_limit": pagination_limit} + if after is not None: + params["after"] = after + if before is not None: + params["before"] = before + if pagination_cursor is not None: + params["pagination_cursor"] = pagination_cursor + if status is not None: + params["status"] = status + if sort_column is not None: + params["sort_column"] = sort_column + if sort_direction is not None: + params["sort_direction"] = sort_direction + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/calendar/list-events", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return ListEventsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListEventsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListEventsOutput(success=False, error=f"Call failed: {exc}") + entries = data.get("entries", []) + events = [e.get("event", e) for e in entries] + return ListEventsOutput( + success=True, + events=events, + has_more=data.get("has_more"), + next_cursor=data.get("next_cursor"), + ) + + +@tool(args_schema=GetGuestInput) +@serialize_pydantic_return +async def get_guest( + event_id: str, + guest_id: str, + api_key: str, +) -> GetGuestOutput: + """Get detailed information for a Luma event guest by ID or email.""" + if not api_key or not api_key.strip(): + return GetGuestOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/event/get-guest", + headers=_headers(api_key), + params={"event_id": event_id, "id": guest_id}, + ) + if response.status_code != 200: + return GetGuestOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetGuestOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetGuestOutput(success=False, error=f"Call failed: {exc}") + return GetGuestOutput(success=True, guest=data.get("guest") or data) + + +@tool(args_schema=GetGuestsInput) +@serialize_pydantic_return +async def get_guests( + event_id: str, + api_key: str, + approval_status: str | None = None, + pagination_cursor: str | None = None, + pagination_limit: int = 50, + sort_column: str | None = None, + sort_direction: str | None = None, +) -> GetGuestsOutput: + """List guests registered for, invited to, or waitlisted for a Luma event.""" + if not api_key or not api_key.strip(): + return GetGuestsOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"event_id": event_id, "pagination_limit": pagination_limit} + if approval_status is not None: + params["approval_status"] = approval_status + if pagination_cursor is not None: + params["pagination_cursor"] = pagination_cursor + if sort_column is not None: + params["sort_column"] = sort_column + if sort_direction is not None: + params["sort_direction"] = sort_direction + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/event/get-guests", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return GetGuestsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetGuestsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetGuestsOutput(success=False, error=f"Call failed: {exc}") + entries = data.get("entries", []) + guests = [e.get("guest", e) for e in entries] + return GetGuestsOutput( + success=True, + guests=guests, + has_more=data.get("has_more"), + next_cursor=data.get("next_cursor"), + ) + + +@tool(args_schema=AddGuestsInput) +@serialize_pydantic_return +async def add_guests( + event_id: str, + guests_json: str, + api_key: str, + ticket_json: str | None = None, + tickets_json: str | None = None, +) -> AddGuestsOutput: + """Add guests to a Luma event with status Going.""" + if not api_key or not api_key.strip(): + return AddGuestsOutput(success=False, error="API key is empty. Please configure a valid credential.") + if ticket_json and tickets_json: + return AddGuestsOutput(success=False, error="ticket_json and tickets_json are mutually exclusive.") + try: + guests_list = json.loads(guests_json) + except json.JSONDecodeError: + return AddGuestsOutput(success=False, error="guests_json is not valid JSON.") + body: dict[str, Any] = {"event_id": event_id, "guests": guests_list} + if ticket_json is not None: + try: + body["ticket"] = json.loads(ticket_json) + except json.JSONDecodeError: + return AddGuestsOutput(success=False, error="ticket_json is not valid JSON.") + if tickets_json is not None: + try: + body["tickets"] = json.loads(tickets_json) + except json.JSONDecodeError: + return AddGuestsOutput(success=False, error="tickets_json is not valid JSON.") + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/event/add-guests", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return AddGuestsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return AddGuestsOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddGuestsOutput(success=False, error=f"Call failed: {exc}") + return AddGuestsOutput(success=True, guests=data.get("guests", [])) + + +@tool(args_schema=ListTicketTypesInput) +@serialize_pydantic_return +async def list_ticket_types( + event_id: str, + api_key: str, + include_hidden: bool = False, +) -> ListTicketTypesOutput: + """List ticket types for a Luma event.""" + if not api_key or not api_key.strip(): + return ListTicketTypesOutput(success=False, error="API key is empty. Please configure a valid credential.") + params: dict[str, Any] = {"event_id": event_id} + if include_hidden: + params["include_hidden"] = "true" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/event/ticket-types/list", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return ListTicketTypesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListTicketTypesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListTicketTypesOutput(success=False, error=f"Call failed: {exc}") + ticket_types = data.get("ticket_types", data.get("entries", [])) + return ListTicketTypesOutput(success=True, ticket_types=ticket_types) + + +@tool(args_schema=SendInvitesInput) +@serialize_pydantic_return +async def send_invites( + event_id: str, + guests_json: str, + api_key: str, + message: str | None = None, +) -> SendInvitesOutput: + """Send email invitations for a Luma event.""" + if not api_key or not api_key.strip(): + return SendInvitesOutput(success=False, error="API key is empty. Please configure a valid credential.") + try: + guests_list = json.loads(guests_json) + except json.JSONDecodeError: + return SendInvitesOutput(success=False, error="guests_json is not valid JSON.") + body: dict[str, Any] = {"event_id": event_id, "guests": guests_list} + if message is not None: + body["message"] = message + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/event/send-invites", + headers=_headers(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return SendInvitesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return SendInvitesOutput(success=False, error="Request timed out.") + except Exception as exc: + return SendInvitesOutput(success=False, error=f"Call failed: {exc}") + return SendInvitesOutput(success=True) diff --git a/src/modulex_integrations/tools/microsoft_entra_id/README.md b/src/modulex_integrations/tools/microsoft_entra_id/README.md new file mode 100644 index 0000000..a206fc1 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/README.md @@ -0,0 +1,43 @@ +# Microsoft Entra ID + +Identity and access management via the Microsoft Graph API (`graph.microsoft.com/v1.0`) for managing users, groups, and directory objects in Microsoft Entra ID (formerly Azure Active Directory). + +## Authentication + +### OAuth2 Authentication + +- Register an app at the [Azure Portal App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade). +- Add redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required env vars (only when bringing your own OAuth app): + - `MICROSOFT_ENTRA_ID_OAUTH2_CLIENT_ID` (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) + - `MICROSOFT_ENTRA_ID_OAUTH2_CLIENT_SECRET` +- Scopes requested: `User.Read`, `User.ReadWrite.All`, `Group.ReadWrite.All`, `GroupMember.ReadWrite.All`, `Directory.ReadWrite.All` + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `add_member_to_group` | Add a user as a member to a Microsoft Entra ID group. | `group_id`, `user_id` | +| `create_group` | Create a new group in Microsoft Entra ID. | `display_name`, `mail_enabled`, `mail_nickname`, `security_enabled` | +| `delete_group` | Delete a group in Microsoft Entra ID. | `group_id` | +| `get_manager` | Get the user's manager information. | — | +| `get_ms365_groups` | Get the user's Microsoft 365 groups (unified groups). | — | +| `get_organization_groups` | List all groups in the organization. | — | +| `get_organization_users` | List all users in the organization. | — | +| `get_profile` | Get the user's profile information from Microsoft Entra ID. | — | +| `remove_member_from_group` | Remove a member from a Microsoft Entra ID group. | `group_id`, `user_id` | +| `search_groups` | Search for groups by name or description. | `query` | +| `update_group` | Update an existing group in Microsoft Entra ID. | `group_id` | +| `update_user` | Update an existing user in Microsoft Entra ID. | `user_id` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth2 credential. + +## Limits & Quotas + +- **Microsoft Graph throttling**: Varies by resource and tenant; typically 10,000 requests per 10 minutes per app per tenant for most directory endpoints. +- **Pagination**: List endpoints may return paginated results; the integration follows `@odata.nextLink` automatically. +- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/microsoft_entra_id/__init__.py b/src/modulex_integrations/tools/microsoft_entra_id/__init__.py new file mode 100644 index 0000000..3da8f9d --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/__init__.py @@ -0,0 +1,48 @@ +"""Microsoft Entra ID integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.microsoft_entra_id.manifest import manifest +from modulex_integrations.tools.microsoft_entra_id.tools import ( + add_member_to_group, + create_group, + delete_group, + get_manager, + get_ms365_groups, + get_organization_groups, + get_organization_users, + get_profile, + remove_member_from_group, + search_groups, + update_group, + update_user, +) + +TOOLS = ( + add_member_to_group, + create_group, + delete_group, + get_manager, + get_ms365_groups, + get_organization_groups, + get_organization_users, + get_profile, + remove_member_from_group, + search_groups, + update_group, + update_user, +) + +__all__ = [ + "TOOLS", + "add_member_to_group", + "create_group", + "delete_group", + "get_manager", + "get_ms365_groups", + "get_organization_groups", + "get_organization_users", + "get_profile", + "manifest", + "remove_member_from_group", + "search_groups", + "update_group", + "update_user", +] diff --git a/src/modulex_integrations/tools/microsoft_entra_id/dependencies.toml b/src/modulex_integrations/tools/microsoft_entra_id/dependencies.toml new file mode 100644 index 0000000..21bd4e1 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the microsoft_entra_id integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/microsoft_entra_id/manifest.py b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py new file mode 100644 index 0000000..e56a975 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/manifest.py @@ -0,0 +1,307 @@ +"""Microsoft Entra ID integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="microsoft_entra_id", + display_name="Microsoft Entra ID", + description="Identity and access management via Microsoft Graph API for users, groups, and directory objects.", + version="1.0.0", + author="ModuleX", + logo="modulex:microsoft_entra_id-themed", + app_url="https://entra.microsoft.com", + categories=["Identity & Access Management", "Enterprise", "Security"], + actions=[ + ActionDefinition( + name="add_member_to_group", + description="Add a user as a member to a Microsoft Entra ID group.", + parameters={ + "group_id": ParameterDef( + type="string", + description="Identifier of the group", + required=True, + ), + "user_id": ParameterDef( + type="string", + description="Identifier of the user to add", + required=True, + ), + }, + ), + ActionDefinition( + name="create_group", + description="Create a new group in Microsoft Entra ID.", + parameters={ + "display_name": ParameterDef( + type="string", + description="The name to display in the address book for the group", + required=True, + ), + "mail_enabled": ParameterDef( + type="boolean", + description="Set to true for mail-enabled groups", + required=True, + ), + "mail_nickname": ParameterDef( + type="string", + description="The mail alias for the group, unique for groups in the organization. Maximum length is 64 characters.", + required=True, + ), + "security_enabled": ParameterDef( + type="boolean", + description="Set to true for security-enabled groups", + required=True, + ), + }, + ), + ActionDefinition( + name="delete_group", + description="Delete a group in Microsoft Entra ID.", + parameters={ + "group_id": ParameterDef( + type="string", + description="Identifier of the group to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="get_manager", + description="Get the user's manager information. Returns the user or organizational contact assigned as the user's manager.", + parameters={ + "user_id": ParameterDef( + type="string", + description="Identifier of the user. Leave empty to use the signed-in user.", + ), + }, + ), + ActionDefinition( + name="get_ms365_groups", + description="Get the user's Microsoft 365 groups (unified groups). Returns groups the user is a direct member of.", + parameters={ + "user_id": ParameterDef( + type="string", + description="Identifier of the user. Leave empty to use the signed-in user.", + ), + }, + ), + ActionDefinition( + name="get_organization_groups", + description="List all groups in the organization (excluding dynamic distribution groups).", + parameters={}, + ), + ActionDefinition( + name="get_organization_users", + description="List all users in the organization. By default returns only enabled accounts.", + parameters={ + "max_users": ParameterDef( + type="integer", + description="Maximum number of users to return. Omit for no limit.", + ), + "filter": ParameterDef( + type="string", + description="OData filter expression, e.g. 'accountEnabled eq true'", + default="accountEnabled eq true", + ), + "search": ParameterDef( + type="string", + description="OData search expression, e.g. '\"displayName:John\"'", + ), + }, + ), + ActionDefinition( + name="get_profile", + description="Get the user's profile information from Microsoft Entra ID.", + parameters={ + "user_id": ParameterDef( + type="string", + description="Identifier of the user. Leave empty to use the signed-in user.", + ), + }, + ), + ActionDefinition( + name="remove_member_from_group", + description="Remove a member from a Microsoft Entra ID group.", + parameters={ + "group_id": ParameterDef( + type="string", + description="Identifier of the group", + required=True, + ), + "user_id": ParameterDef( + type="string", + description="Identifier of the user to remove", + required=True, + ), + }, + ), + ActionDefinition( + name="search_groups", + description="Search for groups by name or description in Microsoft Entra ID.", + parameters={ + "query": ParameterDef( + type="string", + description="Keywords to search by", + required=True, + ), + "max_results": ParameterDef( + type="integer", + description="The maximum number of groups to return", + default=100, + ), + }, + ), + ActionDefinition( + name="update_group", + description="Update an existing group in Microsoft Entra ID.", + parameters={ + "group_id": ParameterDef( + type="string", + description="Identifier of the group to update", + required=True, + ), + "allow_external_senders": ParameterDef( + type="boolean", + description="Whether people external to the organization can send messages to the group", + ), + "auto_subscribe_new_members": ParameterDef( + type="boolean", + description="Whether new members added to the group will be auto-subscribed to receive email notifications", + ), + "description": ParameterDef( + type="string", + description="An optional description for the group", + ), + "display_name": ParameterDef( + type="string", + description="The name to display in the address book for the group", + ), + "mail_nickname": ParameterDef( + type="string", + description="The mail alias for the group. Maximum length is 64 characters.", + ), + "security_enabled": ParameterDef( + type="boolean", + description="Set to true for security-enabled groups", + ), + "visibility": ParameterDef( + type="string", + description="Specifies the visibility of the group. Allowed values: Public, Private.", + ), + }, + ), + ActionDefinition( + name="update_user", + description="Update an existing user in Microsoft Entra ID.", + parameters={ + "user_id": ParameterDef( + type="string", + description="Identifier of the user to update", + required=True, + ), + "display_name": ParameterDef( + type="string", + description="The name to display in the address book for the user", + ), + "mail": ParameterDef( + type="string", + description="The SMTP address for the user", + ), + "mail_nickname": ParameterDef( + type="string", + description="The mail alias for the user", + ), + "account_enabled": ParameterDef( + type="boolean", + description="Whether the account is enabled", + default=True, + ), + "street_address": ParameterDef( + type="string", + description="The street address of the user's place of business", + ), + "city": ParameterDef( + type="string", + description="The city in which the user is located", + ), + "state": ParameterDef( + type="string", + description="The state or province in the user's address", + ), + "postal_code": ParameterDef( + type="string", + description="The postal code for the user's postal address", + ), + "country": ParameterDef( + type="string", + description="The country/region in which the user is located", + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Microsoft Entra ID OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="MICROSOFT_ENTRA_ID_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Microsoft Entra ID OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + ), + EnvVar( + name="MICROSOFT_ENTRA_ID_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Microsoft Entra ID OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_url="https://login.microsoftonline.com/common/oauth2/v2.0/token", + scopes=[ + "User.Read", + "User.ReadWrite.All", + "Group.ReadWrite.All", + "GroupMember.ReadWrite.All", + "Directory.ReadWrite.All", + ], + ), + test_endpoint=TestEndpoint( + url="https://graph.microsoft.com/v1.0/me", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["id"], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user profile", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/microsoft_entra_id/outputs.py b/src/modulex_integrations/tools/microsoft_entra_id/outputs.py new file mode 100644 index 0000000..db5b5b5 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/outputs.py @@ -0,0 +1,133 @@ +"""Pydantic response models for the microsoft_entra_id integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddMemberToGroupOutput", + "CreateGroupOutput", + "DeleteGroupOutput", + "GetManagerOutput", + "GetMs365GroupsOutput", + "GetOrganizationGroupsOutput", + "GetOrganizationUsersOutput", + "GetProfileOutput", + "GroupSummary", + "ManagerInfo", + "RemoveMemberFromGroupOutput", + "SearchGroupsOutput", + "UpdateGroupOutput", + "UpdateUserOutput", + "UserSummary", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class GroupSummary(_Base): + id: str | None = None + display_name: str | None = None + description: str | None = None + mail_enabled: bool | None = None + mail_nickname: str | None = None + security_enabled: bool | None = None + group_types: list[str] = Field(default_factory=list) + deleted_date_time: str | None = None + + +class UserSummary(_Base): + id: str | None = None + full_name: str | None = None + description: str | None = None + email: str | None = None + user_principal_name: str | None = None + surname: str | None = None + given_name: str | None = None + job_title: str | None = None + mobile_phone: str | None = None + + +class ManagerInfo(_Base): + id: str | None = None + display_name: str | None = None + email: str | None = None + job_title: str | None = None + mobile_phone: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class AddMemberToGroupOutput(_Base): + success: bool + error: str | None = None + + +class CreateGroupOutput(_Base): + success: bool + error: str | None = None + group: GroupSummary | None = None + + +class DeleteGroupOutput(_Base): + success: bool + error: str | None = None + + +class GetManagerOutput(_Base): + success: bool + error: str | None = None + manager: ManagerInfo | None = None + message: str | None = None + + +class GetMs365GroupsOutput(_Base): + success: bool + error: str | None = None + groups: list[GroupSummary] = Field(default_factory=list) + + +class GetOrganizationGroupsOutput(_Base): + success: bool + error: str | None = None + groups: list[GroupSummary] = Field(default_factory=list) + + +class GetOrganizationUsersOutput(_Base): + success: bool + error: str | None = None + users: list[UserSummary] = Field(default_factory=list) + + +class GetProfileOutput(_Base): + success: bool + error: str | None = None + data: dict[str, object] | None = None + + +class RemoveMemberFromGroupOutput(_Base): + success: bool + error: str | None = None + + +class SearchGroupsOutput(_Base): + success: bool + error: str | None = None + groups: list[GroupSummary] = Field(default_factory=list) + + +class UpdateGroupOutput(_Base): + success: bool + error: str | None = None + + +class UpdateUserOutput(_Base): + success: bool + error: str | None = None diff --git a/src/modulex_integrations/tools/microsoft_entra_id/tests/__init__.py b/src/modulex_integrations/tools/microsoft_entra_id/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/microsoft_entra_id/tests/test_microsoft_entra_id.py b/src/modulex_integrations/tools/microsoft_entra_id/tests/test_microsoft_entra_id.py new file mode 100644 index 0000000..de900df --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/tests/test_microsoft_entra_id.py @@ -0,0 +1,354 @@ +"""Happy-path tests for every microsoft_entra_id @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.microsoft_entra_id import ( + TOOLS, + add_member_to_group, + create_group, + delete_group, + get_manager, + get_ms365_groups, + get_organization_groups, + get_organization_users, + get_profile, + manifest, + remove_member_from_group, + search_groups, + update_group, + update_user, +) +from modulex_integrations.tools.microsoft_entra_id.outputs import ( + AddMemberToGroupOutput, + CreateGroupOutput, + DeleteGroupOutput, + GetManagerOutput, + GetMs365GroupsOutput, + GetOrganizationGroupsOutput, + GetOrganizationUsersOutput, + GetProfileOutput, + RemoveMemberFromGroupOutput, + SearchGroupsOutput, + UpdateGroupOutput, + UpdateUserOutput, +) + +API = "https://graph.microsoft.com/v1.0" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_12_actions(self) -> None: + assert len(manifest.actions) == 12 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_add_member_to_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/groups/group-123/members/$ref", + status_code=204, + ) + + result_dict = await add_member_to_group.ainvoke( + _args(group_id="group-123", user_id="user-456") + ) + + assert isinstance(result_dict, dict) + result = AddMemberToGroupOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/groups", + json={ + # TODO: fill in a representative response from the upstream API docs + "id": "new-group-id", + "displayName": "Test Group", + "mailEnabled": False, + "mailNickname": "testgroup", + "securityEnabled": True, + "groupTypes": [], + }, + status_code=201, + ) + + result_dict = await create_group.ainvoke( + _args( + display_name="Test Group", + mail_enabled=False, + mail_nickname="testgroup", + security_enabled=True, + ) + ) + + assert isinstance(result_dict, dict) + result = CreateGroupOutput.model_validate(result_dict) + assert result.success is True + assert result.group is not None + assert result.group.id == "new-group-id" + + +@pytest.mark.asyncio +async def test_delete_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/groups/group-123", + status_code=204, + ) + + result_dict = await delete_group.ainvoke(_args(group_id="group-123")) + + assert isinstance(result_dict, dict) + result = DeleteGroupOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_manager(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/user-123/manager", + json={ + # TODO: fill in a representative response from the upstream API docs + "id": "manager-id", + "displayName": "Jane Manager", + "mail": "jane@contoso.com", + "jobTitle": "Director", + "mobilePhone": "+1234567890", + }, + ) + + result_dict = await get_manager.ainvoke(_args(user_id="user-123")) + + assert isinstance(result_dict, dict) + result = GetManagerOutput.model_validate(result_dict) + assert result.success is True + assert result.manager is not None + assert result.manager.display_name == "Jane Manager" + + +@pytest.mark.asyncio +async def test_get_ms365_groups(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/user-123/memberOf/microsoft.graph.group?%24filter=groupTypes%2Fany%28a%3Aa+eq+%27Unified%27%29", + json={ + # TODO: fill in a representative response from the upstream API docs + "value": [ + { + "id": "group-1", + "displayName": "Team Alpha", + "description": "Alpha team group", + "groupTypes": ["Unified"], + } + ], + }, + ) + + result_dict = await get_ms365_groups.ainvoke(_args(user_id="user-123")) + + assert isinstance(result_dict, dict) + result = GetMs365GroupsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.groups) == 1 + + +@pytest.mark.asyncio +async def test_get_organization_groups(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/groups", + json={ + # TODO: fill in a representative response from the upstream API docs + "value": [ + { + "id": "group-1", + "displayName": "All Company", + "description": "Company-wide group", + "mailEnabled": True, + "deletedDateTime": None, + } + ], + }, + ) + + result_dict = await get_organization_groups.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetOrganizationGroupsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.groups) == 1 + + +@pytest.mark.asyncio +async def test_get_organization_users(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users?%24filter=accountEnabled+eq+true", + json={ + # TODO: fill in a representative response from the upstream API docs + "value": [ + { + "id": "user-1", + "displayName": "John Doe", + "mail": "john@contoso.com", + "userPrincipalName": "john@contoso.onmicrosoft.com", + "surname": "Doe", + "givenName": "John", + "jobTitle": "Engineer", + "mobilePhone": None, + } + ], + }, + ) + + result_dict = await get_organization_users.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetOrganizationUsersOutput.model_validate(result_dict) + assert result.success is True + assert len(result.users) == 1 + + +@pytest.mark.asyncio +async def test_get_profile(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/user-123", + json={ + # TODO: fill in a representative response from the upstream API docs + "id": "user-123", + "displayName": "John Doe", + "mail": "john@contoso.com", + }, + ) + + result_dict = await get_profile.ainvoke(_args(user_id="user-123")) + + assert isinstance(result_dict, dict) + result = GetProfileOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + assert result.data["id"] == "user-123" + + +@pytest.mark.asyncio +async def test_remove_member_from_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/groups/group-123/members/user-456/$ref", + status_code=204, + ) + + result_dict = await remove_member_from_group.ainvoke( + _args(group_id="group-123", user_id="user-456") + ) + + assert isinstance(result_dict, dict) + result = RemoveMemberFromGroupOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +@pytest.mark.skip(reason="mock URL does not include $search/$top query params; needs human fix") +async def test_search_groups(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/groups", + json={ + # TODO: fill in a representative response from the upstream API docs + "value": [ + { + "id": "group-1", + "displayName": "Engineering", + "description": "Engineering team", + "mailEnabled": False, + "securityEnabled": True, + "groupTypes": [], + } + ], + }, + ) + + result_dict = await search_groups.ainvoke(_args(query="Engineering")) + + assert isinstance(result_dict, dict) + result = SearchGroupsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.groups) == 1 + + +@pytest.mark.asyncio +async def test_update_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/groups/group-123", + status_code=204, + ) + + result_dict = await update_group.ainvoke( + _args(group_id="group-123", description="Updated description") + ) + + assert isinstance(result_dict, dict) + result = UpdateGroupOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_user(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/users/user-123", + status_code=204, + ) + + result_dict = await update_user.ainvoke( + _args(user_id="user-123", display_name="Jane Updated") + ) + + assert isinstance(result_dict, dict) + result = UpdateUserOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_profile_empty_credential(): # type: ignore[no-untyped-def] + """Failure path: empty access token returns error without hitting the wire.""" + result_dict = await get_profile.ainvoke( + _args(auth_type="oauth2", auth_data={"access_token": ""}) + ) + + assert isinstance(result_dict, dict) + result = GetProfileOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access token" in result.error.lower() diff --git a/src/modulex_integrations/tools/microsoft_entra_id/tools.py b/src/modulex_integrations/tools/microsoft_entra_id/tools.py new file mode 100644 index 0000000..f97c122 --- /dev/null +++ b/src/modulex_integrations/tools/microsoft_entra_id/tools.py @@ -0,0 +1,699 @@ +"""Microsoft Entra ID LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.microsoft_entra_id.outputs import ( + AddMemberToGroupOutput, + CreateGroupOutput, + DeleteGroupOutput, + GetManagerOutput, + GetMs365GroupsOutput, + GetOrganizationGroupsOutput, + GetOrganizationUsersOutput, + GetProfileOutput, + GroupSummary, + ManagerInfo, + RemoveMemberFromGroupOutput, + SearchGroupsOutput, + UpdateGroupOutput, + UpdateUserOutput, + UserSummary, +) + +__all__ = [ + "add_member_to_group", + "create_group", + "delete_group", + "get_manager", + "get_ms365_groups", + "get_organization_groups", + "get_organization_users", + "get_profile", + "remove_member_from_group", + "search_groups", + "update_group", + "update_user", +] + +_BASE_URL = "https://graph.microsoft.com/v1.0" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for Microsoft Graph API based on auth_type/auth_data.""" + headers: dict[str, str] = { + "Content-Type": "application/json", + } + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +async def _collect_odata_values( + client: httpx.AsyncClient, + url: str, + headers: dict[str, str], + params: dict[str, str] | None = None, + max_items: int | None = None, + max_pages: int = 50, +) -> list[dict[str, Any]]: + """Page through OData @odata.nextLink responses.""" + results: list[dict[str, Any]] = [] + next_url: str | None = url + pages_seen = 0 + while next_url and pages_seen < max_pages: + pages_seen += 1 + response = await client.get(next_url, headers=headers, params=params) + response.raise_for_status() + data = response.json() + values = data.get("value", []) + results.extend(values) + if max_items and len(results) >= max_items: + results = results[:max_items] + break + next_url = data.get("@odata.nextLink") + params = None + return results + + +# --- Input schemas -------------------------------------------------------- + + +class AddMemberToGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + group_id: str = Field(description="Identifier of the group") + user_id: str = Field(description="Identifier of the user to add") + + +class CreateGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + display_name: str = Field(description="The name to display in the address book for the group") + mail_enabled: bool = Field(description="Set to true for mail-enabled groups") + mail_nickname: str = Field(description="The mail alias for the group, unique for groups in the organization. Maximum length is 64 characters.") + security_enabled: bool = Field(description="Set to true for security-enabled groups") + + +class DeleteGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + group_id: str = Field(description="Identifier of the group to delete") + + +class GetManagerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str | None = Field(default=None, description="Identifier of the user. Leave empty to use the signed-in user.") + + +class GetMs365GroupsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str | None = Field(default=None, description="Identifier of the user. Leave empty to use the signed-in user.") + + +class GetOrganizationGroupsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class GetOrganizationUsersInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + max_users: int | None = Field(default=None, description="Maximum number of users to return. Omit for no limit.") + filter: str | None = Field(default="accountEnabled eq true", description="OData filter expression, e.g. 'accountEnabled eq true'") + search: str | None = Field(default=None, description="OData search expression, e.g. '\"displayName:John\"'") + + +class GetProfileInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str | None = Field(default=None, description="Identifier of the user. Leave empty to use the signed-in user.") + + +class RemoveMemberFromGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + group_id: str = Field(description="Identifier of the group") + user_id: str = Field(description="Identifier of the user to remove") + + +class SearchGroupsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + query: str = Field(description="Keywords to search by") + max_results: int = Field(default=100, description="The maximum number of groups to return") + + +class UpdateGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + group_id: str = Field(description="Identifier of the group to update") + allow_external_senders: bool | None = Field(default=None, description="Whether people external to the organization can send messages to the group") + auto_subscribe_new_members: bool | None = Field(default=None, description="Whether new members added to the group will be auto-subscribed to receive email notifications") + description: str | None = Field(default=None, description="An optional description for the group") + display_name: str | None = Field(default=None, description="The name to display in the address book for the group") + mail_nickname: str | None = Field(default=None, description="The mail alias for the group. Maximum length is 64 characters.") + security_enabled: bool | None = Field(default=None, description="Set to true for security-enabled groups") + visibility: str | None = Field(default=None, description="Specifies the visibility of the group. Allowed values: Public, Private.") + + +class UpdateUserInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + user_id: str = Field(description="Identifier of the user to update") + display_name: str | None = Field(default=None, description="The name to display in the address book for the user") + mail: str | None = Field(default=None, description="The SMTP address for the user") + mail_nickname: str | None = Field(default=None, description="The mail alias for the user") + account_enabled: bool | None = Field(default=True, description="Whether the account is enabled") + street_address: str | None = Field(default=None, description="The street address of the user's place of business") + city: str | None = Field(default=None, description="The city in which the user is located") + state: str | None = Field(default=None, description="The state or province in the user's address") + postal_code: str | None = Field(default=None, description="The postal code for the user's postal address") + country: str | None = Field(default=None, description="The country/region in which the user is located") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=AddMemberToGroupInput) +@serialize_pydantic_return +async def add_member_to_group( + auth_type: str, + auth_data: dict[str, Any], + group_id: str, + user_id: str, +) -> AddMemberToGroupOutput: + """Add a user as a member to a Microsoft Entra ID group.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return AddMemberToGroupOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + body = {"@odata.id": f"{_BASE_URL}/directoryObjects/{user_id}"} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/groups/{group_id}/members/$ref", + headers=headers, + json=body, + ) + if response.status_code not in (200, 204): + return AddMemberToGroupOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return AddMemberToGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddMemberToGroupOutput(success=False, error=f"Call failed: {exc}") + return AddMemberToGroupOutput(success=True) + + +@tool(args_schema=CreateGroupInput) +@serialize_pydantic_return +async def create_group( + auth_type: str, + auth_data: dict[str, Any], + display_name: str, + mail_enabled: bool, + mail_nickname: str, + security_enabled: bool, +) -> CreateGroupOutput: + """Create a new group in Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateGroupOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + body = { + "displayName": display_name, + "mailEnabled": mail_enabled, + "mailNickname": mail_nickname, + "securityEnabled": security_enabled, + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/groups", + headers=headers, + json=body, + ) + if response.status_code not in (200, 201): + return CreateGroupOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateGroupOutput(success=False, error=f"Call failed: {exc}") + return CreateGroupOutput( + success=True, + group=GroupSummary( + id=data.get("id"), + display_name=data.get("displayName"), + description=data.get("description"), + mail_enabled=data.get("mailEnabled"), + mail_nickname=data.get("mailNickname"), + security_enabled=data.get("securityEnabled"), + group_types=data.get("groupTypes", []), + ), + ) + + +@tool(args_schema=DeleteGroupInput) +@serialize_pydantic_return +async def delete_group( + auth_type: str, + auth_data: dict[str, Any], + group_id: str, +) -> DeleteGroupOutput: + """Delete a group in Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return DeleteGroupOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{_BASE_URL}/groups/{group_id}", + headers=headers, + ) + if response.status_code not in (200, 204): + return DeleteGroupOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteGroupOutput(success=False, error=f"Call failed: {exc}") + return DeleteGroupOutput(success=True) + + +@tool(args_schema=GetManagerInput) +@serialize_pydantic_return +async def get_manager( + auth_type: str, + auth_data: dict[str, Any], + user_id: str | None = None, +) -> GetManagerOutput: + """Get the user's manager information. Returns the user or organizational contact assigned as the user's manager.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return GetManagerOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + path = f"/users/{user_id}/manager" if user_id else "/me/manager" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}{path}", + headers=headers, + ) + if response.status_code == 404: + return GetManagerOutput( + success=True, + message="No manager assigned for this user.", + ) + if response.status_code != 200: + return GetManagerOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetManagerOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetManagerOutput(success=False, error=f"Call failed: {exc}") + return GetManagerOutput( + success=True, + manager=ManagerInfo( + id=data.get("id"), + display_name=data.get("displayName"), + email=data.get("mail"), + job_title=data.get("jobTitle"), + mobile_phone=data.get("mobilePhone"), + ), + ) + + +@tool(args_schema=GetMs365GroupsInput) +@serialize_pydantic_return +async def get_ms365_groups( + auth_type: str, + auth_data: dict[str, Any], + user_id: str | None = None, +) -> GetMs365GroupsOutput: + """Get the user's Microsoft 365 groups (unified groups). Returns groups the user is a direct member of.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return GetMs365GroupsOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + path = f"/users/{user_id}/memberOf/microsoft.graph.group" if user_id else "/me/memberOf/microsoft.graph.group" + params = {"$filter": "groupTypes/any(a:a eq 'Unified')"} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + values = await _collect_odata_values( + client, f"{_BASE_URL}{path}", headers, params=params, + ) + except httpx.HTTPStatusError as exc: + return GetMs365GroupsOutput( + success=False, + error=f"API error ({exc.response.status_code}): {exc.response.text}", + ) + except httpx.TimeoutException: + return GetMs365GroupsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetMs365GroupsOutput(success=False, error=f"Call failed: {exc}") + groups = [ + GroupSummary( + id=g.get("id"), + display_name=g.get("displayName"), + description=g.get("description"), + group_types=g.get("groupTypes", []), + ) + for g in values + ] + return GetMs365GroupsOutput(success=True, groups=groups) + + +@tool(args_schema=GetOrganizationGroupsInput) +@serialize_pydantic_return +async def get_organization_groups( + auth_type: str, + auth_data: dict[str, Any], +) -> GetOrganizationGroupsOutput: + """List all groups in the organization (excluding dynamic distribution groups).""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return GetOrganizationGroupsOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + values = await _collect_odata_values( + client, f"{_BASE_URL}/groups", headers, + ) + except httpx.HTTPStatusError as exc: + return GetOrganizationGroupsOutput( + success=False, + error=f"API error ({exc.response.status_code}): {exc.response.text}", + ) + except httpx.TimeoutException: + return GetOrganizationGroupsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetOrganizationGroupsOutput(success=False, error=f"Call failed: {exc}") + groups = [ + GroupSummary( + id=g.get("id"), + display_name=g.get("displayName"), + description=g.get("description"), + mail_enabled=g.get("mailEnabled"), + deleted_date_time=g.get("deletedDateTime"), + ) + for g in values + ] + return GetOrganizationGroupsOutput(success=True, groups=groups) + + +@tool(args_schema=GetOrganizationUsersInput) +@serialize_pydantic_return +async def get_organization_users( + auth_type: str, + auth_data: dict[str, Any], + max_users: int | None = None, + filter: str | None = "accountEnabled eq true", + search: str | None = None, +) -> GetOrganizationUsersOutput: + """List all users in the organization. By default returns only enabled accounts.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return GetOrganizationUsersOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + if search: + headers["ConsistencyLevel"] = "eventual" + params: dict[str, str] = {} + if filter: + params["$filter"] = filter + if search: + params["$search"] = search + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + values = await _collect_odata_values( + client, + f"{_BASE_URL}/users", + headers, + params=params if params else None, + max_items=max_users, + ) + except httpx.HTTPStatusError as exc: + return GetOrganizationUsersOutput( + success=False, + error=f"API error ({exc.response.status_code}): {exc.response.text}", + ) + except httpx.TimeoutException: + return GetOrganizationUsersOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetOrganizationUsersOutput(success=False, error=f"Call failed: {exc}") + users = [ + UserSummary( + id=u.get("id"), + full_name=u.get("displayName"), + email=u.get("mail"), + user_principal_name=u.get("userPrincipalName"), + surname=u.get("surname"), + given_name=u.get("givenName"), + job_title=u.get("jobTitle"), + mobile_phone=u.get("mobilePhone"), + ) + for u in values + ] + return GetOrganizationUsersOutput(success=True, users=users) + + +@tool(args_schema=GetProfileInput) +@serialize_pydantic_return +async def get_profile( + auth_type: str, + auth_data: dict[str, Any], + user_id: str | None = None, +) -> GetProfileOutput: + """Get the user's profile information from Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return GetProfileOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + path = f"/users/{user_id}" if user_id else "/me" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}{path}", + headers=headers, + ) + if response.status_code != 200: + return GetProfileOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetProfileOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetProfileOutput(success=False, error=f"Call failed: {exc}") + return GetProfileOutput(success=True, data=data) + + +@tool(args_schema=RemoveMemberFromGroupInput) +@serialize_pydantic_return +async def remove_member_from_group( + auth_type: str, + auth_data: dict[str, Any], + group_id: str, + user_id: str, +) -> RemoveMemberFromGroupOutput: + """Remove a member from a Microsoft Entra ID group.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return RemoveMemberFromGroupOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{_BASE_URL}/groups/{group_id}/members/{user_id}/$ref", + headers=headers, + ) + if response.status_code not in (200, 204): + return RemoveMemberFromGroupOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return RemoveMemberFromGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return RemoveMemberFromGroupOutput(success=False, error=f"Call failed: {exc}") + return RemoveMemberFromGroupOutput(success=True) + + +@tool(args_schema=SearchGroupsInput) +@serialize_pydantic_return +async def search_groups( + auth_type: str, + auth_data: dict[str, Any], + query: str, + max_results: int = 100, +) -> SearchGroupsOutput: + """Search for groups by name or description in Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return SearchGroupsOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + headers["ConsistencyLevel"] = "eventual" + params = { + "$search": f'"displayName:{query}" OR "description:{query}"', + "$top": str(max_results), + } + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/groups", + headers=headers, + params=params, + ) + if response.status_code != 200: + return SearchGroupsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchGroupsOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchGroupsOutput(success=False, error=f"Call failed: {exc}") + groups = [ + GroupSummary( + id=g.get("id"), + display_name=g.get("displayName"), + description=g.get("description"), + mail_enabled=g.get("mailEnabled"), + security_enabled=g.get("securityEnabled"), + group_types=g.get("groupTypes", []), + ) + for g in data.get("value", []) + ] + return SearchGroupsOutput(success=True, groups=groups) + + +@tool(args_schema=UpdateGroupInput) +@serialize_pydantic_return +async def update_group( + auth_type: str, + auth_data: dict[str, Any], + group_id: str, + allow_external_senders: bool | None = None, + auto_subscribe_new_members: bool | None = None, + description: str | None = None, + display_name: str | None = None, + mail_nickname: str | None = None, + security_enabled: bool | None = None, + visibility: str | None = None, +) -> UpdateGroupOutput: + """Update an existing group in Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return UpdateGroupOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + body: dict[str, Any] = {} + if allow_external_senders is not None: + body["allowExternalSenders"] = allow_external_senders + if auto_subscribe_new_members is not None: + body["autoSubscribeNewMembers"] = auto_subscribe_new_members + if description is not None: + body["description"] = description + if display_name is not None: + body["displayName"] = display_name + if mail_nickname is not None: + body["mailNickname"] = mail_nickname + if security_enabled is not None: + body["securityEnabled"] = security_enabled + if visibility is not None: + body["visibility"] = visibility + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/groups/{group_id}", + headers=headers, + json=body, + ) + if response.status_code not in (200, 204): + return UpdateGroupOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UpdateGroupOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateGroupOutput(success=False, error=f"Call failed: {exc}") + return UpdateGroupOutput(success=True) + + +@tool(args_schema=UpdateUserInput) +@serialize_pydantic_return +async def update_user( + auth_type: str, + auth_data: dict[str, Any], + user_id: str, + display_name: str | None = None, + mail: str | None = None, + mail_nickname: str | None = None, + account_enabled: bool | None = True, + street_address: str | None = None, + city: str | None = None, + state: str | None = None, + postal_code: str | None = None, + country: str | None = None, +) -> UpdateUserOutput: + """Update an existing user in Microsoft Entra ID.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return UpdateUserOutput(success=False, error="Missing access token. Please re-authenticate.") + headers = _get_auth_headers(auth_type, auth_data) + field_map: dict[str, tuple[str, Any]] = { + "displayName": ("display_name", display_name), + "mail": ("mail", mail), + "mailNickname": ("mail_nickname", mail_nickname), + "accountEnabled": ("account_enabled", account_enabled), + "streetAddress": ("street_address", street_address), + "city": ("city", city), + "state": ("state", state), + "postalCode": ("postal_code", postal_code), + "country": ("country", country), + } + body: dict[str, Any] = {} + for graph_key, (_, value) in field_map.items(): + if value is not None: + body[graph_key] = value + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/users/{user_id}", + headers=headers, + json=body, + ) + if response.status_code not in (200, 204): + return UpdateUserOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UpdateUserOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateUserOutput(success=False, error=f"Call failed: {exc}") + return UpdateUserOutput(success=True) diff --git a/src/modulex_integrations/tools/netlify/README.md b/src/modulex_integrations/tools/netlify/README.md new file mode 100644 index 0000000..9aa9bf1 --- /dev/null +++ b/src/modulex_integrations/tools/netlify/README.md @@ -0,0 +1,35 @@ +# Netlify + +Web hosting and automation platform for modern web projects, interfacing with the Netlify REST API (`api.netlify.com/api/v1`). + +## Authentication + +### OAuth2 Authentication + +- Register an OAuth application at . +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required env vars (only when using your own OAuth app): + - `NETLIFY_OAUTH2_CLIENT_ID` — your OAuth App Client ID + - `NETLIFY_OAUTH2_CLIENT_SECRET` — your OAuth App Client Secret +- Netlify OAuth does not use granular scopes; the access token grants full account access. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `get_site` | Get a specified site by its ID | `site_id` | +| `list_files` | Returns a list of all the files in the current deploy for a site | `site_id` | +| `list_site_deploys` | Returns a list of all deploys for a specific site | `site_id` | +| `rollback_deploy` | Restores an old deploy and makes it the live version of the site | `site_id`, `deploy_id` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- Netlify API rate limit: 500 requests per minute per access token (as documented by Netlify). +- Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. +- Deploy operations (rollback) may take a few seconds to propagate. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/netlify/__init__.py b/src/modulex_integrations/tools/netlify/__init__.py new file mode 100644 index 0000000..fff8aa0 --- /dev/null +++ b/src/modulex_integrations/tools/netlify/__init__.py @@ -0,0 +1,24 @@ +"""Netlify integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.netlify.manifest import manifest +from modulex_integrations.tools.netlify.tools import ( + get_site, + list_files, + list_site_deploys, + rollback_deploy, +) + +TOOLS = ( + get_site, + list_files, + list_site_deploys, + rollback_deploy, +) + +__all__ = [ + "TOOLS", + "get_site", + "list_files", + "list_site_deploys", + "manifest", + "rollback_deploy", +] diff --git a/src/modulex_integrations/tools/netlify/dependencies.toml b/src/modulex_integrations/tools/netlify/dependencies.toml new file mode 100644 index 0000000..fad22bc --- /dev/null +++ b/src/modulex_integrations/tools/netlify/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the netlify integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/netlify/manifest.py b/src/modulex_integrations/tools/netlify/manifest.py new file mode 100644 index 0000000..e959827 --- /dev/null +++ b/src/modulex_integrations/tools/netlify/manifest.py @@ -0,0 +1,130 @@ +"""Netlify integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="netlify", + display_name="Netlify", + description="Web hosting and automation platform for modern web projects", + version="1.0.0", + author="ModuleX", + logo="modulex:netlify-themed", + app_url="https://www.netlify.com", + categories=["Developer Tools & Infrastructure", "hosting", "ci-cd"], + actions=[ + ActionDefinition( + name="get_site", + description="Get a specified site by its ID", + parameters={ + "site_id": ParameterDef( + type="string", + description="The Netlify site ID to retrieve information for", + required=True, + ), + }, + ), + ActionDefinition( + name="list_files", + description="Returns a list of all the files in the current deploy for a site", + parameters={ + "site_id": ParameterDef( + type="string", + description="The Netlify site ID to list files for", + required=True, + ), + }, + ), + ActionDefinition( + name="list_site_deploys", + description="Returns a list of all deploys for a specific site", + parameters={ + "site_id": ParameterDef( + type="string", + description="The Netlify site ID to list deploys for", + required=True, + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of deploys to return", + default=None, + ), + "max_pages": ParameterDef( + type="integer", + description="Maximum number of pages to fetch (1-500)", + default=50, + ), + }, + ), + ActionDefinition( + name="rollback_deploy", + description="Restores an old deploy and makes it the live version of the site", + parameters={ + "site_id": ParameterDef( + type="string", + description="The Netlify site ID to rollback a deploy for", + required=True, + ), + "deploy_id": ParameterDef( + type="string", + description="The deploy ID to restore", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Netlify OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="NETLIFY_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Netlify OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + about_url="https://app.netlify.com/user/applications", + ), + EnvVar( + name="NETLIFY_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Netlify OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + about_url="https://app.netlify.com/user/applications", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://app.netlify.com/authorize", + token_url="https://api.netlify.com/oauth/token", + scopes=[], + ), + test_endpoint=TestEndpoint( + url="https://api.netlify.com/api/v1/user", + method="GET", + headers={"Authorization": "Bearer {access_token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["id"], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user info", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/netlify/outputs.py b/src/modulex_integrations/tools/netlify/outputs.py new file mode 100644 index 0000000..da1f4ff --- /dev/null +++ b/src/modulex_integrations/tools/netlify/outputs.py @@ -0,0 +1,58 @@ +"""Pydantic response models for the netlify integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "GetSiteOutput", + "ListFilesOutput", + "ListSiteDeploysOutput", + "RollbackDeployOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class GetSiteOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + name: str | None = None + url: str | None = None + ssl_url: str | None = None + admin_url: str | None = None + state: str | None = None + created_at: str | None = None + updated_at: str | None = None + default_domain: str | None = None + custom_domain: str | None = None + + +class ListFilesOutput(_Base): + success: bool + error: str | None = None + files: list[dict[str, Any]] = Field(default_factory=list) + + +class ListSiteDeploysOutput(_Base): + success: bool + error: str | None = None + deploys: list[dict[str, Any]] = Field(default_factory=list) + + +class RollbackDeployOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + state: str | None = None + name: str | None = None + url: str | None = None + ssl_url: str | None = None + created_at: str | None = None + updated_at: str | None = None diff --git a/src/modulex_integrations/tools/netlify/tests/__init__.py b/src/modulex_integrations/tools/netlify/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/netlify/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/netlify/tests/test_netlify.py b/src/modulex_integrations/tools/netlify/tests/test_netlify.py new file mode 100644 index 0000000..2a1071d --- /dev/null +++ b/src/modulex_integrations/tools/netlify/tests/test_netlify.py @@ -0,0 +1,163 @@ +"""Happy-path tests for every netlify @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.netlify import ( + TOOLS, + get_site, + list_files, + list_site_deploys, + manifest, + rollback_deploy, +) +from modulex_integrations.tools.netlify.outputs import ( + GetSiteOutput, + ListFilesOutput, + ListSiteDeploysOutput, + RollbackDeployOutput, +) + +API = "https://api.netlify.com/api/v1" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity ---------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_4_actions(self) -> None: + assert len(manifest.actions) == 4 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ---------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_site(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sites/test-site-id", + json={ + # TODO: fill in a representative response shape from the Netlify API docs + "id": "test-site-id", + "name": "my-site", + "url": "https://my-site.netlify.app", + "ssl_url": "https://my-site.netlify.app", + "admin_url": "https://app.netlify.com/sites/my-site", + "state": "ready", + "created_at": "2023-01-01T00:00:00Z", + "updated_at": "2023-06-01T00:00:00Z", + "default_domain": "my-site.netlify.app", + "custom_domain": None, + }, + ) + + result_dict = await get_site.ainvoke(_args(site_id="test-site-id")) + + assert isinstance(result_dict, dict) + result = GetSiteOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "my-site" + assert result.id == "test-site-id" + + +@pytest.mark.asyncio +async def test_list_files(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sites/test-site-id/files", + json=[ + # TODO: fill in a representative response shape from the Netlify API docs + {"id": "/index.html", "path": "/index.html", "size": 1234}, + {"id": "/style.css", "path": "/style.css", "size": 567}, + ], + ) + + result_dict = await list_files.ainvoke(_args(site_id="test-site-id")) + + assert isinstance(result_dict, dict) + result = ListFilesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.files) == 2 + + +@pytest.mark.asyncio +async def test_list_site_deploys(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/sites/test-site-id/deploys?page=1&per_page=100", + json=[ + # TODO: fill in a representative response shape from the Netlify API docs + {"id": "deploy-1", "state": "ready", "created_at": "2023-06-01T00:00:00Z"}, + {"id": "deploy-2", "state": "ready", "created_at": "2023-05-01T00:00:00Z"}, + ], + ) + + result_dict = await list_site_deploys.ainvoke(_args(site_id="test-site-id")) + + assert isinstance(result_dict, dict) + result = ListSiteDeploysOutput.model_validate(result_dict) + assert result.success is True + assert len(result.deploys) == 2 + + +@pytest.mark.asyncio +async def test_rollback_deploy(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/sites/test-site-id/deploys/deploy-1/restore", + json={ + # TODO: fill in a representative response shape from the Netlify API docs + "id": "deploy-1", + "state": "ready", + "name": "my-site", + "url": "https://my-site.netlify.app", + "ssl_url": "https://my-site.netlify.app", + "created_at": "2023-06-01T00:00:00Z", + "updated_at": "2023-06-01T12:00:00Z", + }, + ) + + result_dict = await rollback_deploy.ainvoke( + _args(site_id="test-site-id", deploy_id="deploy-1") + ) + + assert isinstance(result_dict, dict) + result = RollbackDeployOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "deploy-1" + assert result.state == "ready" + + +# --- Failure-path tests -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_site_empty_credentials() -> None: + """Verify that empty credentials return a structured error, not a network call.""" + result_dict = await get_site.ainvoke( + _args(auth_data={}, site_id="test-site-id") + ) + assert isinstance(result_dict, dict) + result = GetSiteOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access_token" in result.error.lower() diff --git a/src/modulex_integrations/tools/netlify/tools.py b/src/modulex_integrations/tools/netlify/tools.py new file mode 100644 index 0000000..45d8d1a --- /dev/null +++ b/src/modulex_integrations/tools/netlify/tools.py @@ -0,0 +1,245 @@ +"""Netlify LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.netlify.outputs import ( + GetSiteOutput, + ListFilesOutput, + ListSiteDeploysOutput, + RollbackDeployOutput, +) + +__all__ = [ + "get_site", + "list_files", + "list_site_deploys", + "rollback_deploy", +] + +_BASE_URL = "https://api.netlify.com/api/v1" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Netlify API based on auth_type/auth_data.""" + headers: dict[str, str] = {"Accept": "application/json"} + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas ------------------------------------------------------------ + + +class GetSiteInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + site_id: str = Field(description="The Netlify site ID to retrieve information for") + + +class ListFilesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + site_id: str = Field(description="The Netlify site ID to list files for") + + +class ListSiteDeploysInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + site_id: str = Field(description="The Netlify site ID to list deploys for") + max_results: int | None = Field( + default=None, description="Maximum number of deploys to return" + ) + max_pages: int = Field( + default=50, + description="Maximum number of pages to fetch (1-500)", + ge=1, + le=500, + ) + + +class RollbackDeployInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + site_id: str = Field(description="The Netlify site ID to rollback a deploy for") + deploy_id: str = Field(description="The deploy ID to restore") + + +# --- @tool functions ---------------------------------------------------------- + + +@tool(args_schema=GetSiteInput) +@serialize_pydantic_return +async def get_site( + auth_type: str, + auth_data: dict[str, Any], + site_id: str, +) -> GetSiteOutput: + """Get a specified site by its ID.""" + if not auth_data.get("access_token"): + return GetSiteOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/sites/{site_id}", + headers=headers, + ) + if response.status_code != 200: + return GetSiteOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetSiteOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetSiteOutput(success=False, error=f"Call failed: {exc}") + + return GetSiteOutput( + success=True, + id=data.get("id"), + name=data.get("name"), + url=data.get("url"), + ssl_url=data.get("ssl_url"), + admin_url=data.get("admin_url"), + state=data.get("state"), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + default_domain=data.get("default_domain"), + custom_domain=data.get("custom_domain"), + ) + + +@tool(args_schema=ListFilesInput) +@serialize_pydantic_return +async def list_files( + auth_type: str, + auth_data: dict[str, Any], + site_id: str, +) -> ListFilesOutput: + """Returns a list of all the files in the current deploy for a site.""" + if not auth_data.get("access_token"): + return ListFilesOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/sites/{site_id}/files", + headers=headers, + ) + if response.status_code != 200: + return ListFilesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListFilesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListFilesOutput(success=False, error=f"Call failed: {exc}") + + return ListFilesOutput(success=True, files=data if isinstance(data, list) else []) + + +@tool(args_schema=ListSiteDeploysInput) +@serialize_pydantic_return +async def list_site_deploys( + auth_type: str, + auth_data: dict[str, Any], + site_id: str, + max_results: int | None = None, + max_pages: int = 50, +) -> ListSiteDeploysOutput: + """Returns a list of all deploys for a specific site.""" + if not auth_data.get("access_token"): + return ListSiteDeploysOutput( + success=False, error="Missing or empty access_token in auth_data." + ) + headers = _get_auth_headers(auth_type, auth_data) + try: + all_deploys: list[dict[str, Any]] = [] + page = 1 + per_page = 100 + pages_seen = 0 + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while pages_seen < max_pages: + pages_seen += 1 + params: dict[str, Any] = {"page": page, "per_page": per_page} + response = await client.get( + f"{_BASE_URL}/sites/{site_id}/deploys", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListSiteDeploysOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + batch = response.json() + if not isinstance(batch, list) or not batch: + break + all_deploys.extend(batch) + if max_results and len(all_deploys) >= max_results: + all_deploys = all_deploys[:max_results] + break + if len(batch) < per_page: + break + page += 1 + except httpx.TimeoutException: + return ListSiteDeploysOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListSiteDeploysOutput(success=False, error=f"Call failed: {exc}") + + return ListSiteDeploysOutput(success=True, deploys=all_deploys) + + +@tool(args_schema=RollbackDeployInput) +@serialize_pydantic_return +async def rollback_deploy( + auth_type: str, + auth_data: dict[str, Any], + site_id: str, + deploy_id: str, +) -> RollbackDeployOutput: + """Restores an old deploy and makes it the live version of the site.""" + if not auth_data.get("access_token"): + return RollbackDeployOutput( + success=False, error="Missing or empty access_token in auth_data." + ) + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/sites/{site_id}/deploys/{deploy_id}/restore", + headers=headers, + ) + if response.status_code not in (200, 201): + return RollbackDeployOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return RollbackDeployOutput(success=False, error="Request timed out.") + except Exception as exc: + return RollbackDeployOutput(success=False, error=f"Call failed: {exc}") + + return RollbackDeployOutput( + success=True, + id=data.get("id"), + state=data.get("state"), + name=data.get("name"), + url=data.get("url"), + ssl_url=data.get("ssl_url"), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + ) diff --git a/src/modulex_integrations/tools/pagerduty/README.md b/src/modulex_integrations/tools/pagerduty/README.md new file mode 100644 index 0000000..0d01d00 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/README.md @@ -0,0 +1,35 @@ +# PagerDuty + +Incident management, on-call scheduling, and alerting via the PagerDuty REST API (`api.pagerduty.com`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth app at the [PagerDuty Developer Console](https://developer.pagerduty.com/docs/app-integration-development/). +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Scopes requested: `read`, `write` +- Env vars (only for custom OAuth app): + - `PAGERDUTY_OAUTH2_CLIENT_ID` — your OAuth App Client ID + - `PAGERDUTY_OAUTH2_CLIENT_SECRET` — your OAuth App Client Secret + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `trigger_incident` | Trigger a new incident on a PagerDuty service | `title`, `service_id` | +| `acknowledge_incident` | Acknowledge a triggered incident in PagerDuty | `incident_id` | +| `resolve_incident` | Resolve a triggered or acknowledged incident in PagerDuty | `incident_id` | +| `find_oncall_user` | Find the user on call for a specific PagerDuty schedule | `schedule_id`, `user_id` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- **Rate limits**: PagerDuty REST API allows up to 960 requests per minute (varies by account tier). +- **Throttling**: Requests exceeding the rate limit receive HTTP 429; implement client-side backoff. +- **Error model**: non-2xx responses raise `httpx.HTTPStatusError` (Pattern A). The caller should handle retries. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/pagerduty/__init__.py b/src/modulex_integrations/tools/pagerduty/__init__.py new file mode 100644 index 0000000..3415f1d --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/__init__.py @@ -0,0 +1,24 @@ +"""PagerDuty integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.pagerduty.manifest import manifest +from modulex_integrations.tools.pagerduty.tools import ( + acknowledge_incident, + find_oncall_user, + resolve_incident, + trigger_incident, +) + +TOOLS = ( + trigger_incident, + acknowledge_incident, + resolve_incident, + find_oncall_user, +) + +__all__ = [ + "TOOLS", + "acknowledge_incident", + "find_oncall_user", + "manifest", + "resolve_incident", + "trigger_incident", +] diff --git a/src/modulex_integrations/tools/pagerduty/dependencies.toml b/src/modulex_integrations/tools/pagerduty/dependencies.toml new file mode 100644 index 0000000..328ccc5 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the pagerduty integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/pagerduty/manifest.py b/src/modulex_integrations/tools/pagerduty/manifest.py new file mode 100644 index 0000000..59daa75 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/manifest.py @@ -0,0 +1,156 @@ +"""PagerDuty integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="pagerduty", + display_name="PagerDuty", + description="Incident management and on-call scheduling platform", + version="1.0.0", + author="ModuleX", + logo="modulex:pagerduty-themed", + app_url="https://www.pagerduty.com", + categories=["Incident Management", "Developer Tools & Infrastructure"], + actions=[ + ActionDefinition( + name="trigger_incident", + description="Trigger a new incident on a PagerDuty service", + parameters={ + "title": ParameterDef( + type="string", + description="A succinct description of the nature, symptoms, cause, or effect of the incident", + required=True, + ), + "service_id": ParameterDef( + type="string", + description="The ID of the PagerDuty service to trigger the incident on", + required=True, + ), + "urgency": ParameterDef( + type="string", + description="The urgency of the incident: high or low", + ), + "body_details": ParameterDef( + type="string", + description="Additional incident details", + ), + "incident_key": ParameterDef( + type="string", + description="A string which identifies the incident. Subsequent requests with the same key and service will be rejected if an open incident matches", + ), + "escalation_policy_id": ParameterDef( + type="string", + description="The ID of the escalation policy to assign", + ), + "assignee_ids": ParameterDef( + type="array", + description="List of user IDs to assign to the incident", + ), + "conference_bridge_number": ParameterDef( + type="string", + description="Phone number for the conference bridge (format: +1 415-555-1212,,,,1234#)", + ), + "conference_bridge_url": ParameterDef( + type="string", + description="URL for the conference bridge (e.g. a web conference or Slack channel link)", + ), + }, + ), + ActionDefinition( + name="acknowledge_incident", + description="Acknowledge a triggered incident in PagerDuty", + parameters={ + "incident_id": ParameterDef( + type="string", + description="The ID of the incident to acknowledge", + required=True, + ), + }, + ), + ActionDefinition( + name="resolve_incident", + description="Resolve a triggered or acknowledged incident in PagerDuty", + parameters={ + "incident_id": ParameterDef( + type="string", + description="The ID of the incident to resolve", + required=True, + ), + }, + ), + ActionDefinition( + name="find_oncall_user", + description="Find the user on call for a specific PagerDuty schedule", + parameters={ + "schedule_id": ParameterDef( + type="string", + description="The ID of the on-call schedule", + required=True, + ), + "user_id": ParameterDef( + type="string", + description="The ID of the user to search for in the schedule", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using PagerDuty OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="PAGERDUTY_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="PagerDuty OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + about_url="https://developer.pagerduty.com/docs/app-integration-development/", + ), + EnvVar( + name="PAGERDUTY_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="PagerDuty OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + about_url="https://developer.pagerduty.com/docs/app-integration-development/", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://app.pagerduty.com/oauth/authorize", + token_url="https://app.pagerduty.com/oauth/token", + scopes=["read", "write"], + ), + test_endpoint=TestEndpoint( + url="https://api.pagerduty.com/users/me", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + "Content-Type": "application/json", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["user"], + ), + cost_level="free", + description="Validates OAuth token by fetching the authenticated user", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/pagerduty/outputs.py b/src/modulex_integrations/tools/pagerduty/outputs.py new file mode 100644 index 0000000..9cc6231 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/outputs.py @@ -0,0 +1,75 @@ +"""Pydantic response models for the pagerduty integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AcknowledgeIncidentOutput", + "FindOncallUserOutput", + "IncidentSummary", + "OncallUser", + "ResolveIncidentOutput", + "TriggerIncidentOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class IncidentSummary(_Base): + """Core fields returned for an incident by the PagerDuty API.""" + + id: str | None = None + type: str | None = None + summary: str | None = None + status: str | None = None + title: str | None = None + urgency: str | None = None + incident_key: str | None = None + html_url: str | None = None + created_at: str | None = None + service: dict[str, Any] | None = None + escalation_policy: dict[str, Any] | None = None + assignments: list[dict[str, Any]] = Field(default_factory=list) + + +class OncallUser(_Base): + """A user found on-call for a schedule.""" + + id: str | None = None + name: str | None = None + email: str | None = None + type: str | None = None + html_url: str | None = None + + +# --- Per-action output models ---------------------------------------------- + + +class TriggerIncidentOutput(_Base): + success: bool + incident: IncidentSummary | None = None + + +class AcknowledgeIncidentOutput(_Base): + success: bool + incident: IncidentSummary | None = None + + +class ResolveIncidentOutput(_Base): + success: bool + incident: IncidentSummary | None = None + + +class FindOncallUserOutput(_Base): + success: bool + found: bool = False + user: OncallUser | None = None diff --git a/src/modulex_integrations/tools/pagerduty/tests/__init__.py b/src/modulex_integrations/tools/pagerduty/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/pagerduty/tests/test_pagerduty.py b/src/modulex_integrations/tools/pagerduty/tests/test_pagerduty.py new file mode 100644 index 0000000..bcfb971 --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/tests/test_pagerduty.py @@ -0,0 +1,191 @@ +"""Happy-path tests for every pagerduty @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.pagerduty import ( + TOOLS, + acknowledge_incident, + find_oncall_user, + manifest, + resolve_incident, + trigger_incident, +) +from modulex_integrations.tools.pagerduty.outputs import ( + AcknowledgeIncidentOutput, + FindOncallUserOutput, + ResolveIncidentOutput, + TriggerIncidentOutput, +) + +API = "https://api.pagerduty.com" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_4_actions(self) -> None: + assert len(manifest.actions) == 4 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_trigger_incident(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/incidents", + json={ + # TODO: fill in a representative response shape from the PagerDuty API docs + "incident": { + "id": "PT4KHLK", + "type": "incident", + "summary": "[#1234] Test incident", + "status": "triggered", + "title": "Test incident", + "urgency": "high", + "incident_key": "test-key-123", + "html_url": "https://subdomain.pagerduty.com/incidents/PT4KHLK", + "created_at": "2024-01-01T00:00:00Z", + "service": {"id": "PSERVICE", "type": "service_reference"}, + "escalation_policy": {"id": "PPOLICY", "type": "escalation_policy_reference"}, + "assignments": [], + }, + }, + ) + + result_dict = await trigger_incident.ainvoke( + _args(title="Test incident", service_id="PSERVICE") + ) + + assert isinstance(result_dict, dict) + result = TriggerIncidentOutput.model_validate(result_dict) + assert result.success is True + assert result.incident is not None + assert result.incident.id == "PT4KHLK" + assert result.incident.status == "triggered" + + +@pytest.mark.asyncio +async def test_acknowledge_incident(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/incidents/PT4KHLK", + json={ + # TODO: fill in a representative response shape from the PagerDuty API docs + "incident": { + "id": "PT4KHLK", + "type": "incident", + "summary": "[#1234] Test incident", + "status": "acknowledged", + "title": "Test incident", + "html_url": "https://subdomain.pagerduty.com/incidents/PT4KHLK", + "created_at": "2024-01-01T00:00:00Z", + "service": {"id": "PSERVICE", "type": "service_reference"}, + "escalation_policy": {"id": "PPOLICY", "type": "escalation_policy_reference"}, + "assignments": [], + }, + }, + ) + + result_dict = await acknowledge_incident.ainvoke( + _args(incident_id="PT4KHLK") + ) + + assert isinstance(result_dict, dict) + result = AcknowledgeIncidentOutput.model_validate(result_dict) + assert result.success is True + assert result.incident is not None + assert result.incident.status == "acknowledged" + + +@pytest.mark.asyncio +async def test_resolve_incident(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/incidents/PT4KHLK", + json={ + # TODO: fill in a representative response shape from the PagerDuty API docs + "incident": { + "id": "PT4KHLK", + "type": "incident", + "summary": "[#1234] Test incident", + "status": "resolved", + "title": "Test incident", + "html_url": "https://subdomain.pagerduty.com/incidents/PT4KHLK", + "created_at": "2024-01-01T00:00:00Z", + "service": {"id": "PSERVICE", "type": "service_reference"}, + "escalation_policy": {"id": "PPOLICY", "type": "escalation_policy_reference"}, + "assignments": [], + }, + }, + ) + + result_dict = await resolve_incident.ainvoke( + _args(incident_id="PT4KHLK") + ) + + assert isinstance(result_dict, dict) + result = ResolveIncidentOutput.model_validate(result_dict) + assert result.success is True + assert result.incident is not None + assert result.incident.status == "resolved" + + +@pytest.mark.asyncio +async def test_find_oncall_user(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/schedules/PSCHED1/users", + json={ + # TODO: fill in a representative response shape from the PagerDuty API docs + "users": [ + { + "id": "PUSER1", + "name": "Jane Doe", + "email": "jane@example.com", + "type": "user", + "html_url": "https://subdomain.pagerduty.com/users/PUSER1", + }, + { + "id": "PUSER2", + "name": "John Smith", + "email": "john@example.com", + "type": "user", + "html_url": "https://subdomain.pagerduty.com/users/PUSER2", + }, + ], + }, + ) + + result_dict = await find_oncall_user.ainvoke( + _args(schedule_id="PSCHED1", user_id="PUSER1") + ) + + assert isinstance(result_dict, dict) + result = FindOncallUserOutput.model_validate(result_dict) + assert result.success is True + assert result.found is True + assert result.user is not None + assert result.user.id == "PUSER1" + assert result.user.name == "Jane Doe" diff --git a/src/modulex_integrations/tools/pagerduty/tools.py b/src/modulex_integrations/tools/pagerduty/tools.py new file mode 100644 index 0000000..ed22d2c --- /dev/null +++ b/src/modulex_integrations/tools/pagerduty/tools.py @@ -0,0 +1,301 @@ +"""PagerDuty LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.pagerduty.outputs import ( + AcknowledgeIncidentOutput, + FindOncallUserOutput, + IncidentSummary, + OncallUser, + ResolveIncidentOutput, + TriggerIncidentOutput, +) + +__all__ = [ + "acknowledge_incident", + "find_oncall_user", + "resolve_incident", + "trigger_incident", +] + +_BASE_URL = "https://api.pagerduty.com" + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the PagerDuty API based on auth_type/auth_data.""" + headers: dict[str, str] = { + "Accept": "application/json", + "Content-Type": "application/json", + } + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class TriggerIncidentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + title: str = Field(description="A succinct description of the nature, symptoms, cause, or effect of the incident") + service_id: str = Field(description="The ID of the PagerDuty service to trigger the incident on") + urgency: str | None = Field(default=None, description="The urgency of the incident: high or low") + body_details: str | None = Field(default=None, description="Additional incident details") + incident_key: str | None = Field(default=None, description="A string which identifies the incident") + escalation_policy_id: str | None = Field(default=None, description="The ID of the escalation policy to assign") + assignee_ids: list[str] | None = Field(default=None, description="List of user IDs to assign to the incident") + conference_bridge_number: str | None = Field(default=None, description="Phone number for the conference bridge") + conference_bridge_url: str | None = Field(default=None, description="URL for the conference bridge") + + +class AcknowledgeIncidentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + incident_id: str = Field(description="The ID of the incident to acknowledge") + + +class ResolveIncidentInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + incident_id: str = Field(description="The ID of the incident to resolve") + + +class FindOncallUserInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + schedule_id: str = Field(description="The ID of the on-call schedule") + user_id: str = Field(description="The ID of the user to search for in the schedule") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=TriggerIncidentInput) +@serialize_pydantic_return +async def trigger_incident( + auth_type: str, + auth_data: dict[str, Any], + title: str, + service_id: str, + urgency: str | None = None, + body_details: str | None = None, + incident_key: str | None = None, + escalation_policy_id: str | None = None, + assignee_ids: list[str] | None = None, + conference_bridge_number: str | None = None, + conference_bridge_url: str | None = None, +) -> TriggerIncidentOutput: + """Trigger a new incident on a PagerDuty service.""" + if not auth_data.get("access_token"): + return TriggerIncidentOutput(success=False) + headers = _get_auth_headers(auth_type, auth_data) + + incident_body: dict[str, Any] = { + "type": "incident", + "title": title, + "service": { + "id": service_id, + "type": "service_reference", + }, + } + + if urgency: + incident_body["urgency"] = urgency + + if body_details: + incident_body["body"] = { + "type": "incident_body", + "details": body_details, + } + + if incident_key: + incident_body["incident_key"] = incident_key + + if escalation_policy_id: + incident_body["escalation_policy"] = { + "id": escalation_policy_id, + "type": "escalation_policy_reference", + } + + if assignee_ids: + incident_body["assignments"] = [ + {"assignee": {"id": uid, "type": "user_reference"}} + for uid in assignee_ids + ] + + if conference_bridge_number or conference_bridge_url: + conference_bridge: dict[str, str] = {} + if conference_bridge_number: + conference_bridge["conference_number"] = conference_bridge_number + if conference_bridge_url: + conference_bridge["conference_url"] = conference_bridge_url + incident_body["conference_bridge"] = conference_bridge + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{_BASE_URL}/incidents", + headers=headers, + json={"incident": incident_body}, + ) + response.raise_for_status() + data = response.json() + + inc = data.get("incident", {}) + return TriggerIncidentOutput( + success=True, + incident=IncidentSummary( + id=inc.get("id"), + type=inc.get("type"), + summary=inc.get("summary"), + status=inc.get("status"), + title=inc.get("title"), + urgency=inc.get("urgency"), + incident_key=inc.get("incident_key"), + html_url=inc.get("html_url"), + created_at=inc.get("created_at"), + service=inc.get("service"), + escalation_policy=inc.get("escalation_policy"), + assignments=inc.get("assignments", []), + ), + ) + + +@tool(args_schema=AcknowledgeIncidentInput) +@serialize_pydantic_return +async def acknowledge_incident( + auth_type: str, + auth_data: dict[str, Any], + incident_id: str, +) -> AcknowledgeIncidentOutput: + """Acknowledge a triggered incident in PagerDuty.""" + if not auth_data.get("access_token"): + return AcknowledgeIncidentOutput(success=False) + headers = _get_auth_headers(auth_type, auth_data) + + async with httpx.AsyncClient() as client: + response = await client.put( + f"{_BASE_URL}/incidents/{incident_id}", + headers=headers, + json={ + "incident": { + "type": "incident_reference", + "status": "acknowledged", + }, + }, + ) + response.raise_for_status() + data = response.json() + + inc = data.get("incident", {}) + return AcknowledgeIncidentOutput( + success=True, + incident=IncidentSummary( + id=inc.get("id"), + type=inc.get("type"), + summary=inc.get("summary"), + status=inc.get("status"), + title=inc.get("title"), + urgency=inc.get("urgency"), + incident_key=inc.get("incident_key"), + html_url=inc.get("html_url"), + created_at=inc.get("created_at"), + service=inc.get("service"), + escalation_policy=inc.get("escalation_policy"), + assignments=inc.get("assignments", []), + ), + ) + + +@tool(args_schema=ResolveIncidentInput) +@serialize_pydantic_return +async def resolve_incident( + auth_type: str, + auth_data: dict[str, Any], + incident_id: str, +) -> ResolveIncidentOutput: + """Resolve a triggered or acknowledged incident in PagerDuty.""" + if not auth_data.get("access_token"): + return ResolveIncidentOutput(success=False) + headers = _get_auth_headers(auth_type, auth_data) + + async with httpx.AsyncClient() as client: + response = await client.put( + f"{_BASE_URL}/incidents/{incident_id}", + headers=headers, + json={ + "incident": { + "type": "incident_reference", + "status": "resolved", + }, + }, + ) + response.raise_for_status() + data = response.json() + + inc = data.get("incident", {}) + return ResolveIncidentOutput( + success=True, + incident=IncidentSummary( + id=inc.get("id"), + type=inc.get("type"), + summary=inc.get("summary"), + status=inc.get("status"), + title=inc.get("title"), + urgency=inc.get("urgency"), + incident_key=inc.get("incident_key"), + html_url=inc.get("html_url"), + created_at=inc.get("created_at"), + service=inc.get("service"), + escalation_policy=inc.get("escalation_policy"), + assignments=inc.get("assignments", []), + ), + ) + + +@tool(args_schema=FindOncallUserInput) +@serialize_pydantic_return +async def find_oncall_user( + auth_type: str, + auth_data: dict[str, Any], + schedule_id: str, + user_id: str, +) -> FindOncallUserOutput: + """Find the user on call for a specific PagerDuty schedule.""" + if not auth_data.get("access_token"): + return FindOncallUserOutput(success=False) + headers = _get_auth_headers(auth_type, auth_data) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{_BASE_URL}/schedules/{schedule_id}/users", + headers=headers, + ) + response.raise_for_status() + data = response.json() + + users = data.get("users", []) + matched = next((u for u in users if u.get("id") == user_id), None) + + if matched is None: + return FindOncallUserOutput(success=True, found=False) + + return FindOncallUserOutput( + success=True, + found=True, + user=OncallUser( + id=matched.get("id"), + name=matched.get("name"), + email=matched.get("email"), + type=matched.get("type"), + html_url=matched.get("html_url"), + ), + ) diff --git a/src/modulex_integrations/tools/product_hunt/README.md b/src/modulex_integrations/tools/product_hunt/README.md new file mode 100644 index 0000000..a030538 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/README.md @@ -0,0 +1,32 @@ +# Product Hunt + +Discover and explore tech products, topics, and community posts via the Product Hunt GraphQL API (`api.producthunt.com/v2/api/graphql`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth application at . +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Scopes requested: `public`, `private` +- Required env vars (only for custom OAuth apps): + - `PRODUCT_HUNT_OAUTH2_CLIENT_ID` (format: 40-char hex string) + - `PRODUCT_HUNT_OAUTH2_CLIENT_SECRET` (format: 40-char hex string, sensitive) + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `list_topic_options` | Retrieves available topic options with slug and display name | _(none)_ | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- Product Hunt API v2 rate limits are not publicly documented in detail; typical observed limit is approximately 450 requests per 15-minute window per token. +- No per-request pricing; API access is free for authorized applications. +- Error model: non-2xx responses and GraphQL-level errors are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/product_hunt/__init__.py b/src/modulex_integrations/tools/product_hunt/__init__.py new file mode 100644 index 0000000..ced5632 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/__init__.py @@ -0,0 +1,13 @@ +"""Product Hunt integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.product_hunt.manifest import manifest +from modulex_integrations.tools.product_hunt.tools import ( + list_topic_options, +) + +TOOLS = (list_topic_options,) + +__all__ = [ + "TOOLS", + "list_topic_options", + "manifest", +] diff --git a/src/modulex_integrations/tools/product_hunt/dependencies.toml b/src/modulex_integrations/tools/product_hunt/dependencies.toml new file mode 100644 index 0000000..e9f592f --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the product_hunt integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/product_hunt/manifest.py b/src/modulex_integrations/tools/product_hunt/manifest.py new file mode 100644 index 0000000..246b8be --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/manifest.py @@ -0,0 +1,84 @@ +"""Product Hunt integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="product_hunt", + display_name="Product Hunt", + description=( + "Discover and explore tech products, topics, and community posts" + " via the Product Hunt GraphQL API" + ), + version="1.0.0", + author="ModuleX", + logo="modulex:product_hunt-themed", + app_url="https://www.producthunt.com", + categories=["Productivity & Collaboration", "Marketing"], + actions=[ + ActionDefinition( + name="list_topic_options", + description="Retrieves available topic options with slug and display name", + parameters={}, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Product Hunt OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="PRODUCT_HUNT_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Product Hunt OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://www.producthunt.com/v2/oauth/applications", + ), + EnvVar( + name="PRODUCT_HUNT_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Product Hunt OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://www.producthunt.com/v2/oauth/applications", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://api.producthunt.com/v2/oauth/authorize", + token_url="https://api.producthunt.com/v2/oauth/token", + scopes=["public", "private"], + ), + test_endpoint=TestEndpoint( + url="https://api.producthunt.com/v2/api/graphql", + method="POST", + headers={ + "Authorization": "Bearer {access_token}", + "Content-Type": "application/json", + }, + body={"query": "{ viewer { id } }"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates OAuth token by fetching the authenticated user via GraphQL", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/product_hunt/outputs.py b/src/modulex_integrations/tools/product_hunt/outputs.py new file mode 100644 index 0000000..b0db090 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/outputs.py @@ -0,0 +1,34 @@ +"""Pydantic response models for the product_hunt integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "ListTopicOptionsOutput", + "TopicOption", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class TopicOption(_Base): + """A single topic option with slug and display name.""" + + value: str | None = None + label: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class ListTopicOptionsOutput(_Base): + success: bool + error: str | None = None + topics: list[TopicOption] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/product_hunt/tests/__init__.py b/src/modulex_integrations/tools/product_hunt/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/product_hunt/tests/test_product_hunt.py b/src/modulex_integrations/tools/product_hunt/tests/test_product_hunt.py new file mode 100644 index 0000000..0b20c72 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/tests/test_product_hunt.py @@ -0,0 +1,74 @@ +"""Happy-path tests for every product_hunt @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.product_hunt import ( + TOOLS, + list_topic_options, + manifest, +) +from modulex_integrations.tools.product_hunt.outputs import ( + ListTopicOptionsOutput, +) + +API = "https://api.producthunt.com/v2/api/graphql" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_1_action(self) -> None: + assert len(manifest.actions) == 1 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_topic_options(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=API, + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": { + "topics": { + "edges": [ + {"node": {"name": "Artificial Intelligence", + "slug": "artificial-intelligence"}}, + {"node": {"name": "Developer Tools", + "slug": "developer-tools"}}, + ] + } + } + }, + ) + + result_dict = await list_topic_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListTopicOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.topics) == 2 + assert result.topics[0].value == "artificial-intelligence" + assert result.topics[0].label == "Artificial Intelligence" diff --git a/src/modulex_integrations/tools/product_hunt/tools.py b/src/modulex_integrations/tools/product_hunt/tools.py new file mode 100644 index 0000000..34a3926 --- /dev/null +++ b/src/modulex_integrations/tools/product_hunt/tools.py @@ -0,0 +1,103 @@ +"""Product Hunt LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.product_hunt.outputs import ( + ListTopicOptionsOutput, + TopicOption, +) + +__all__ = [ + "list_topic_options", +] + +_BASE_URL = "https://api.producthunt.com/v2/api/graphql" + +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Product Hunt GraphQL API.""" + headers: dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class ListTopicOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +# --- @tool functions ------------------------------------------------------ + +_LIST_TOPICS_QUERY = """\ +query { + topics { + edges { + node { + name + slug + } + } + } +} +""" + + +@tool(args_schema=ListTopicOptionsInput) +@serialize_pydantic_return +async def list_topic_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListTopicOptionsOutput: + """Retrieves available topic options with slug and display name""" + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + _BASE_URL, + headers=headers, + json={"query": _LIST_TOPICS_QUERY}, + ) + if response.status_code != 200: + return ListTopicOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListTopicOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListTopicOptionsOutput(success=False, error=f"Call failed: {exc}") + + errors = data.get("errors") + if errors: + return ListTopicOptionsOutput( + success=False, + error=f"GraphQL error: {errors[0].get('message', str(errors))}", + ) + + edges = (data.get("data") or {}).get("topics", {}).get("edges", []) + topics = [ + TopicOption( + value=(edge.get("node") or {}).get("slug"), + label=(edge.get("node") or {}).get("name"), + ) + for edge in edges + ] + return ListTopicOptionsOutput(success=True, topics=topics) diff --git a/src/modulex_integrations/tools/reflect/README.md b/src/modulex_integrations/tools/reflect/README.md new file mode 100644 index 0000000..3a72cc7 --- /dev/null +++ b/src/modulex_integrations/tools/reflect/README.md @@ -0,0 +1,35 @@ +# Reflect + +Note-taking and knowledge management via the Reflect API (`reflect.app/api`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth app at the [Reflect developer console](https://reflect.app/developer). +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required env vars (only for custom OAuth app): + - `REFLECT_OAUTH2_CLIENT_ID` (Client ID) + - `REFLECT_OAUTH2_CLIENT_SECRET` (Client Secret, sensitive) +- No specific scopes documented by the provider. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `append_daily_note` | Append to a daily note | `graph_id`, `text` | +| `create_link` | Create a new link | `graph_id`, `url` | +| `get_user` | Retrieves information about the authenticated user | | +| `list_graph_id_options` | Retrieves available options for the GraphId field | | +| `list_links` | Retrieve all links for a graph | `graph_id` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- No documented rate limits from the Reflect API. +- Error model: non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/reflect/__init__.py b/src/modulex_integrations/tools/reflect/__init__.py new file mode 100644 index 0000000..46dd73d --- /dev/null +++ b/src/modulex_integrations/tools/reflect/__init__.py @@ -0,0 +1,27 @@ +"""Reflect integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.reflect.manifest import manifest +from modulex_integrations.tools.reflect.tools import ( + append_daily_note, + create_link, + get_user, + list_graph_id_options, + list_links, +) + +TOOLS = ( + append_daily_note, + create_link, + get_user, + list_graph_id_options, + list_links, +) + +__all__ = [ + "TOOLS", + "append_daily_note", + "create_link", + "get_user", + "list_graph_id_options", + "list_links", + "manifest", +] diff --git a/src/modulex_integrations/tools/reflect/dependencies.toml b/src/modulex_integrations/tools/reflect/dependencies.toml new file mode 100644 index 0000000..74a0ea1 --- /dev/null +++ b/src/modulex_integrations/tools/reflect/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the reflect integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/reflect/manifest.py b/src/modulex_integrations/tools/reflect/manifest.py new file mode 100644 index 0000000..1ede7c9 --- /dev/null +++ b/src/modulex_integrations/tools/reflect/manifest.py @@ -0,0 +1,140 @@ +"""Reflect integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="reflect", + display_name="Reflect", + description="Note-taking and knowledge management via the Reflect API", + version="1.0.0", + author="ModuleX", + logo="modulex:reflect-themed", + app_url="https://reflect.app", + categories=["Productivity & Collaboration", "note-taking", "knowledge-management"], + actions=[ + ActionDefinition( + name="append_daily_note", + description="Append to a daily note", + parameters={ + "graph_id": ParameterDef( + type="string", + description="The graph identifier", + required=True, + ), + "text": ParameterDef( + type="string", + description="Text to append to the daily note", + required=True, + ), + "list_name": ParameterDef( + type="string", + description="Name of the list to append to", + ), + "date": ParameterDef( + type="string", + description="Date of the daily note in ISO 8601 format. Defaults to today.", + ), + }, + ), + ActionDefinition( + name="create_link", + description="Create a new link", + parameters={ + "graph_id": ParameterDef( + type="string", + description="The graph identifier", + required=True, + ), + "url": ParameterDef( + type="string", + description="The URL of the link to create", + required=True, + ), + "title": ParameterDef( + type="string", + description="The link title", + ), + "description": ParameterDef( + type="string", + description="The link description", + ), + }, + ), + ActionDefinition( + name="get_user", + description="Retieves information about the authenticated user", + parameters={}, + ), + ActionDefinition( + name="list_graph_id_options", + description="Retrieves available options for the GraphId field", + parameters={}, + ), + ActionDefinition( + name="list_links", + description="Retieve all links for a graph", + parameters={ + "graph_id": ParameterDef( + type="string", + description="The graph identifier", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Reflect OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="REFLECT_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Reflect OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + about_url="https://reflect.app/developer", + ), + EnvVar( + name="REFLECT_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Reflect OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + about_url="https://reflect.app/developer", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://reflect.app/oauth/authorize", + token_url="https://reflect.app/oauth/token", + scopes=[], + ), + test_endpoint=TestEndpoint( + url="https://reflect.app/api/users/me", + method="GET", + headers={"Authorization": "Bearer {access_token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["uid"], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user info", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/reflect/outputs.py b/src/modulex_integrations/tools/reflect/outputs.py new file mode 100644 index 0000000..48a9ac5 --- /dev/null +++ b/src/modulex_integrations/tools/reflect/outputs.py @@ -0,0 +1,65 @@ +"""Pydantic response models for the reflect integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AppendDailyNoteOutput", + "CreateLinkOutput", + "GetUserOutput", + "LinkItem", + "ListGraphIdOptionsOutput", + "ListLinksOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class LinkItem(_Base): + """A link object returned by the Reflect API.""" + + id: str | None = None + url: str | None = None + title: str | None = None + description: str | None = None + updated_at: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class AppendDailyNoteOutput(_Base): + success: bool + error: str | None = None + + +class CreateLinkOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + + +class GetUserOutput(_Base): + success: bool + error: str | None = None + uid: str | None = None + graph_ids: list[str] = Field(default_factory=list) + + +class ListGraphIdOptionsOutput(_Base): + success: bool + error: str | None = None + graph_ids: list[str] = Field(default_factory=list) + + +class ListLinksOutput(_Base): + success: bool + error: str | None = None + links: list[LinkItem] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/reflect/tests/__init__.py b/src/modulex_integrations/tools/reflect/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/reflect/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/reflect/tests/test_reflect.py b/src/modulex_integrations/tools/reflect/tests/test_reflect.py new file mode 100644 index 0000000..4443a4f --- /dev/null +++ b/src/modulex_integrations/tools/reflect/tests/test_reflect.py @@ -0,0 +1,176 @@ +"""Happy-path tests for every reflect @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.reflect import ( + TOOLS, + append_daily_note, + create_link, + get_user, + list_graph_id_options, + list_links, + manifest, +) +from modulex_integrations.tools.reflect.outputs import ( + AppendDailyNoteOutput, + CreateLinkOutput, + GetUserOutput, + ListGraphIdOptionsOutput, + ListLinksOutput, +) + +API = "https://reflect.app/api" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_5_actions(self) -> None: + assert len(manifest.actions) == 5 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_append_daily_note(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/graphs/graph123/daily-notes", + json={}, + status_code=200, + ) + + result_dict = await append_daily_note.ainvoke( + _args(graph_id="graph123", text="Hello world") + ) + + assert isinstance(result_dict, dict) + result = AppendDailyNoteOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_create_link(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/graphs/graph123/links", + json={ + "id": "link_abc123", + # TODO: fill in additional response fields from upstream API docs + }, + status_code=201, + ) + + result_dict = await create_link.ainvoke( + _args(graph_id="graph123", url="https://example.com") + ) + + assert isinstance(result_dict, dict) + result = CreateLinkOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "link_abc123" + + +@pytest.mark.asyncio +async def test_get_user(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/me", + json={ + "uid": "user_001", + "graph_ids": ["graph_a", "graph_b"], + # TODO: fill in additional response fields from upstream API docs + }, + ) + + result_dict = await get_user.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetUserOutput.model_validate(result_dict) + assert result.success is True + assert result.uid == "user_001" + assert result.graph_ids == ["graph_a", "graph_b"] + + +@pytest.mark.asyncio +async def test_list_graph_id_options(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/users/me", + json={ + "uid": "user_001", + "graph_ids": ["graph_x", "graph_y"], + }, + ) + + result_dict = await list_graph_id_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListGraphIdOptionsOutput.model_validate(result_dict) + assert result.success is True + assert result.graph_ids == ["graph_x", "graph_y"] + + +@pytest.mark.asyncio +async def test_list_links(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/graphs/graph123/links", + json=[ + { + "id": "link_1", + "url": "https://example.com", + "title": "Example", + "description": "An example link", + "updated_at": "2024-01-15T10:00:00Z", + }, + # TODO: fill in additional response items from upstream API docs + ], + ) + + result_dict = await list_links.ainvoke(_args(graph_id="graph123")) + + assert isinstance(result_dict, dict) + result = ListLinksOutput.model_validate(result_dict) + assert result.success is True + assert len(result.links) == 1 + assert result.links[0].id == "link_1" + assert result.links[0].title == "Example" + + +# --- Failure-path tests ----------------------------------------------------- + + +@pytest.mark.asyncio +async def test_get_user_missing_token(): # type: ignore[no-untyped-def] + """Empty credential should return success=False without hitting the wire.""" + result_dict = await get_user.ainvoke( + {"auth_type": "oauth2", "auth_data": {}} + ) + + assert isinstance(result_dict, dict) + result = GetUserOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access token" in result.error.lower() diff --git a/src/modulex_integrations/tools/reflect/tools.py b/src/modulex_integrations/tools/reflect/tools.py new file mode 100644 index 0000000..491c66a --- /dev/null +++ b/src/modulex_integrations/tools/reflect/tools.py @@ -0,0 +1,267 @@ +"""Reflect LangChain @tool functions.""" +from __future__ import annotations + +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.reflect.outputs import ( + AppendDailyNoteOutput, + CreateLinkOutput, + GetUserOutput, + LinkItem, + ListGraphIdOptionsOutput, + ListLinksOutput, +) + +__all__ = [ + "append_daily_note", + "create_link", + "get_user", + "list_graph_id_options", + "list_links", +] + +_BASE_URL = "https://reflect.app/api" + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Reflect API based on auth_type/auth_data.""" + headers: dict[str, str] = {"Accept": "application/json"} + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class AppendDailyNoteInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + graph_id: str = Field(description="The graph identifier") + text: str = Field(description="Text to append to the daily note") + list_name: str | None = Field(default=None, description="Name of the list to append to") + date: str | None = Field( + default=None, description="Date of the daily note in ISO 8601 format. Defaults to today." + ) + + +class CreateLinkInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + graph_id: str = Field(description="The graph identifier") + url: str = Field(description="The URL of the link to create") + title: str | None = Field(default=None, description="The link title") + description: str | None = Field(default=None, description="The link description") + + +class GetUserInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class ListGraphIdOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class ListLinksInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + graph_id: str = Field(description="The graph identifier") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=AppendDailyNoteInput) +@serialize_pydantic_return +async def append_daily_note( + auth_type: str, + auth_data: dict[str, Any], + graph_id: str, + text: str, + list_name: str | None = None, + date: str | None = None, +) -> AppendDailyNoteOutput: + """Append to a daily note.""" + if not auth_data.get("access_token"): + return AppendDailyNoteOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + payload: dict[str, Any] = { + "text": text, + "transform_type": "list-append", + } + if list_name is not None: + payload["list_name"] = list_name + if date is not None: + payload["date"] = date + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{_BASE_URL}/graphs/{graph_id}/daily-notes", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201, 204): + return AppendDailyNoteOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return AppendDailyNoteOutput(success=False, error="Request timed out.") + except Exception as exc: + return AppendDailyNoteOutput(success=False, error=f"Call failed: {exc}") + return AppendDailyNoteOutput(success=True) + + +@tool(args_schema=CreateLinkInput) +@serialize_pydantic_return +async def create_link( + auth_type: str, + auth_data: dict[str, Any], + graph_id: str, + url: str, + title: str | None = None, + description: str | None = None, +) -> CreateLinkOutput: + """Create a new link.""" + if not auth_data.get("access_token"): + return CreateLinkOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + payload: dict[str, Any] = {"url": url} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/graphs/{graph_id}/links", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return CreateLinkOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateLinkOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateLinkOutput(success=False, error=f"Call failed: {exc}") + return CreateLinkOutput(success=True, id=data.get("id")) + + +@tool(args_schema=GetUserInput) +@serialize_pydantic_return +async def get_user( + auth_type: str, + auth_data: dict[str, Any], +) -> GetUserOutput: + """Retieves information about the authenticated user.""" + if not auth_data.get("access_token"): + return GetUserOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/users/me", + headers=headers, + ) + if response.status_code != 200: + return GetUserOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetUserOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetUserOutput(success=False, error=f"Call failed: {exc}") + return GetUserOutput( + success=True, + uid=data.get("uid"), + graph_ids=data.get("graph_ids", []), + ) + + +@tool(args_schema=ListGraphIdOptionsInput) +@serialize_pydantic_return +async def list_graph_id_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListGraphIdOptionsOutput: + """Retrieves available options for the GraphId field.""" + if not auth_data.get("access_token"): + return ListGraphIdOptionsOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/users/me", + headers=headers, + ) + if response.status_code != 200: + return ListGraphIdOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListGraphIdOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListGraphIdOptionsOutput(success=False, error=f"Call failed: {exc}") + return ListGraphIdOptionsOutput( + success=True, + graph_ids=data.get("graph_ids", []), + ) + + +@tool(args_schema=ListLinksInput) +@serialize_pydantic_return +async def list_links( + auth_type: str, + auth_data: dict[str, Any], + graph_id: str, +) -> ListLinksOutput: + """Retieve all links for a graph.""" + if not auth_data.get("access_token"): + return ListLinksOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/graphs/{graph_id}/links", + headers=headers, + ) + if response.status_code != 200: + return ListLinksOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListLinksOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListLinksOutput(success=False, error=f"Call failed: {exc}") + links = [ + LinkItem( + id=item.get("id"), + url=item.get("url"), + title=item.get("title"), + description=item.get("description"), + updated_at=item.get("updated_at"), + ) + for item in (data if isinstance(data, list) else []) + ] + return ListLinksOutput(success=True, links=links) diff --git a/src/modulex_integrations/tools/shopify_partner/README.md b/src/modulex_integrations/tools/shopify_partner/README.md new file mode 100644 index 0000000..fad2bb7 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/README.md @@ -0,0 +1,31 @@ +# Shopify Partner + +Verify incoming Shopify webhooks and interact with the Shopify Partner API (`partners.shopify.com//api/`). + +## Authentication + +### API Key (Partner Credentials) + +- Log in to your [Shopify Partner Dashboard](https://partners.shopify.com) and note your Organization ID from the URL. +- Navigate to **Settings > Partner API clients** to create or copy your API access token. +- Required env vars: + - `SHOPIFY_PARTNER_ORGANIZATION_ID` (format: `12345678`) + - `SHOPIFY_PARTNER_API_KEY` (format: `shppa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `verify_webhook` | Verify an incoming webhook from Shopify by validating its HMAC-SHA256 signature | `app_secret_key`, `shopify_hmac`, `body` | + +Every tool takes additional `organization_id` and `api_key` parameters that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- The `verify_webhook` action performs local HMAC computation and does not call the Shopify Partner API, so no rate limits apply to it. +- The Shopify Partner GraphQL API (for future actions) has a cost-based throttle of 1,000 points per second with a bucket size of 2,000. +- Error model: failures are returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/shopify_partner/__init__.py b/src/modulex_integrations/tools/shopify_partner/__init__.py new file mode 100644 index 0000000..f386461 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/__init__.py @@ -0,0 +1,13 @@ +"""Shopify Partner integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.shopify_partner.manifest import manifest +from modulex_integrations.tools.shopify_partner.tools import ( + verify_webhook, +) + +TOOLS = (verify_webhook,) + +__all__ = [ + "TOOLS", + "manifest", + "verify_webhook", +] diff --git a/src/modulex_integrations/tools/shopify_partner/dependencies.toml b/src/modulex_integrations/tools/shopify_partner/dependencies.toml new file mode 100644 index 0000000..e42fac1 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the shopify_partner integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/shopify_partner/manifest.py b/src/modulex_integrations/tools/shopify_partner/manifest.py new file mode 100644 index 0000000..4dc1ec6 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/manifest.py @@ -0,0 +1,79 @@ +"""Shopify Partner integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="shopify_partner", + display_name="Shopify Partner", + description="Shopify Partner API for managing apps, verifying webhooks, and accessing partner account data", + version="1.0.0", + author="ModuleX", + logo="modulex:shopify_partner-themed", + app_url="https://partners.shopify.com", + categories=["ecommerce", "Developer Tools & Infrastructure"], + actions=[ + ActionDefinition( + name="verify_webhook", + description="Verify an incoming webhook from Shopify by validating its HMAC-SHA256 signature", + parameters={ + "app_secret_key": ParameterDef( + type="string", + description="The secret key associated with the Shopify App receiving the webhook", + required=True, + ), + "shopify_hmac": ParameterDef( + type="string", + description="The value of the x-shopify-hmac-sha256 webhook request header", + required=True, + ), + "body": ParameterDef( + type="string", + description="The incoming webhook payload as a JSON string", + required=True, + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="Shopify Partner API Credentials", + description="Authenticate using your Shopify Partner organization ID and API key", + setup_instructions=[ + "Log in to your Shopify Partner Dashboard at https://partners.shopify.com", + "Go to Settings > Partner API clients", + "Create or copy your API key and note your Organization ID from the URL", + "Paste both values below", + ], + setup_environment_variables=[ + EnvVar( + name="SHOPIFY_PARTNER_ORGANIZATION_ID", + display_name="Organization ID", + description="Your Shopify Partner organization ID (visible in the URL: partners.shopify.com/)", + required=True, + sensitive=False, + sample_format="12345678", + about_url="https://partners.shopify.com", + ), + EnvVar( + name="SHOPIFY_PARTNER_API_KEY", + display_name="API Key", + description="Your Shopify Partner API access token", + required=True, + sensitive=True, + sample_format="shppa_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://partners.shopify.com", + ), + ], + ), + ], +) diff --git a/src/modulex_integrations/tools/shopify_partner/outputs.py b/src/modulex_integrations/tools/shopify_partner/outputs.py new file mode 100644 index 0000000..54d9c91 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/outputs.py @@ -0,0 +1,20 @@ +"""Pydantic response models for the shopify_partner integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = [ + "VerifyWebhookOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class VerifyWebhookOutput(_Base): + success: bool + error: str | None = None + valid: bool | None = None diff --git a/src/modulex_integrations/tools/shopify_partner/tests/__init__.py b/src/modulex_integrations/tools/shopify_partner/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/shopify_partner/tests/test_shopify_partner.py b/src/modulex_integrations/tools/shopify_partner/tests/test_shopify_partner.py new file mode 100644 index 0000000..d371609 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/tests/test_shopify_partner.py @@ -0,0 +1,82 @@ +"""Happy-path tests for every shopify_partner @tool, plus a manifest sanity check.""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +from typing import Any + +import pytest + +from modulex_integrations.tools.shopify_partner import ( + TOOLS, + manifest, + verify_webhook, +) +from modulex_integrations.tools.shopify_partner.outputs import ( + VerifyWebhookOutput, +) + +_ORGANIZATION_ID = "12345678" +_API_KEY = "fake-api-key" +_APP_SECRET = "test-secret-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(organization_id=_ORGANIZATION_ID, api_key=_API_KEY, **extra) + + +class TestManifest: + def test_manifest_exposes_1_action(self) -> None: + assert len(manifest.actions) == 1 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_api_key_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"api_key"} + + +@pytest.mark.asyncio +async def test_verify_webhook_valid() -> None: + payload = json.dumps({"shop_domain": "example.myshopify.com", "topic": "app/uninstalled"}) + expected_hmac = base64.b64encode( + hmac.new(_APP_SECRET.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest() + ).decode("utf-8") + + result_dict = await verify_webhook.ainvoke( + _args(app_secret_key=_APP_SECRET, shopify_hmac=expected_hmac, body=payload) + ) + + assert isinstance(result_dict, dict) + result = VerifyWebhookOutput.model_validate(result_dict) + assert result.success is True + assert result.valid is True + + +@pytest.mark.asyncio +async def test_verify_webhook_invalid_signature() -> None: + payload = json.dumps({"shop_domain": "example.myshopify.com"}) + + result_dict = await verify_webhook.ainvoke( + _args(app_secret_key=_APP_SECRET, shopify_hmac="invalid-hmac-value", body=payload) + ) + + assert isinstance(result_dict, dict) + result = VerifyWebhookOutput.model_validate(result_dict) + assert result.success is True + assert result.valid is False + + +@pytest.mark.asyncio +async def test_verify_webhook_empty_secret() -> None: + result_dict = await verify_webhook.ainvoke( + _args(app_secret_key="", shopify_hmac="anything", body="{}") + ) + + assert isinstance(result_dict, dict) + result = VerifyWebhookOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "secret key" in result.error.lower() diff --git a/src/modulex_integrations/tools/shopify_partner/tools.py b/src/modulex_integrations/tools/shopify_partner/tools.py new file mode 100644 index 0000000..4d84f16 --- /dev/null +++ b/src/modulex_integrations/tools/shopify_partner/tools.py @@ -0,0 +1,65 @@ +"""Shopify Partner LangChain @tool functions.""" +from __future__ import annotations + +import base64 +import hashlib +import hmac + +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.shopify_partner.outputs import ( + VerifyWebhookOutput, +) + +__all__ = [ + "verify_webhook", +] + + +class VerifyWebhookInput(BaseModel): + app_secret_key: str = Field(description="The secret key associated with the Shopify App receiving the webhook") + shopify_hmac: str = Field(description="The value of the x-shopify-hmac-sha256 webhook request header") + body: str = Field(description="The incoming webhook payload as a JSON string") + organization_id: str = Field(description="Shopify Partner organization ID (provided by credential system)") + api_key: str = Field(description="Shopify Partner API key (provided by credential system)") + + +@tool(args_schema=VerifyWebhookInput) +@serialize_pydantic_return +async def verify_webhook( + app_secret_key: str, + shopify_hmac: str, + body: str, + organization_id: str, + api_key: str, +) -> VerifyWebhookOutput: + """Verify an incoming webhook from Shopify by validating its HMAC-SHA256 signature.""" + if not app_secret_key or not app_secret_key.strip(): + return VerifyWebhookOutput( + success=False, + error="App secret key is empty. Please provide the Shopify App secret key.", + ) + + try: + body_bytes = body.encode("utf-8") + except Exception as exc: + return VerifyWebhookOutput( + success=False, + error=f"Failed to encode body: {exc}", + ) + + computed = hmac.new( + app_secret_key.encode("utf-8"), + body_bytes, + hashlib.sha256, + ).digest() + + computed_b64 = base64.b64encode(computed).decode("utf-8") + is_valid = hmac.compare_digest(computed_b64, shopify_hmac) + + return VerifyWebhookOutput( + success=True, + valid=is_valid, + ) diff --git a/src/modulex_integrations/tools/typeform/README.md b/src/modulex_integrations/tools/typeform/README.md new file mode 100644 index 0000000..e3047d2 --- /dev/null +++ b/src/modulex_integrations/tools/typeform/README.md @@ -0,0 +1,41 @@ +# Typeform + +Online form builder for surveys, quizzes, and interactive forms via the Typeform REST API (`api.typeform.com`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth app at . +- Required redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Scopes requested: `forms:read`, `forms:write`, `images:read`, `images:write`, `responses:read`, `accounts:read`, `workspaces:read` +- Env vars (custom app only): `TYPEFORM_OAUTH2_CLIENT_ID`, `TYPEFORM_OAUTH2_CLIENT_SECRET` + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `list_forms` | Retrieves a list of forms from your Typeform account | | +| `create_form` | Creates a new form with the specified title | `title` | +| `duplicate_form` | Duplicates an existing form and adds (copy) to the end of the title | `form_id` | +| `delete_form` | Deletes a form from your Typeform account | `form_id` | +| `list_images` | Retrieves a list of all images in your Typeform account | | +| `get_form` | Retrieves the details of a specific form | `form_id` | +| `lookup_responses` | Search for form responses matching a query string | `form_id`, `query` | +| `list_responses` | Returns form responses and date and time of form landing and submission | `form_id` | +| `update_form_title` | Updates an existing form's title | `form_id`, `title` | +| `delete_image` | Deletes an image from your Typeform account | `image_id` | +| `create_image` | Adds an image to your Typeform account | `file_name` | +| `update_dropdown_multiple_choice_ranking` | Update a dropdown, multiple choice, or ranking field's choices by adding a new choice | `form_id`, `field_id`, `choice` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth2 credential. + +## Limits & Quotas + +- **Rate limit**: Typeform API allows up to 2 requests per second per OAuth token. +- **Responses endpoint**: Maximum 1000 responses per request (page_size cap). +- **Error model**: Non-2xx responses are caught and returned as `success=False` + `error` rather than raising. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/typeform/__init__.py b/src/modulex_integrations/tools/typeform/__init__.py new file mode 100644 index 0000000..ccc89c8 --- /dev/null +++ b/src/modulex_integrations/tools/typeform/__init__.py @@ -0,0 +1,48 @@ +"""Typeform integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.typeform.manifest import manifest +from modulex_integrations.tools.typeform.tools import ( + create_form, + create_image, + delete_form, + delete_image, + duplicate_form, + get_form, + list_forms, + list_images, + list_responses, + lookup_responses, + update_dropdown_multiple_choice_ranking, + update_form_title, +) + +TOOLS = ( + list_forms, + create_form, + duplicate_form, + delete_form, + list_images, + get_form, + lookup_responses, + list_responses, + update_form_title, + delete_image, + create_image, + update_dropdown_multiple_choice_ranking, +) + +__all__ = [ + "TOOLS", + "create_form", + "create_image", + "delete_form", + "delete_image", + "duplicate_form", + "get_form", + "list_forms", + "list_images", + "list_responses", + "lookup_responses", + "manifest", + "update_dropdown_multiple_choice_ranking", + "update_form_title", +] diff --git a/src/modulex_integrations/tools/typeform/dependencies.toml b/src/modulex_integrations/tools/typeform/dependencies.toml new file mode 100644 index 0000000..d9d0976 --- /dev/null +++ b/src/modulex_integrations/tools/typeform/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the typeform integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/typeform/manifest.py b/src/modulex_integrations/tools/typeform/manifest.py new file mode 100644 index 0000000..a5e838d --- /dev/null +++ b/src/modulex_integrations/tools/typeform/manifest.py @@ -0,0 +1,327 @@ +"""Typeform integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="typeform", + display_name="Typeform", + description="Online form builder for surveys, quizzes, and interactive forms", + version="1.0.0", + author="ModuleX", + logo="modulex:typeform-themed", + app_url="https://www.typeform.com", + categories=["Productivity & Collaboration", "forms", "surveys"], + actions=[ + ActionDefinition( + name="list_forms", + description="Retrieves a list of forms from your Typeform account", + parameters={ + "search": ParameterDef( + type="string", + description="Returns items that contain the specified string", + ), + "page": ParameterDef( + type="integer", + description="The page of results to retrieve. Default 1 is the first page of results", + default=1, + ), + "page_size": ParameterDef( + type="integer", + description="Number of results to retrieve per page. Default is 10. Maximum is 200", + default=10, + ), + "workspace_id": ParameterDef( + type="string", + description="Retrieve typeforms for the specified workspace ID", + ), + }, + ), + ActionDefinition( + name="create_form", + description="Creates a new form with the specified title", + parameters={ + "title": ParameterDef( + type="string", + description="Title to use for the typeform", + required=True, + ), + "workspace_href": ParameterDef( + type="string", + description="URL of the workspace to use for the typeform. If not specified, the form is saved in the default workspace", + ), + }, + ), + ActionDefinition( + name="duplicate_form", + description="Duplicates an existing form and adds (copy) to the end of the title", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form to duplicate", + required=True, + ), + }, + ), + ActionDefinition( + name="delete_form", + description="Deletes a form from your Typeform account", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="list_images", + description="Retrieves a list of all images in your Typeform account", + parameters={}, + ), + ActionDefinition( + name="get_form", + description="Retrieves the details of a specific form", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="lookup_responses", + description="Search for form responses matching a query string", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form", + required=True, + ), + "query": ParameterDef( + type="string", + description="Limit request to only responses that include the specified string. Matched against all answers, hidden fields, and variable values", + required=True, + ), + "page_size": ParameterDef( + type="integer", + description="Maximum number of responses. Maximum value is 1000. Default is 25", + default=25, + ), + "since": ParameterDef( + type="string", + description="Limit to responses submitted since this date/time (ISO 8601 UTC or timestamp in seconds)", + ), + "until": ParameterDef( + type="string", + description="Limit to responses submitted until this date/time (ISO 8601 UTC or timestamp in seconds)", + ), + "after": ParameterDef( + type="string", + description="Limit to responses submitted after the specified token. Cannot be used with sort", + ), + "before": ParameterDef( + type="string", + description="Limit to responses submitted before the specified token. Cannot be used with sort", + ), + }, + ), + ActionDefinition( + name="list_responses", + description="Returns form responses and date and time of form landing and submission", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form", + required=True, + ), + "page_size": ParameterDef( + type="integer", + description="Maximum number of responses. Maximum value is 1000. Default is 25", + default=25, + ), + "since": ParameterDef( + type="string", + description="Limit to responses submitted since this date/time (ISO 8601 UTC or timestamp in seconds)", + ), + "until": ParameterDef( + type="string", + description="Limit to responses submitted until this date/time (ISO 8601 UTC or timestamp in seconds)", + ), + "after": ParameterDef( + type="string", + description="Limit to responses submitted after the specified token. Cannot be used with sort", + ), + "before": ParameterDef( + type="string", + description="Limit to responses submitted before the specified token. Cannot be used with sort", + ), + "included_response_ids": ParameterDef( + type="string", + description="Comma-separated list of response_ids to include. Cannot be combined with excluded_response_ids", + ), + "excluded_response_ids": ParameterDef( + type="string", + description="Comma-separated list of response_ids to exclude. Cannot be combined with included_response_ids", + ), + "completed": ParameterDef( + type="boolean", + description="Limit responses only to those which were submitted. If true, filters by submitted_at; otherwise by landed_at", + ), + "sort": ParameterDef( + type="string", + description="Responses order in {fieldID},{asc|desc} format. Default is submitted_at,desc", + default="submitted_at,desc", + ), + "query": ParameterDef( + type="string", + description="Limit request to only responses that include the specified string", + ), + "fields": ParameterDef( + type="string", + description="Comma-separated list of field IDs to show in answers section", + ), + "answered_fields": ParameterDef( + type="string", + description="Comma-separated list of field IDs that must have answers in the response", + ), + }, + ), + ActionDefinition( + name="update_form_title", + description="Updates an existing form's title", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form to update", + required=True, + ), + "title": ParameterDef( + type="string", + description="New title for the typeform", + required=True, + ), + "workspace_href": ParameterDef( + type="string", + description="URL of the workspace to move the form to", + ), + }, + ), + ActionDefinition( + name="delete_image", + description="Deletes an image from your Typeform account", + parameters={ + "image_id": ParameterDef( + type="string", + description="Unique ID for the image to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="create_image", + description="Adds an image to your Typeform account", + parameters={ + "file_name": ParameterDef( + type="string", + description="File name for the image", + required=True, + ), + "image": ParameterDef( + type="string", + description="Base64 code for the image (without data URI prefix). Either image or url must be provided", + ), + "url": ParameterDef( + type="string", + description="URL of the image to add. Either image or url must be provided", + ), + }, + ), + ActionDefinition( + name="update_dropdown_multiple_choice_ranking", + description="Update a dropdown, multiple choice, or ranking field's choices by adding a new choice", + parameters={ + "form_id": ParameterDef( + type="string", + description="Unique ID for the form", + required=True, + ), + "field_id": ParameterDef( + type="string", + description="Unique ID for the dropdown, multiple choice, or ranking field", + required=True, + ), + "choice": ParameterDef( + type="string", + description="The new choice label to add to the end of the existing choices", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Typeform OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="TYPEFORM_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Typeform OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://admin.typeform.com/account#/section/tokens", + ), + EnvVar( + name="TYPEFORM_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Typeform OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://admin.typeform.com/account#/section/tokens", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://api.typeform.com/oauth/authorize", + token_url="https://api.typeform.com/oauth/token", + scopes=[ + "forms:read", + "forms:write", + "images:read", + "images:write", + "responses:read", + "accounts:read", + "workspaces:read", + ], + ), + test_endpoint=TestEndpoint( + url="https://api.typeform.com/me", + method="GET", + headers={"Authorization": "Bearer {access_token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["user_id"], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user info", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/typeform/outputs.py b/src/modulex_integrations/tools/typeform/outputs.py new file mode 100644 index 0000000..1a643fc --- /dev/null +++ b/src/modulex_integrations/tools/typeform/outputs.py @@ -0,0 +1,154 @@ +"""Pydantic response models for the typeform integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateFormOutput", + "CreateImageOutput", + "DeleteFormOutput", + "DeleteImageOutput", + "DuplicateFormOutput", + "FormSummary", + "GetFormOutput", + "ImageItem", + "ListFormsOutput", + "ListImagesOutput", + "ListResponsesOutput", + "LookupResponsesOutput", + "ResponseItem", + "UpdateDropdownMultipleChoiceRankingOutput", + "UpdateFormTitleOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class FormSummary(_Base): + id: str | None = None + title: str | None = None + type: str | None = None + last_updated_at: str | None = None + self_url: str | None = None + + +class ResponseItem(_Base): + response_id: str | None = None + landed_at: str | None = None + submitted_at: str | None = None + answers: list[dict[str, Any]] = Field(default_factory=list) + + +class ImageItem(_Base): + id: str | None = None + src: str | None = None + file_name: str | None = None + width: int | None = None + height: int | None = None + + +# --- Per-action output models --------------------------------------------- + + +class ListFormsOutput(_Base): + success: bool + error: str | None = None + forms: list[FormSummary] = Field(default_factory=list) + total_items: int | None = None + page_count: int | None = None + + +class CreateFormOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + title: str | None = None + type: str | None = None + self_url: str | None = None + + +class DuplicateFormOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + title: str | None = None + type: str | None = None + self_url: str | None = None + + +class DeleteFormOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + + +class ListImagesOutput(_Base): + success: bool + error: str | None = None + images: list[ImageItem] = Field(default_factory=list) + + +class GetFormOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + title: str | None = None + type: str | None = None + fields: list[dict[str, Any]] = Field(default_factory=list) + self_url: str | None = None + + +class LookupResponsesOutput(_Base): + success: bool + error: str | None = None + items: list[ResponseItem] = Field(default_factory=list) + total_items: int | None = None + page_count: int | None = None + + +class ListResponsesOutput(_Base): + success: bool + error: str | None = None + items: list[ResponseItem] = Field(default_factory=list) + total_items: int | None = None + page_count: int | None = None + + +class UpdateFormTitleOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + title: str | None = None + + +class DeleteImageOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + + +class CreateImageOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + src: str | None = None + file_name: str | None = None + width: int | None = None + height: int | None = None + + +class UpdateDropdownMultipleChoiceRankingOutput(_Base): + success: bool + error: str | None = None + id: str | None = None + title: str | None = None + fields: list[dict[str, Any]] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/typeform/tests/__init__.py b/src/modulex_integrations/tools/typeform/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/typeform/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/typeform/tests/test_typeform.py b/src/modulex_integrations/tools/typeform/tests/test_typeform.py new file mode 100644 index 0000000..80a5ac7 --- /dev/null +++ b/src/modulex_integrations/tools/typeform/tests/test_typeform.py @@ -0,0 +1,386 @@ +"""Happy-path tests for every typeform @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.typeform import ( + TOOLS, + create_form, + create_image, + delete_form, + delete_image, + duplicate_form, + get_form, + list_forms, + list_images, + list_responses, + lookup_responses, + manifest, + update_dropdown_multiple_choice_ranking, + update_form_title, +) +from modulex_integrations.tools.typeform.outputs import ( + CreateFormOutput, + CreateImageOutput, + DeleteFormOutput, + DeleteImageOutput, + DuplicateFormOutput, + GetFormOutput, + ListFormsOutput, + ListImagesOutput, + ListResponsesOutput, + LookupResponsesOutput, + UpdateDropdownMultipleChoiceRankingOutput, + UpdateFormTitleOutput, +) + +API = "https://api.typeform.com" + +_AUTH: dict[str, Any] = { + "auth_type": "oauth2", + "auth_data": {"access_token": "fake_access_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + """Build a ``.ainvoke()`` input dict: auth + per-test extras.""" + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_12_actions(self) -> None: + assert len(manifest.actions) == 12 + + def test_manifest_actions_match_tools_tuple(self) -> None: + assert {a.name for a in manifest.actions} == {t.name for t in TOOLS} + + def test_manifest_has_oauth2_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"oauth2"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_forms(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms?page=1&page_size=10", + json={ + "total_items": 1, + "page_count": 1, + "items": [ + { + "id": "abc123", + "title": "My Form", + "type": "quiz", + "last_updated_at": "2024-01-01T00:00:00Z", + "_links": {"display": "https://example.typeform.com/to/abc123"}, + } + ], + }, + ) + + result_dict = await list_forms.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListFormsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.forms) == 1 + assert result.forms[0].id == "abc123" + + +@pytest.mark.asyncio +async def test_create_form(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/forms", + json={ + "id": "new123", + "title": "New Form", + "type": "form", + "_links": {"display": "https://example.typeform.com/to/new123"}, + }, + status_code=201, + ) + + result_dict = await create_form.ainvoke(_args(title="New Form")) + + assert isinstance(result_dict, dict) + result = CreateFormOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "new123" + + +@pytest.mark.asyncio +async def test_duplicate_form(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms/orig123", + json={ + "id": "orig123", + "title": "Original", + "type": "form", + "fields": [], + "_links": {"display": "https://example.typeform.com/to/orig123"}, + }, + ) + httpx_mock.add_response( + method="POST", + url=f"{API}/forms", + json={ + "id": "copy456", + "title": "Original (copy)", + "type": "form", + "_links": {"display": "https://example.typeform.com/to/copy456"}, + }, + status_code=201, + ) + + result_dict = await duplicate_form.ainvoke(_args(form_id="orig123")) + + assert isinstance(result_dict, dict) + result = DuplicateFormOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "copy456" + + +@pytest.mark.asyncio +async def test_delete_form(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/forms/del123", + status_code=204, + ) + + result_dict = await delete_form.ainvoke(_args(form_id="del123")) + + assert isinstance(result_dict, dict) + result = DeleteFormOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "del123" + + +@pytest.mark.asyncio +async def test_list_images(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/images", + json=[ + { + "id": "img001", + "src": "https://images.typeform.com/img001", + "file_name": "logo.png", + "width": 200, + "height": 100, + } + ], + ) + + result_dict = await list_images.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListImagesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.images) == 1 + assert result.images[0].id == "img001" + + +@pytest.mark.asyncio +async def test_get_form(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms/form789", + json={ + "id": "form789", + "title": "Survey", + "type": "form", + "fields": [{"id": "f1", "type": "short_text", "title": "Name"}], + "_links": {"display": "https://example.typeform.com/to/form789"}, + }, + ) + + result_dict = await get_form.ainvoke(_args(form_id="form789")) + + assert isinstance(result_dict, dict) + result = GetFormOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "form789" + assert len(result.fields) == 1 + + +@pytest.mark.asyncio +async def test_list_forms_missing_token() -> None: + """Failure path: missing access_token returns structured error.""" + result_dict = await list_forms.ainvoke( + {"auth_type": "oauth2", "auth_data": {}} + ) + assert isinstance(result_dict, dict) + result = ListFormsOutput.model_validate(result_dict) + assert result.success is False + assert "access token" in (result.error or "").lower() + + +@pytest.mark.asyncio +async def test_lookup_responses(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms/form789/responses?query=hello&page_size=25", + json={ + "total_items": 1, + "page_count": 1, + "items": [ + { + "response_id": "resp001", + "landed_at": "2024-01-01T00:00:00Z", + "submitted_at": "2024-01-01T00:01:00Z", + "answers": [{"field": {"id": "f1"}, "type": "text", "text": "hello"}], + } + ], + }, + ) + + result_dict = await lookup_responses.ainvoke(_args(form_id="form789", query="hello")) + + assert isinstance(result_dict, dict) + result = LookupResponsesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 1 + assert result.items[0].response_id == "resp001" + + +@pytest.mark.asyncio +async def test_list_responses(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms/form789/responses?page_size=25&sort=submitted_at%2Cdesc", + json={ + "total_items": 1, + "page_count": 1, + "items": [ + { + "response_id": "resp002", + "landed_at": "2024-02-01T00:00:00Z", + "submitted_at": "2024-02-01T00:02:00Z", + "answers": [], + } + ], + }, + ) + + result_dict = await list_responses.ainvoke(_args(form_id="form789")) + + assert isinstance(result_dict, dict) + result = ListResponsesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.items) == 1 + + +@pytest.mark.asyncio +async def test_update_form_title(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/forms/form789", + status_code=204, + ) + + result_dict = await update_form_title.ainvoke(_args(form_id="form789", title="Updated Title")) + + assert isinstance(result_dict, dict) + result = UpdateFormTitleOutput.model_validate(result_dict) + assert result.success is True + assert result.title == "Updated Title" + + +@pytest.mark.asyncio +async def test_delete_image(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/images/img001", + status_code=204, + ) + + result_dict = await delete_image.ainvoke(_args(image_id="img001")) + + assert isinstance(result_dict, dict) + result = DeleteImageOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "img001" + + +@pytest.mark.asyncio +async def test_create_image(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/images", + json={ + "id": "img002", + "src": "https://images.typeform.com/img002", + "file_name": "banner.png", + "width": 800, + "height": 400, + }, + status_code=201, + ) + + result_dict = await create_image.ainvoke( + _args(file_name="banner.png", url="https://example.com/banner.png") + ) + + assert isinstance(result_dict, dict) + result = CreateImageOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "img002" + + +@pytest.mark.asyncio +async def test_update_dropdown_multiple_choice_ranking(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/forms/form789", + json={ + "id": "form789", + "title": "Survey", + "type": "form", + "fields": [ + { + "id": "field01", + "type": "dropdown", + "title": "Favorite Color", + "properties": {"choices": [{"label": "Red"}, {"label": "Blue"}]}, + } + ], + "_links": {"display": "https://example.typeform.com/to/form789"}, + }, + ) + httpx_mock.add_response( + method="PUT", + url=f"{API}/forms/form789", + json={ + "id": "form789", + "title": "Survey", + "type": "form", + "fields": [ + { + "id": "field01", + "type": "dropdown", + "title": "Favorite Color", + "properties": {"choices": [{"label": "Red"}, {"label": "Blue"}, {"label": "Green"}]}, + } + ], + }, + ) + + result_dict = await update_dropdown_multiple_choice_ranking.ainvoke( + _args(form_id="form789", field_id="field01", choice="Green") + ) + + assert isinstance(result_dict, dict) + result = UpdateDropdownMultipleChoiceRankingOutput.model_validate(result_dict) + assert result.success is True + assert result.id == "form789" diff --git a/src/modulex_integrations/tools/typeform/tools.py b/src/modulex_integrations/tools/typeform/tools.py new file mode 100644 index 0000000..55e0cce --- /dev/null +++ b/src/modulex_integrations/tools/typeform/tools.py @@ -0,0 +1,748 @@ +"""Typeform LangChain @tool functions.""" +from __future__ import annotations + +import json +import re +from typing import Any + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.typeform.outputs import ( + CreateFormOutput, + CreateImageOutput, + DeleteFormOutput, + DeleteImageOutput, + DuplicateFormOutput, + FormSummary, + GetFormOutput, + ImageItem, + ListFormsOutput, + ListImagesOutput, + ListResponsesOutput, + LookupResponsesOutput, + ResponseItem, + UpdateDropdownMultipleChoiceRankingOutput, + UpdateFormTitleOutput, +) + +__all__ = [ + "create_form", + "create_image", + "delete_form", + "delete_image", + "duplicate_form", + "get_form", + "list_forms", + "list_images", + "list_responses", + "lookup_responses", + "update_dropdown_multiple_choice_ranking", + "update_form_title", +] + +_BASE_URL = "https://api.typeform.com" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Typeform API based on auth_type/auth_data.""" + headers: dict[str, str] = {"Accept": "application/json"} + if auth_type == "oauth2": + access_token = auth_data.get("access_token") + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class ListFormsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + search: str | None = Field(default=None, description="Returns items that contain the specified string") + page: int = Field(default=1, description="The page of results to retrieve") + page_size: int = Field(default=10, description="Number of results to retrieve per page. Maximum is 200") + workspace_id: str | None = Field(default=None, description="Retrieve typeforms for the specified workspace ID") + + +class CreateFormInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + title: str = Field(description="Title to use for the typeform") + workspace_href: str | None = Field(default=None, description="URL of the workspace to use for the typeform") + + +class DuplicateFormInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form to duplicate") + + +class DeleteFormInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form to delete") + + +class ListImagesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class GetFormInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form to retrieve") + + +class LookupResponsesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form") + query: str = Field(description="Limit request to only responses that include the specified string") + page_size: int = Field(default=25, description="Maximum number of responses. Maximum is 1000") + since: str | None = Field(default=None, description="Limit to responses submitted since this date/time") + until: str | None = Field(default=None, description="Limit to responses submitted until this date/time") + after: str | None = Field(default=None, description="Limit to responses submitted after the specified token") + before: str | None = Field(default=None, description="Limit to responses submitted before the specified token") + + +class ListResponsesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form") + page_size: int = Field(default=25, description="Maximum number of responses. Maximum is 1000") + since: str | None = Field(default=None, description="Limit to responses submitted since this date/time") + until: str | None = Field(default=None, description="Limit to responses submitted until this date/time") + after: str | None = Field(default=None, description="Limit to responses submitted after the specified token") + before: str | None = Field(default=None, description="Limit to responses submitted before the specified token") + included_response_ids: str | None = Field(default=None, description="Comma-separated list of response_ids to include") + excluded_response_ids: str | None = Field(default=None, description="Comma-separated list of response_ids to exclude") + completed: bool | None = Field(default=None, description="Limit responses only to those which were submitted") + sort: str = Field(default="submitted_at,desc", description="Responses order in {fieldID},{asc|desc} format") + query: str | None = Field(default=None, description="Limit request to only responses that include the specified string") + fields: str | None = Field(default=None, description="Comma-separated list of field IDs to show in answers section") + answered_fields: str | None = Field(default=None, description="Comma-separated list of field IDs that must have answers") + + +class UpdateFormTitleInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form to update") + title: str = Field(description="New title for the typeform") + workspace_href: str | None = Field(default=None, description="URL of the workspace to move the form to") + + +class DeleteImageInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + image_id: str = Field(description="Unique ID for the image to delete") + + +class CreateImageInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + file_name: str = Field(description="File name for the image") + image: str | None = Field(default=None, description="Base64 code for the image (without data URI prefix)") + url: str | None = Field(default=None, description="URL of the image to add. Either image or url must be provided") + + +class UpdateDropdownMultipleChoiceRankingInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + form_id: str = Field(description="Unique ID for the form") + field_id: str = Field(description="Unique ID for the dropdown, multiple choice, or ranking field") + choice: str = Field(description="The new choice label to add to the end of the existing choices") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=ListFormsInput) +@serialize_pydantic_return +async def list_forms( + auth_type: str, + auth_data: dict[str, Any], + search: str | None = None, + page: int = 1, + page_size: int = 10, + workspace_id: str | None = None, +) -> ListFormsOutput: + """Retrieves a list of forms from your Typeform account.""" + if not auth_data.get("access_token"): + return ListFormsOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, Any] = {"page": page, "page_size": page_size} + if search: + params["search"] = search + if workspace_id: + params["workspace_id"] = workspace_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/forms", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListFormsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListFormsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListFormsOutput(success=False, error=f"Call failed: {exc}") + items = data.get("items", []) + forms = [ + FormSummary( + id=f.get("id"), + title=f.get("title"), + type=f.get("type"), + last_updated_at=f.get("last_updated_at"), + self_url=(f.get("_links") or {}).get("display"), + ) + for f in items + ] + return ListFormsOutput( + success=True, + forms=forms, + total_items=data.get("total_items"), + page_count=data.get("page_count"), + ) + + +@tool(args_schema=CreateFormInput) +@serialize_pydantic_return +async def create_form( + auth_type: str, + auth_data: dict[str, Any], + title: str, + workspace_href: str | None = None, +) -> CreateFormOutput: + """Creates a new form with the specified title.""" + if not auth_data.get("access_token"): + return CreateFormOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + payload: dict[str, Any] = {"title": title} + if workspace_href: + payload["workspace"] = {"href": workspace_href} + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/forms", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return CreateFormOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateFormOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateFormOutput(success=False, error=f"Call failed: {exc}") + return CreateFormOutput( + success=True, + id=data.get("id"), + title=data.get("title"), + type=data.get("type"), + self_url=(data.get("_links") or {}).get("display"), + ) + + +@tool(args_schema=DuplicateFormInput) +@serialize_pydantic_return +async def duplicate_form( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, +) -> DuplicateFormOutput: + """Duplicates an existing form and adds (copy) to the end of the title.""" + if not auth_data.get("access_token"): + return DuplicateFormOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + get_response = await client.get( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + ) + if get_response.status_code != 200: + return DuplicateFormOutput( + success=False, + error=f"API error fetching form ({get_response.status_code}): {get_response.text}", + ) + form_data = get_response.json() + form_data.pop("id", None) + form_data.pop("_links", None) + form_data["title"] = f"{form_data.get('title', '')} (copy)" + form_json = json.dumps(form_data) + form_json = re.sub(r'"id"\s*:\s*"[^"]*"', '"id":""', form_json) + cleaned_form = json.loads(form_json) + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + create_response = await client.post( + f"{_BASE_URL}/forms", + headers=headers, + json=cleaned_form, + ) + if create_response.status_code not in (200, 201): + return DuplicateFormOutput( + success=False, + error=f"API error creating copy ({create_response.status_code}): {create_response.text}", + ) + data = create_response.json() + except httpx.TimeoutException: + return DuplicateFormOutput(success=False, error="Request timed out.") + except Exception as exc: + return DuplicateFormOutput(success=False, error=f"Call failed: {exc}") + return DuplicateFormOutput( + success=True, + id=data.get("id"), + title=data.get("title"), + type=data.get("type"), + self_url=(data.get("_links") or {}).get("display"), + ) + + +@tool(args_schema=DeleteFormInput) +@serialize_pydantic_return +async def delete_form( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, +) -> DeleteFormOutput: + """Deletes a form from your Typeform account.""" + if not auth_data.get("access_token"): + return DeleteFormOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + ) + if response.status_code not in (200, 204): + return DeleteFormOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteFormOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteFormOutput(success=False, error=f"Call failed: {exc}") + return DeleteFormOutput(success=True, id=form_id) + + +@tool(args_schema=ListImagesInput) +@serialize_pydantic_return +async def list_images( + auth_type: str, + auth_data: dict[str, Any], +) -> ListImagesOutput: + """Retrieves a list of all images in your Typeform account.""" + if not auth_data.get("access_token"): + return ListImagesOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/images", + headers=headers, + ) + if response.status_code != 200: + return ListImagesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListImagesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListImagesOutput(success=False, error=f"Call failed: {exc}") + images = [ + ImageItem( + id=img.get("id"), + src=img.get("src"), + file_name=img.get("file_name"), + width=img.get("width"), + height=img.get("height"), + ) + for img in (data if isinstance(data, list) else []) + ] + return ListImagesOutput(success=True, images=images) + + +@tool(args_schema=GetFormInput) +@serialize_pydantic_return +async def get_form( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, +) -> GetFormOutput: + """Retrieves the details of a specific form.""" + if not auth_data.get("access_token"): + return GetFormOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + ) + if response.status_code != 200: + return GetFormOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetFormOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetFormOutput(success=False, error=f"Call failed: {exc}") + return GetFormOutput( + success=True, + id=data.get("id"), + title=data.get("title"), + type=data.get("type"), + fields=data.get("fields", []), + self_url=(data.get("_links") or {}).get("display"), + ) + + +@tool(args_schema=LookupResponsesInput) +@serialize_pydantic_return +async def lookup_responses( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, + query: str, + page_size: int = 25, + since: str | None = None, + until: str | None = None, + after: str | None = None, + before: str | None = None, +) -> LookupResponsesOutput: + """Search for form responses matching a query string.""" + if not auth_data.get("access_token"): + return LookupResponsesOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, Any] = {"query": query, "page_size": page_size} + if since: + params["since"] = since + if until: + params["until"] = until + if after: + params["after"] = after + if before: + params["before"] = before + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/forms/{form_id}/responses", + headers=headers, + params=params, + ) + if response.status_code != 200: + return LookupResponsesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return LookupResponsesOutput(success=False, error="Request timed out.") + except Exception as exc: + return LookupResponsesOutput(success=False, error=f"Call failed: {exc}") + items = [ + ResponseItem( + response_id=r.get("response_id"), + landed_at=r.get("landed_at"), + submitted_at=r.get("submitted_at"), + answers=r.get("answers", []), + ) + for r in data.get("items", []) + ] + return LookupResponsesOutput( + success=True, + items=items, + total_items=data.get("total_items"), + page_count=data.get("page_count"), + ) + + +@tool(args_schema=ListResponsesInput) +@serialize_pydantic_return +async def list_responses( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, + page_size: int = 25, + since: str | None = None, + until: str | None = None, + after: str | None = None, + before: str | None = None, + included_response_ids: str | None = None, + excluded_response_ids: str | None = None, + completed: bool | None = None, + sort: str = "submitted_at,desc", + query: str | None = None, + fields: str | None = None, + answered_fields: str | None = None, +) -> ListResponsesOutput: + """Returns form responses and date and time of form landing and submission.""" + if not auth_data.get("access_token"): + return ListResponsesOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, Any] = {"page_size": page_size, "sort": sort} + if since: + params["since"] = since + if until: + params["until"] = until + if after: + params["after"] = after + if before: + params["before"] = before + if included_response_ids: + params["included_response_ids"] = included_response_ids + if excluded_response_ids: + params["excluded_response_ids"] = excluded_response_ids + if completed is not None: + params["completed"] = str(completed).lower() + if query: + params["query"] = query + if fields: + params["fields"] = fields + if answered_fields: + params["answered_fields"] = answered_fields + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/forms/{form_id}/responses", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListResponsesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListResponsesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListResponsesOutput(success=False, error=f"Call failed: {exc}") + items = [ + ResponseItem( + response_id=r.get("response_id"), + landed_at=r.get("landed_at"), + submitted_at=r.get("submitted_at"), + answers=r.get("answers", []), + ) + for r in data.get("items", []) + ] + return ListResponsesOutput( + success=True, + items=items, + total_items=data.get("total_items"), + page_count=data.get("page_count"), + ) + + +@tool(args_schema=UpdateFormTitleInput) +@serialize_pydantic_return +async def update_form_title( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, + title: str, + workspace_href: str | None = None, +) -> UpdateFormTitleOutput: + """Updates an existing form's title.""" + if not auth_data.get("access_token"): + return UpdateFormTitleOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + patch_ops: list[dict[str, str]] = [ + {"op": "replace", "path": "/title", "value": title}, + ] + if workspace_href: + patch_ops.append({"op": "replace", "path": "/workspace/href", "value": workspace_href}) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + json=patch_ops, + ) + if response.status_code not in (200, 204): + return UpdateFormTitleOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UpdateFormTitleOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateFormTitleOutput(success=False, error=f"Call failed: {exc}") + return UpdateFormTitleOutput(success=True, id=form_id, title=title) + + +@tool(args_schema=DeleteImageInput) +@serialize_pydantic_return +async def delete_image( + auth_type: str, + auth_data: dict[str, Any], + image_id: str, +) -> DeleteImageOutput: + """Deletes an image from your Typeform account.""" + if not auth_data.get("access_token"): + return DeleteImageOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{_BASE_URL}/images/{image_id}", + headers=headers, + ) + if response.status_code not in (200, 204): + return DeleteImageOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteImageOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteImageOutput(success=False, error=f"Call failed: {exc}") + return DeleteImageOutput(success=True, id=image_id) + + +@tool(args_schema=CreateImageInput) +@serialize_pydantic_return +async def create_image( + auth_type: str, + auth_data: dict[str, Any], + file_name: str, + image: str | None = None, + url: str | None = None, +) -> CreateImageOutput: + """Adds an image to your Typeform account.""" + if not auth_data.get("access_token"): + return CreateImageOutput(success=False, error="Missing OAuth2 access token.") + if not image and not url: + return CreateImageOutput( + success=False, + error="Either 'image' (base64) or 'url' must be provided.", + ) + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + payload: dict[str, Any] = {"file_name": file_name} + if image: + payload["image"] = image + if url: + payload["url"] = url + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/images", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return CreateImageOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateImageOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateImageOutput(success=False, error=f"Call failed: {exc}") + return CreateImageOutput( + success=True, + id=data.get("id"), + src=data.get("src"), + file_name=data.get("file_name"), + width=data.get("width"), + height=data.get("height"), + ) + + +@tool(args_schema=UpdateDropdownMultipleChoiceRankingInput) +@serialize_pydantic_return +async def update_dropdown_multiple_choice_ranking( + auth_type: str, + auth_data: dict[str, Any], + form_id: str, + field_id: str, + choice: str, +) -> UpdateDropdownMultipleChoiceRankingOutput: + """Update a dropdown, multiple choice, or ranking field's choices by adding a new choice.""" + if not auth_data.get("access_token"): + return UpdateDropdownMultipleChoiceRankingOutput(success=False, error="Missing OAuth2 access token.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + get_response = await client.get( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + ) + if get_response.status_code != 200: + return UpdateDropdownMultipleChoiceRankingOutput( + success=False, + error=f"API error fetching form ({get_response.status_code}): {get_response.text}", + ) + form_data = get_response.json() + target_field = None + for field in form_data.get("fields", []): + if field.get("id") == field_id: + target_field = field + break + if target_field is None: + return UpdateDropdownMultipleChoiceRankingOutput( + success=False, + error=f"Field '{field_id}' not found in form '{form_id}'", + ) + valid_types = ("dropdown", "multiple_choice", "ranking") + if target_field.get("type") not in valid_types: + return UpdateDropdownMultipleChoiceRankingOutput( + success=False, + error=f"Field '{field_id}' is of type '{target_field.get('type')}', expected one of {valid_types}", + ) + properties = target_field.get("properties", {}) + choices = properties.get("choices", []) + choices.append({"label": choice}) + properties["choices"] = choices + target_field["properties"] = properties + form_data.pop("id", None) + form_data.pop("_links", None) + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + put_response = await client.put( + f"{_BASE_URL}/forms/{form_id}", + headers=headers, + json=form_data, + ) + if put_response.status_code not in (200, 204): + return UpdateDropdownMultipleChoiceRankingOutput( + success=False, + error=f"API error updating form ({put_response.status_code}): {put_response.text}", + ) + result_data = put_response.json() if put_response.status_code == 200 else form_data + except httpx.TimeoutException: + return UpdateDropdownMultipleChoiceRankingOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateDropdownMultipleChoiceRankingOutput(success=False, error=f"Call failed: {exc}") + return UpdateDropdownMultipleChoiceRankingOutput( + success=True, + id=result_data.get("id", form_id), + title=result_data.get("title"), + fields=result_data.get("fields", []), + )