From 124b60c6334a8453e42b224bdcfde34d6f86fa09 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Thu, 28 May 2026 14:11:37 +0000 Subject: [PATCH 01/15] auto-integrate: gong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the **gong** tool (revenue intelligence platform — Gong REST API v2). - **5 actions**: `add_new_call`, `get_extensive_data`, `list_calls`, `list_workspace_id_options`, `retrieve_transcripts_of_calls` - **Auth**: OAuth2 (scopes: `api:calls:read:basic`, `api:calls:read:extensive`, `api:calls:create`, `api:workspaces:read`, `api:calls:read:transcript`) - **Consumer-side audit applied 3 patches before merge:** 1. Logo corrected from `simple-icons:gong` to `modulex:gong-themed` (check 8.9, mechanical). 2. Credential-validity short-circuit guard added to all 5 tool function bodies (check 8.4, mechanical). 3. Failure-path test `test_list_calls_empty_credential` appended (check 6.5, mechanical). - **No runtime dependencies** beyond the base package (`httpx`, `pydantic`, `langchain-core`). - All gates pass: ruff, mypy --strict, pytest (9/9 green). Provider: primary Run: 26579415749 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 7 + pyproject.toml | 5 + src/modulex_integrations/tools/gong/README.md | 34 ++ .../tools/gong/__init__.py | 27 ++ .../tools/gong/dependencies.toml | 3 + .../tools/gong/manifest.py | 264 +++++++++++ .../tools/gong/outputs.py | 67 +++ .../tools/gong/tests/__init__.py | 1 + .../tools/gong/tests/test_gong.py | 197 ++++++++ src/modulex_integrations/tools/gong/tools.py | 426 ++++++++++++++++++ 10 files changed, 1031 insertions(+) create mode 100644 src/modulex_integrations/tools/gong/README.md create mode 100644 src/modulex_integrations/tools/gong/__init__.py create mode 100644 src/modulex_integrations/tools/gong/dependencies.toml create mode 100644 src/modulex_integrations/tools/gong/manifest.py create mode 100644 src/modulex_integrations/tools/gong/outputs.py create mode 100644 src/modulex_integrations/tools/gong/tests/__init__.py create mode 100644 src/modulex_integrations/tools/gong/tests/test_gong.py create mode 100644 src/modulex_integrations/tools/gong/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30db015..884b00f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `gong` integration — 5 actions, auth: oauth2. Revenue intelligence + platform for recording, transcribing, and analyzing sales conversations + via the Gong REST API (add_new_call, get_extensive_data, list_calls, + list_workspace_id_options, retrieve_transcripts_of_calls). + Producer-staged by integration-drafts; consumer-side audit applied + 3 patches before merge. + - `figma` integration — 3 actions, auth: oauth2. Design collaboration platform for creating, sharing, and commenting on design files via the Figma REST API (list_comments, delete_comment, post_a_comment). diff --git a/pyproject.toml b/pyproject.toml index c1c85c8..78f4735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -124,6 +124,7 @@ cloudflare = "modulex_integrations.tools.cloudflare" segment = "modulex_integrations.tools.segment" semrush = "modulex_integrations.tools.semrush" gmail = "modulex_integrations.tools.gmail" +gong = "modulex_integrations.tools.gong" godaddy = "modulex_integrations.tools.godaddy" google_cloud = "modulex_integrations.tools.google_cloud" google_contacts = "modulex_integrations.tools.google_contacts" @@ -578,6 +579,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/instructure_canvas/manifest.py" = ["E501"] "src/modulex_integrations/tools/instructure_canvas/tools.py" = ["E501"] +# gong manifest and tools have long description string literals in +# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped. +"src/modulex_integrations/tools/gong/manifest.py" = ["E501"] +"src/modulex_integrations/tools/gong/tools.py" = ["E501"] # postgrid manifest and tools have long description string literals in # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/postgrid/manifest.py" = ["E501"] diff --git a/src/modulex_integrations/tools/gong/README.md b/src/modulex_integrations/tools/gong/README.md new file mode 100644 index 0000000..8938bd0 --- /dev/null +++ b/src/modulex_integrations/tools/gong/README.md @@ -0,0 +1,34 @@ +# Gong + +Revenue intelligence platform for recording, transcribing, and analyzing sales conversations via the Gong REST API (`us-66463.api.gong.io/v2`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth app at . +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required scopes: `api:calls:read:basic`, `api:calls:read:extensive`, `api:calls:create`, `api:workspaces:read`, `api:calls:read:transcript` +- Env vars (custom app only): `GONG_OAUTH2_CLIENT_ID`, `GONG_OAUTH2_CLIENT_SECRET` + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `add_new_call` | Add a new call to Gong | `client_unique_id`, `actual_start`, `direction`, `primary_user`, `parties` | +| `get_extensive_data` | List detailed call data with content selectors for topics, trackers, transcripts, and more | (none required) | +| `list_calls` | List calls with optional date range filtering | (none required) | +| `list_workspace_id_options` | Retrieve available workspace IDs and names | (none required) | +| `retrieve_transcripts_of_calls` | Retrieve transcripts of calls with optional date range and call ID filtering | (none required) | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- Gong API rate limits vary by endpoint and plan tier. Consult your Gong admin for specific limits. +- The `get_extensive_data` action paginates internally up to `max_results` (default 600). +- 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/gong/__init__.py b/src/modulex_integrations/tools/gong/__init__.py new file mode 100644 index 0000000..edc6981 --- /dev/null +++ b/src/modulex_integrations/tools/gong/__init__.py @@ -0,0 +1,27 @@ +"""Gong integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.gong.manifest import manifest +from modulex_integrations.tools.gong.tools import ( + add_new_call, + get_extensive_data, + list_calls, + list_workspace_id_options, + retrieve_transcripts_of_calls, +) + +TOOLS = ( + add_new_call, + get_extensive_data, + list_calls, + list_workspace_id_options, + retrieve_transcripts_of_calls, +) + +__all__ = [ + "TOOLS", + "add_new_call", + "get_extensive_data", + "list_calls", + "list_workspace_id_options", + "manifest", + "retrieve_transcripts_of_calls", +] diff --git a/src/modulex_integrations/tools/gong/dependencies.toml b/src/modulex_integrations/tools/gong/dependencies.toml new file mode 100644 index 0000000..f7224f8 --- /dev/null +++ b/src/modulex_integrations/tools/gong/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the gong integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/gong/manifest.py b/src/modulex_integrations/tools/gong/manifest.py new file mode 100644 index 0000000..a10ef35 --- /dev/null +++ b/src/modulex_integrations/tools/gong/manifest.py @@ -0,0 +1,264 @@ +"""Gong integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="gong", + display_name="Gong", + description="Revenue intelligence platform for recording, transcribing, and analyzing sales conversations", + version="1.0.0", + author="ModuleX", + logo="modulex:gong-themed", + app_url="https://www.gong.io", + categories=["Sales", "Revenue Intelligence", "Conversation Analytics"], + actions=[ + ActionDefinition( + name="add_new_call", + description="Add a new call to Gong", + parameters={ + "client_unique_id": ParameterDef( + type="string", + description="A call's unique identifier in the PBX or recording system. Used to prevent duplicate uploads.", + required=True, + ), + "actual_start": ParameterDef( + type="string", + description="The actual date and time when the call started in ISO-8601 format (e.g., 2018-02-18T02:30:00-07:00 or 2018-02-18T08:00:00Z).", + required=True, + ), + "direction": ParameterDef( + type="string", + description="Whether the call is Inbound, Outbound, Conference, or Unknown.", + required=True, + ), + "primary_user": ParameterDef( + type="string", + description="The Gong internal user ID of the team member who hosted the call.", + required=True, + ), + "parties": ParameterDef( + type="array", + description="A list of the call's participants as JSON objects. Each party can have: phoneNumber, emailAddress, name, mediaChannelId.", + required=True, + ), + "title": ParameterDef( + type="string", + description="The title of the call, available for indexing and search.", + ), + "purpose": ParameterDef( + type="string", + description="The purpose of the call. Free text up to 255 characters.", + ), + "scheduled_start": ParameterDef( + type="string", + description="The date and time the call was scheduled to begin in ISO-8601 format.", + ), + "scheduled_end": ParameterDef( + type="string", + description="The date and time the call was scheduled to end in ISO-8601 format.", + ), + "duration": ParameterDef( + type="integer", + description="The actual call duration in seconds.", + ), + "disposition": ParameterDef( + type="string", + description="The disposition of the call. Free text up to 255 characters.", + ), + "meeting_url": ParameterDef( + type="string", + description="The URL of the conference call by which users join the meeting.", + ), + "call_provider_code": ParameterDef( + type="string", + description="Code identifying the conferencing/telephony system: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales.", + ), + "download_media_url": ParameterDef( + type="string", + description="The URL from which Gong can download the media file. Must be unique, max 1.5GB.", + ), + "workspace_id": ParameterDef( + type="string", + description="Optional workspace identifier for call placement.", + ), + "language_code": ParameterDef( + type="string", + description="Language code for transcription (e.g., en-US, fr-FR). Optional; Gong auto-detects if not set.", + ), + }, + ), + ActionDefinition( + name="get_extensive_data", + description="List detailed call data with content selectors for topics, trackers, transcripts, and more", + parameters={ + "from_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) from which to list recorded calls.", + ), + "to_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) until which to list recorded calls.", + ), + "workspace_id": ParameterDef( + type="string", + description="The ID of the workspace to filter by.", + ), + "call_ids": ParameterDef( + type="array", + description="List of call ID strings to filter. If not supplied, returns all calls in date range.", + ), + "primary_user_ids": ParameterDef( + type="array", + description="List of user ID strings. If supplied, returns only calls hosted by these users.", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return.", + default=600, + ), + "context": ParameterDef( + type="string", + description="Context level: None, Basic (add links), or Extended (include link data).", + default="None", + ), + "context_timing": ParameterDef( + type="array", + description="Timing for context data: 'Now' or 'TimeOfCall' or both. Only valid when context is Extended.", + ), + "include_parties": ParameterDef( + type="boolean", + description="Whether to include parties in the response.", + default=False, + ), + "exposed_fields_content": ParameterDef( + type="object", + description="Fields to include for content: structure, topics, trackers, trackerOccurrences, pointsOfInterest, brief, outline, highlights, callOutcome, keyPoints (boolean values).", + ), + "exposed_fields_interaction": ParameterDef( + type="object", + description="Fields to include for interaction: speakers, video, personInteractionStats, questions (boolean values).", + ), + "include_public_comments": ParameterDef( + type="boolean", + description="Whether to include public comments in the response.", + default=False, + ), + "include_media": ParameterDef( + type="boolean", + description="Whether to include media in the response.", + default=False, + ), + }, + ), + ActionDefinition( + name="list_calls", + description="List calls with optional date range filtering", + parameters={ + "from_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) from which to list recorded calls.", + ), + "to_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) until which to list recorded calls.", + ), + "cursor": ParameterDef( + type="string", + description="Pagination cursor returned by a previous call.", + ), + }, + ), + ActionDefinition( + name="list_workspace_id_options", + description="Retrieve available workspace IDs and names", + parameters={}, + ), + ActionDefinition( + name="retrieve_transcripts_of_calls", + description="Retrieve transcripts of calls with optional date range and call ID filtering", + parameters={ + "from_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) from which to filter calls.", + ), + "to_date_time": ParameterDef( + type="string", + description="Date and time (ISO-8601) until which to filter calls.", + ), + "workspace_id": ParameterDef( + type="string", + description="The ID of the workspace to filter by.", + ), + "call_ids": ParameterDef( + type="array", + description="List of call ID strings to retrieve transcripts for.", + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Gong OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="GONG_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Gong OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://app.gong.io/company/api", + ), + EnvVar( + name="GONG_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Gong OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="x" * 40, + about_url="https://app.gong.io/company/api", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://app.gong.io/oauth2/authorize", + token_url="https://app.gong.io/oauth2/generate-customer-token", + scopes=[ + "api:calls:read:basic", + "api:calls:read:extensive", + "api:calls:create", + "api:workspaces:read", + "api:calls:read:transcript", + ], + ), + test_endpoint=TestEndpoint( + url="https://us-66463.api.gong.io/v2/calls", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["requestId"], + ), + cost_level="free", + description="Validates OAuth token by listing calls", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/gong/outputs.py b/src/modulex_integrations/tools/gong/outputs.py new file mode 100644 index 0000000..e73aaf7 --- /dev/null +++ b/src/modulex_integrations/tools/gong/outputs.py @@ -0,0 +1,67 @@ +"""Pydantic response models for the gong integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddNewCallOutput", + "GetExtensiveDataOutput", + "ListCallsOutput", + "ListWorkspaceIdOptionsOutput", + "RetrieveTranscriptsOfCallsOutput", + "Workspace", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class Workspace(_Base): + """A Gong workspace entry.""" + + id: str | None = None + name: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class AddNewCallOutput(_Base): + success: bool + error: str | None = None + request_id: str | None = None + call_id: str | None = None + + +class GetExtensiveDataOutput(_Base): + success: bool + error: str | None = None + calls: list[dict[str, Any]] = Field(default_factory=list) + + +class ListCallsOutput(_Base): + success: bool + error: str | None = None + request_id: str | None = None + cursor: str | None = None + calls: list[dict[str, Any]] = Field(default_factory=list) + + +class ListWorkspaceIdOptionsOutput(_Base): + success: bool + error: str | None = None + workspaces: list[Workspace] = Field(default_factory=list) + + +class RetrieveTranscriptsOfCallsOutput(_Base): + success: bool + error: str | None = None + call_transcripts: list[dict[str, Any]] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/gong/tests/__init__.py b/src/modulex_integrations/tools/gong/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/gong/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/gong/tests/test_gong.py b/src/modulex_integrations/tools/gong/tests/test_gong.py new file mode 100644 index 0000000..9a1af1a --- /dev/null +++ b/src/modulex_integrations/tools/gong/tests/test_gong.py @@ -0,0 +1,197 @@ +"""Happy-path tests for every gong @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.gong import ( + TOOLS, + add_new_call, + get_extensive_data, + list_calls, + list_workspace_id_options, + manifest, + retrieve_transcripts_of_calls, +) +from modulex_integrations.tools.gong.outputs import ( + AddNewCallOutput, + GetExtensiveDataOutput, + ListCallsOutput, + ListWorkspaceIdOptionsOutput, + RetrieveTranscriptsOfCallsOutput, +) + +API = "https://us-66463.api.gong.io/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_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_add_new_call(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/calls", + json={ + # TODO: fill in a representative response shape from the Gong API docs + "requestId": "req-123", + "callId": "call-456", + }, + ) + + result_dict = await add_new_call.ainvoke( + _args( + client_unique_id="unique-id-123", + actual_start="2024-01-15T10:00:00Z", + direction="Outbound", + primary_user="user-789", + parties=[{"emailAddress": "test@example.com", "name": "Test User"}], + ) + ) + + assert isinstance(result_dict, dict) + result = AddNewCallOutput.model_validate(result_dict) + assert result.success is True + assert result.request_id == "req-123" + assert result.call_id == "call-456" + + +@pytest.mark.asyncio +async def test_get_extensive_data(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/calls/extensive", + json={ + # TODO: fill in a representative response shape from the Gong API docs + "requestId": "req-abc", + "records": {"cursor": None, "totalRecords": 1}, + "calls": [{"metaData": {"id": "call-1", "title": "Demo Call"}}], + }, + ) + + result_dict = await get_extensive_data.ainvoke( + _args(from_date_time="2024-01-01T00:00:00Z", to_date_time="2024-01-31T23:59:59Z") + ) + + assert isinstance(result_dict, dict) + result = GetExtensiveDataOutput.model_validate(result_dict) + assert result.success is True + assert len(result.calls) == 1 + + +@pytest.mark.asyncio +async def test_list_calls(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/calls", + json={ + # TODO: fill in a representative response shape from the Gong API docs + "requestId": "req-xyz", + "records": {"cursor": "next-page", "totalRecords": 2}, + "calls": [ + {"id": "call-1", "title": "Call A"}, + {"id": "call-2", "title": "Call B"}, + ], + }, + ) + + result_dict = await list_calls.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListCallsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.calls) == 2 + assert result.request_id == "req-xyz" + + +@pytest.mark.asyncio +async def test_list_workspace_id_options(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/workspaces", + json={ + # TODO: fill in a representative response shape from the Gong API docs + "workspaces": [ + {"id": "ws-1", "name": "Default Workspace"}, + {"id": "ws-2", "name": "Sales Team"}, + ], + }, + ) + + result_dict = await list_workspace_id_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListWorkspaceIdOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.workspaces) == 2 + assert result.workspaces[0].name == "Default Workspace" + + +@pytest.mark.asyncio +async def test_retrieve_transcripts_of_calls(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/calls/transcript", + json={ + # TODO: fill in a representative response shape from the Gong API docs + "requestId": "req-tr-1", + "callTranscripts": [ + { + "callId": "call-1", + "transcript": [ + {"speakerId": "s1", "topic": "Introduction", "sentences": []} + ], + } + ], + }, + ) + + result_dict = await retrieve_transcripts_of_calls.ainvoke( + _args(call_ids=["call-1"]) + ) + + assert isinstance(result_dict, dict) + result = RetrieveTranscriptsOfCallsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.call_transcripts) == 1 + + +# --- Failure-path tests (credential short-circuit) ------------------------- + + +@pytest.mark.asyncio +async def test_list_calls_empty_credential() -> None: + """list_calls must fail immediately when access_token is missing/empty.""" + result_dict = await list_calls.ainvoke( + {"auth_type": "oauth2", "auth_data": {"access_token": ""}} + ) + + assert isinstance(result_dict, dict) + result = ListCallsOutput.model_validate(result_dict) + assert result.success is False + assert "access_token" in (result.error or "") diff --git a/src/modulex_integrations/tools/gong/tools.py b/src/modulex_integrations/tools/gong/tools.py new file mode 100644 index 0000000..bc1a6ea --- /dev/null +++ b/src/modulex_integrations/tools/gong/tools.py @@ -0,0 +1,426 @@ +"""Gong 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.gong.outputs import ( + AddNewCallOutput, + GetExtensiveDataOutput, + ListCallsOutput, + ListWorkspaceIdOptionsOutput, + RetrieveTranscriptsOfCallsOutput, + Workspace, +) + +__all__ = [ + "add_new_call", + "get_extensive_data", + "list_calls", + "list_workspace_id_options", + "retrieve_transcripts_of_calls", +] + +_BASE_URL = "https://us-66463.api.gong.io/v2" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Gong 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 AddNewCallInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + client_unique_id: str = Field(description="A call's unique identifier in the PBX or recording system") + actual_start: str = Field(description="Actual start date/time in ISO-8601 format") + direction: str = Field(description="Call direction: Inbound, Outbound, Conference, or Unknown") + primary_user: str = Field(description="Gong internal user ID of the call host") + parties: list[dict[str, Any]] = Field(description="List of call participants as objects with phoneNumber, emailAddress, name, mediaChannelId") + title: str | None = Field(default=None, description="Title of the call") + purpose: str | None = Field(default=None, description="Purpose of the call, up to 255 characters") + scheduled_start: str | None = Field(default=None, description="Scheduled start in ISO-8601 format") + scheduled_end: str | None = Field(default=None, description="Scheduled end in ISO-8601 format") + duration: int | None = Field(default=None, description="Actual call duration in seconds") + disposition: str | None = Field(default=None, description="Disposition of the call, up to 255 characters") + meeting_url: str | None = Field(default=None, description="Conference call URL") + call_provider_code: str | None = Field(default=None, description="Provider code: zoom, clearslide, gotomeeting, ringcentral, outreach, insidesales") + download_media_url: str | None = Field(default=None, description="URL for Gong to download the media file") + workspace_id: str | None = Field(default=None, description="Workspace identifier for call placement") + language_code: str | None = Field(default=None, description="Language code for transcription (e.g., en-US)") + + +class GetExtensiveDataInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format") + to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format") + workspace_id: str | None = Field(default=None, description="Workspace ID to filter by") + call_ids: list[str] | None = Field(default=None, description="List of call IDs to filter") + primary_user_ids: list[str] | None = Field(default=None, description="List of user IDs to filter by host") + max_results: int = Field(default=600, description="Maximum number of results to return") + context: str = Field(default="None", description="Context level: None, Basic, or Extended") + context_timing: list[str] | None = Field(default=None, description="Timing for context data: Now or TimeOfCall") + include_parties: bool = Field(default=False, description="Whether to include parties") + exposed_fields_content: dict[str, bool] | None = Field(default=None, description="Content fields to include") + exposed_fields_interaction: dict[str, bool] | None = Field(default=None, description="Interaction fields to include") + include_public_comments: bool = Field(default=False, description="Whether to include public comments") + include_media: bool = Field(default=False, description="Whether to include media") + + +class ListCallsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format") + to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format") + cursor: str | None = Field(default=None, description="Pagination cursor from a previous call") + + +class ListWorkspaceIdOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class RetrieveTranscriptsOfCallsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + from_date_time: str | None = Field(default=None, description="Start date/time in ISO-8601 format") + to_date_time: str | None = Field(default=None, description="End date/time in ISO-8601 format") + workspace_id: str | None = Field(default=None, description="Workspace ID to filter by") + call_ids: list[str] | None = Field(default=None, description="List of call IDs to retrieve transcripts for") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=AddNewCallInput) +@serialize_pydantic_return +async def add_new_call( + auth_type: str, + auth_data: dict[str, Any], + client_unique_id: str, + actual_start: str, + direction: str, + primary_user: str, + parties: list[dict[str, Any]], + title: str | None = None, + purpose: str | None = None, + scheduled_start: str | None = None, + scheduled_end: str | None = None, + duration: int | None = None, + disposition: str | None = None, + meeting_url: str | None = None, + call_provider_code: str | None = None, + download_media_url: str | None = None, + workspace_id: str | None = None, + language_code: str | None = None, +) -> AddNewCallOutput: + """Add a new call to Gong.""" + if not auth_data.get("access_token"): + return AddNewCallOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + payload: dict[str, Any] = { + "clientUniqueId": client_unique_id, + "actualStart": actual_start, + "direction": direction, + "primaryUser": primary_user, + "parties": parties, + } + if title is not None: + payload["title"] = title + if purpose is not None: + payload["purpose"] = purpose + if scheduled_start is not None: + payload["scheduledStart"] = scheduled_start + if scheduled_end is not None: + payload["scheduledEnd"] = scheduled_end + if duration is not None: + payload["duration"] = duration + if disposition is not None: + payload["disposition"] = disposition + if meeting_url is not None: + payload["meetingUrl"] = meeting_url + if call_provider_code is not None: + payload["callProviderCode"] = call_provider_code + if download_media_url is not None: + payload["downloadMediaUrl"] = download_media_url + if workspace_id is not None: + payload["workspaceId"] = workspace_id + if language_code is not None: + payload["languageCode"] = language_code + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/calls", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return AddNewCallOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return AddNewCallOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddNewCallOutput(success=False, error=f"Call failed: {exc}") + + return AddNewCallOutput( + success=True, + request_id=data.get("requestId"), + call_id=data.get("callId"), + ) + + +@tool(args_schema=GetExtensiveDataInput) +@serialize_pydantic_return +async def get_extensive_data( + auth_type: str, + auth_data: dict[str, Any], + from_date_time: str | None = None, + to_date_time: str | None = None, + workspace_id: str | None = None, + call_ids: list[str] | None = None, + primary_user_ids: list[str] | None = None, + max_results: int = 600, + context: str = "None", + context_timing: list[str] | None = None, + include_parties: bool = False, + exposed_fields_content: dict[str, bool] | None = None, + exposed_fields_interaction: dict[str, bool] | None = None, + include_public_comments: bool = False, + include_media: bool = False, +) -> GetExtensiveDataOutput: + """List detailed call data with content selectors for topics, trackers, transcripts, and more.""" + if not auth_data.get("access_token"): + return GetExtensiveDataOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + payload: dict[str, Any] = {} + + filter_obj: dict[str, Any] = {} + if from_date_time is not None: + filter_obj["fromDateTime"] = from_date_time + if to_date_time is not None: + filter_obj["toDateTime"] = to_date_time + if workspace_id is not None: + filter_obj["workspaceId"] = workspace_id + if call_ids is not None: + filter_obj["callIds"] = call_ids + if primary_user_ids is not None: + filter_obj["primaryUserIds"] = primary_user_ids + if filter_obj: + payload["filter"] = filter_obj + + content_selector: dict[str, Any] = {} + if context != "None": + content_selector["context"] = context + if context_timing is not None: + content_selector["contextTiming"] = context_timing + if include_parties: + content_selector["includeParties"] = True + if exposed_fields_content is not None: + content_selector["exposedFields"] = content_selector.get("exposedFields", {}) + content_selector["exposedFields"]["content"] = exposed_fields_content + if exposed_fields_interaction is not None: + content_selector["exposedFields"] = content_selector.get("exposedFields", {}) + content_selector["exposedFields"]["interaction"] = exposed_fields_interaction + if include_public_comments: + content_selector["includePublicComments"] = True + if include_media: + content_selector["includeMedia"] = True + if content_selector: + payload["contentSelector"] = content_selector + + all_calls: list[dict[str, Any]] = [] + cursor: str | None = None + collected = 0 + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while collected < max_results: + request_payload = dict(payload) + if cursor is not None: + request_payload["cursor"] = cursor + + response = await client.post( + f"{_BASE_URL}/calls/extensive", + headers=headers, + json=request_payload, + ) + if response.status_code != 200: + return GetExtensiveDataOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + calls = data.get("calls", []) + all_calls.extend(calls) + collected += len(calls) + + records = data.get("records", {}) + cursor = records.get("cursor") + if not cursor or not calls: + break + except httpx.TimeoutException: + return GetExtensiveDataOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetExtensiveDataOutput(success=False, error=f"Call failed: {exc}") + + return GetExtensiveDataOutput(success=True, calls=all_calls[:max_results]) + + +@tool(args_schema=ListCallsInput) +@serialize_pydantic_return +async def list_calls( + auth_type: str, + auth_data: dict[str, Any], + from_date_time: str | None = None, + to_date_time: str | None = None, + cursor: str | None = None, +) -> ListCallsOutput: + """List calls with optional date range filtering.""" + if not auth_data.get("access_token"): + return ListCallsOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + + params: dict[str, str] = {} + if from_date_time is not None: + params["fromDateTime"] = from_date_time + if to_date_time is not None: + params["toDateTime"] = to_date_time + if cursor is not None: + params["cursor"] = cursor + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/calls", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListCallsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListCallsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCallsOutput(success=False, error=f"Call failed: {exc}") + + return ListCallsOutput( + success=True, + request_id=data.get("requestId"), + cursor=(data.get("records") or {}).get("cursor"), + calls=data.get("calls", []), + ) + + +@tool(args_schema=ListWorkspaceIdOptionsInput) +@serialize_pydantic_return +async def list_workspace_id_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListWorkspaceIdOptionsOutput: + """Retrieve available workspace IDs and names.""" + if not auth_data.get("access_token"): + return ListWorkspaceIdOptionsOutput(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}/workspaces", + headers=headers, + ) + if response.status_code != 200: + return ListWorkspaceIdOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListWorkspaceIdOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListWorkspaceIdOptionsOutput(success=False, error=f"Call failed: {exc}") + + raw_workspaces = data.get("workspaces", []) + workspaces = [ + Workspace(id=w.get("id"), name=w.get("name")) + for w in raw_workspaces + ] + return ListWorkspaceIdOptionsOutput(success=True, workspaces=workspaces) + + +@tool(args_schema=RetrieveTranscriptsOfCallsInput) +@serialize_pydantic_return +async def retrieve_transcripts_of_calls( + auth_type: str, + auth_data: dict[str, Any], + from_date_time: str | None = None, + to_date_time: str | None = None, + workspace_id: str | None = None, + call_ids: list[str] | None = None, +) -> RetrieveTranscriptsOfCallsOutput: + """Retrieve transcripts of calls with optional date range and call ID filtering.""" + if not auth_data.get("access_token"): + return RetrieveTranscriptsOfCallsOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + filter_obj: dict[str, Any] = {} + if from_date_time is not None: + filter_obj["fromDateTime"] = from_date_time + if to_date_time is not None: + filter_obj["toDateTime"] = to_date_time + if workspace_id is not None: + filter_obj["workspaceId"] = workspace_id + if call_ids is not None: + filter_obj["callIds"] = call_ids + + payload: dict[str, Any] = {} + if filter_obj: + payload["filter"] = filter_obj + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/calls/transcript", + headers=headers, + json=payload, + ) + if response.status_code != 200: + return RetrieveTranscriptsOfCallsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return RetrieveTranscriptsOfCallsOutput(success=False, error="Request timed out.") + except Exception as exc: + return RetrieveTranscriptsOfCallsOutput(success=False, error=f"Call failed: {exc}") + + return RetrieveTranscriptsOfCallsOutput( + success=True, + call_transcripts=data.get("callTranscripts", []), + ) From a3a77ba7383463922ef2cbbe61f5fb0b30494400 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Thu, 28 May 2026 17:51:14 +0000 Subject: [PATCH 02/15] auto-integrate: hunter Add the `hunter` integration (Hunter.io) with 13 actions for professional email finding and verification. **Actions:** account_information, combined_enrichment, create_lead, delete_lead, domain_search, email_count, email_finder, email_verifier, get_lead, get_leads_list, list_leads, list_leads_lists, update_lead. **Auth:** API key (single `api_key` credential field). **Auditor patches applied (1):** - PATCH #1 (check 8.9, mechanical): Replaced CDN logo URL with `modulex:hunter-themed` placeholder per logo convention. **Merger-applied fixes (2):** - `outputs.py`: Added `from typing import Any` and replaced bare `dict` annotations with `dict[str, Any]` to satisfy mypy --strict. - `pyproject.toml`: Added E501 per-file-ignores for hunter manifest.py, tools.py, and tests/test_hunter.py (long string literals). **Gates:** ruff PASS, mypy --strict PASS, pytest 17/17 PASS. Provider: primary Run: 26591544044 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 7 + pyproject.toml | 6 + .../tools/hunter/README.md | 42 + .../tools/hunter/__init__.py | 51 ++ .../tools/hunter/dependencies.toml | 3 + .../tools/hunter/manifest.py | 417 +++++++++ .../tools/hunter/outputs.py | 157 ++++ .../tools/hunter/tests/__init__.py | 1 + .../tools/hunter/tests/test_hunter.py | 387 +++++++++ .../tools/hunter/tools.py | 815 ++++++++++++++++++ 10 files changed, 1886 insertions(+) create mode 100644 src/modulex_integrations/tools/hunter/README.md create mode 100644 src/modulex_integrations/tools/hunter/__init__.py create mode 100644 src/modulex_integrations/tools/hunter/dependencies.toml create mode 100644 src/modulex_integrations/tools/hunter/manifest.py create mode 100644 src/modulex_integrations/tools/hunter/outputs.py create mode 100644 src/modulex_integrations/tools/hunter/tests/__init__.py create mode 100644 src/modulex_integrations/tools/hunter/tests/test_hunter.py create mode 100644 src/modulex_integrations/tools/hunter/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 884b00f..94b2e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `hunter` integration — 13 actions, auth: api_key. Professional email + finding and verification via the Hunter.io API (account_information, + combined_enrichment, create_lead, delete_lead, domain_search, + email_count, email_finder, email_verifier, get_lead, get_leads_list, + list_leads, list_leads_lists, update_lead). Producer-staged by + integration-drafts; consumer-side audit applied 1 patch before merge. + - `gong` integration — 5 actions, auth: oauth2. Revenue intelligence platform for recording, transcribing, and analyzing sales conversations via the Gong REST API (add_new_call, get_extensive_data, list_calls, diff --git a/pyproject.toml b/pyproject.toml index 78f4735..19b833b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ hackernews = "modulex_integrations.tools.hackernews" help_scout = "modulex_integrations.tools.help_scout" heroku = "modulex_integrations.tools.heroku" hootsuite = "modulex_integrations.tools.hootsuite" +hunter = "modulex_integrations.tools.hunter" lemon_squeezy = "modulex_integrations.tools.lemon_squeezy" short_io = "modulex_integrations.tools.short_io" nasdaq = "modulex_integrations.tools.nasdaq" @@ -587,6 +588,11 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/postgrid/manifest.py" = ["E501"] "src/modulex_integrations/tools/postgrid/tools.py" = ["E501"] +# hunter manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/hunter/manifest.py" = ["E501"] +"src/modulex_integrations/tools/hunter/tools.py" = ["E501"] +"src/modulex_integrations/tools/hunter/tests/test_hunter.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/hunter/README.md b/src/modulex_integrations/tools/hunter/README.md new file mode 100644 index 0000000..37cabdb --- /dev/null +++ b/src/modulex_integrations/tools/hunter/README.md @@ -0,0 +1,42 @@ +# Hunter + +Find and verify professional email addresses, search domains for contacts, and manage leads using the Hunter.io API (`api.hunter.io/v2`). + +## Authentication + +### API Key Authentication + +- Sign in at and navigate to [API Keys](https://hunter.io/api-keys). +- Copy your API key. +- Required env var: `HUNTER_API_KEY` (format: 40-character hex string). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `account_information` | Get information about your Hunter account | _(none)_ | +| `combined_enrichment` | Returns all the information associated with an email address and its domain name | `email` | +| `create_lead` | Create a new lead in your Hunter account | `email` | +| `delete_lead` | Delete an existing lead from your Hunter account | `lead_id` | +| `domain_search` | Search all the email addresses corresponding to one website or company | `limit` | +| `email_count` | Get the number of email addresses Hunter has for one domain or company | _(none)_ | +| `email_finder` | Find the most likely email address from a domain name, a first name and a last name | `first_name`, `last_name` | +| `email_verifier` | Check the deliverability of a given email address | `email` | +| `get_lead` | Retrieve one of your leads by ID | `lead_id` | +| `get_leads_list` | Retrieves all the fields of a leads list, including its leads | `leads_list_id`, `limit` | +| `list_leads` | List all your leads with comprehensive filtering options | `limit` | +| `list_leads_lists` | List all your leads lists, sorted with the most recent first | `limit` | +| `update_lead` | Update an existing lead in your Hunter account | `lead_id` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Free plan**: 25 searches and 50 verifications per month. +- **Paid plans**: Limits scale with plan tier (up to 30,000+ requests/month on Enterprise). +- **Rate limit**: 10 requests/second across all endpoints. +- **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/hunter/__init__.py b/src/modulex_integrations/tools/hunter/__init__.py new file mode 100644 index 0000000..43ab214 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/__init__.py @@ -0,0 +1,51 @@ +"""Hunter integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.hunter.manifest import manifest +from modulex_integrations.tools.hunter.tools import ( + account_information, + combined_enrichment, + create_lead, + delete_lead, + domain_search, + email_count, + email_finder, + email_verifier, + get_lead, + get_leads_list, + list_leads, + list_leads_lists, + update_lead, +) + +TOOLS = ( + account_information, + combined_enrichment, + create_lead, + delete_lead, + domain_search, + email_count, + email_finder, + email_verifier, + get_lead, + get_leads_list, + list_leads, + list_leads_lists, + update_lead, +) + +__all__ = [ + "TOOLS", + "account_information", + "combined_enrichment", + "create_lead", + "delete_lead", + "domain_search", + "email_count", + "email_finder", + "email_verifier", + "get_lead", + "get_leads_list", + "list_leads", + "list_leads_lists", + "manifest", + "update_lead", +] diff --git a/src/modulex_integrations/tools/hunter/dependencies.toml b/src/modulex_integrations/tools/hunter/dependencies.toml new file mode 100644 index 0000000..ad88993 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the hunter integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/hunter/manifest.py b/src/modulex_integrations/tools/hunter/manifest.py new file mode 100644 index 0000000..ff3bef3 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/manifest.py @@ -0,0 +1,417 @@ +"""Hunter integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="hunter", + display_name="Hunter", + description="Find and verify professional email addresses using the Hunter.io API", + version="1.0.0", + author="ModuleX", + logo="modulex:hunter-themed", + app_url="https://hunter.io", + categories=["Marketing & Sales", "Lead Generation", "Email"], + actions=[ + ActionDefinition( + name="account_information", + description="Get information about your Hunter account", + parameters={}, + ), + ActionDefinition( + name="combined_enrichment", + description="Returns all the information associated with an email address and its domain name", + parameters={ + "email": ParameterDef( + type="string", + description="The email address you want to find information about", + required=True, + ), + }, + ), + ActionDefinition( + name="create_lead", + description="Create a new lead in your Hunter account", + parameters={ + "email": ParameterDef( + type="string", + description="The email address of the lead", + required=True, + ), + "first_name": ParameterDef( + type="string", + description="The first name of the lead", + ), + "last_name": ParameterDef( + type="string", + description="The last name of the lead", + ), + "position": ParameterDef( + type="string", + description="The job title of the lead", + ), + "company": ParameterDef( + type="string", + description="The name of the company the lead is working in", + ), + "company_industry": ParameterDef( + type="string", + description="The sector of the company. Allowed values: Animal, Art & Entertainment, Automotive, Beauty & Fitness, Books & Literature, Education & Career, Finance, Food & Drink, Game, Health, Hobby & Leisure, Home & Garden, Industry, Internet & Telecom, Law & Government, Manufacturing, News, Real Estate, Science, Retail, Sport, Technology, Travel", + ), + "company_size": ParameterDef( + type="string", + description="The size of the company the lead is working in", + ), + "confidence_score": ParameterDef( + type="integer", + description="Estimation of the probability the email address returned is correct, between 0 and 100", + ), + "website": ParameterDef( + type="string", + description="The domain name of the company", + ), + "country_code": ParameterDef( + type="string", + description="The country of the lead (ISO 3166-1 alpha-2 standard)", + ), + "linkedin_url": ParameterDef( + type="string", + description="The address of the public profile on LinkedIn", + ), + "phone_number": ParameterDef( + type="string", + description="The phone number of the lead", + ), + "twitter": ParameterDef( + type="string", + description="The Twitter handle of the lead", + ), + "notes": ParameterDef( + type="string", + description="Some personal notes about the lead", + ), + "source": ParameterDef( + type="string", + description="The source where the lead has been found", + ), + "leads_list_id": ParameterDef( + type="string", + description="The identifier of the list the lead belongs to. If not specified, the lead is saved in the last list created", + ), + }, + ), + ActionDefinition( + name="delete_lead", + description="Delete an existing lead from your Hunter account", + parameters={ + "lead_id": ParameterDef( + type="string", + description="The unique identifier of the lead", + required=True, + ), + }, + ), + ActionDefinition( + name="domain_search", + description="Search all the email addresses corresponding to one website or company", + parameters={ + "domain": ParameterDef( + type="string", + description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided", + ), + "company": ParameterDef( + type="string", + description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided", + ), + "limit": ParameterDef( + type="integer", + description="Specifies the max number of email addresses to return", + default=100, + required=True, + ), + "type": ParameterDef( + type="string", + description="Get only personal or generic email addresses. Allowed values: personal, generic", + ), + "seniority": ParameterDef( + type="string", + description="Get only email addresses for people with the selected seniority level(s). Comma-separated values: junior, senior, executive", + ), + "department": ParameterDef( + type="string", + description="Get only email addresses for people working in the selected department(s). Comma-separated values: executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations", + ), + }, + ), + ActionDefinition( + name="email_count", + description="Get the number of email addresses Hunter has for one domain or company", + parameters={ + "domain": ParameterDef( + type="string", + description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided", + ), + "company": ParameterDef( + type="string", + description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided", + ), + "type": ParameterDef( + type="string", + description="Get only personal or generic email addresses. Allowed values: personal, generic", + ), + }, + ), + ActionDefinition( + name="email_finder", + description="Find the most likely email address from a domain name, a first name and a last name", + parameters={ + "domain": ParameterDef( + type="string", + description="Domain name from which you want to find the email addresses. For example, 'stripe.com'. Either domain or company must be provided", + ), + "company": ParameterDef( + type="string", + description="The company name from which you want to find the email addresses. For example, 'stripe'. Either domain or company must be provided", + ), + "first_name": ParameterDef( + type="string", + description="The person's first name", + required=True, + ), + "last_name": ParameterDef( + type="string", + description="The person's last name", + required=True, + ), + }, + ), + ActionDefinition( + name="email_verifier", + description="Check the deliverability of a given email address, verify if it has been found in Hunter's database, and return their sources", + parameters={ + "email": ParameterDef( + type="string", + description="The email address you want to verify", + required=True, + ), + }, + ), + ActionDefinition( + name="get_lead", + description="Retrieve one of your leads by ID", + parameters={ + "lead_id": ParameterDef( + type="string", + description="The unique identifier of the lead", + required=True, + ), + }, + ), + ActionDefinition( + name="get_leads_list", + description="Retrieves all the fields of a leads list, including its leads", + parameters={ + "leads_list_id": ParameterDef( + type="string", + description="Identifier of the leads list to retrieve", + required=True, + ), + "limit": ParameterDef( + type="integer", + description="A limit on the number of leads to be returned. Limit can range between 1 and 100", + default=100, + required=True, + ), + }, + ), + ActionDefinition( + name="list_leads", + description="List all your leads with comprehensive filtering options", + parameters={ + "leads_list_id": ParameterDef( + type="string", + description="Only returns the leads belonging to this list", + ), + "email": ParameterDef( + type="string", + description="Filter leads by email", + ), + "first_name": ParameterDef( + type="string", + description="Filter leads by first name", + ), + "last_name": ParameterDef( + type="string", + description="Filter leads by last name", + ), + "position": ParameterDef( + type="string", + description="Filter leads by position", + ), + "company": ParameterDef( + type="string", + description="Filter leads by company", + ), + "industry": ParameterDef( + type="string", + description="Filter leads by industry", + ), + "website": ParameterDef( + type="string", + description="Filter leads by website", + ), + "country_code": ParameterDef( + type="string", + description="Filter leads by country code (ISO 3166-1 alpha-2)", + ), + "company_size": ParameterDef( + type="string", + description="Filter leads by company size", + ), + "source": ParameterDef( + type="string", + description="Filter leads by source", + ), + "twitter": ParameterDef( + type="string", + description="Filter leads by Twitter handle", + ), + "linkedin_url": ParameterDef( + type="string", + description="Filter leads by LinkedIn URL", + ), + "phone_number": ParameterDef( + type="string", + description="Filter leads by phone number", + ), + "sync_status": ParameterDef( + type="string", + description="Filter by synchronization status. Allowed values: pending, error, success", + ), + "sending_status": ParameterDef( + type="string", + description="Filter by sending status. Comma-separated values: clicked, opened, sent, pending, error, bounced, unsubscribed, replied", + ), + "verification_status": ParameterDef( + type="string", + description="Filter by verification status. Comma-separated values: accept_all, disposable, invalid, unknown, valid, webmail, pending", + ), + "last_activity_at": ParameterDef( + type="string", + description="Filter by last activity. Allowed values: * (any), ~ (unset)", + ), + "last_contacted_at": ParameterDef( + type="string", + description="Filter by last contact date. Allowed values: * (any), ~ (unset)", + ), + "query": ParameterDef( + type="string", + description="Only returns leads with First Name, Last Name, or Email matching the query", + ), + "limit": ParameterDef( + type="integer", + description="A limit on the number of leads to be returned. Limit can range between 1 and 1000", + default=100, + required=True, + ), + }, + ), + ActionDefinition( + name="list_leads_lists", + description="List all your leads lists, sorted with the most recent first", + parameters={ + "limit": ParameterDef( + type="integer", + description="A limit on the number of lists to be returned. Limit can range between 1 and 100", + default=100, + required=True, + ), + }, + ), + ActionDefinition( + name="update_lead", + description="Update an existing lead in your Hunter account", + parameters={ + "lead_id": ParameterDef( + type="string", + description="The unique identifier of the lead", + required=True, + ), + "email": ParameterDef( + type="string", + description="The email address of the lead", + ), + "first_name": ParameterDef( + type="string", + description="The person's first name", + ), + "last_name": ParameterDef( + type="string", + description="The person's last name", + ), + "position": ParameterDef( + type="string", + description="The person's position in the company", + ), + "company": ParameterDef( + type="string", + description="The company name", + ), + "website": ParameterDef( + type="string", + description="The website URL of the company", + ), + "phone_number": ParameterDef( + type="string", + description="The person's phone number", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Hunter API key", + setup_instructions=[ + "Go to https://hunter.io and sign in", + "Navigate to your account API settings at https://hunter.io/api-keys", + "Copy your API key", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="HUNTER_API_KEY", + display_name="Hunter API Key", + description="Your Hunter API key from hunter.io/api-keys", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://hunter.io/api-keys", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.hunter.io/v2/account", + method="GET", + headers={}, + params={"api_key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates the API key by fetching account information", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/hunter/outputs.py b/src/modulex_integrations/tools/hunter/outputs.py new file mode 100644 index 0000000..ab38f32 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/outputs.py @@ -0,0 +1,157 @@ +"""Pydantic response models for the hunter integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AccountInformationOutput", + "CombinedEnrichmentOutput", + "CreateLeadOutput", + "DeleteLeadOutput", + "DomainSearchOutput", + "EmailCountOutput", + "EmailFinderOutput", + "EmailVerifierOutput", + "GetLeadOutput", + "GetLeadsListOutput", + "ListLeadsListsOutput", + "ListLeadsOutput", + "UpdateLeadOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class AccountInformationOutput(_Base): + success: bool + error: str | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + plan_name: str | None = None + plan_level: int | None = None + reset_date: str | None = None + team_id: int | None = None + calls_used: int | None = None + calls_available: int | None = None + + +class CombinedEnrichmentOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CreateLeadOutput(_Base): + success: bool + error: str | None = None + id: int | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + + +class DeleteLeadOutput(_Base): + success: bool + error: str | None = None + + +class DomainSearchOutput(_Base): + success: bool + error: str | None = None + domain: str | None = None + disposable: bool | None = None + webmail: bool | None = None + accept_all: bool | None = None + pattern: str | None = None + organization: str | None = None + emails: list[dict[str, Any]] = Field(default_factory=list) + total_results: int | None = None + + +class EmailCountOutput(_Base): + success: bool + error: str | None = None + total: int | None = None + personal_emails: int | None = None + generic_emails: int | None = None + department: dict[str, Any] | None = None + + +class EmailFinderOutput(_Base): + success: bool + error: str | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + score: int | None = None + domain: str | None = None + accept_all: bool | None = None + position: str | None = None + twitter: str | None = None + linkedin_url: str | None = None + phone_number: str | None = None + company: str | None = None + sources: list[dict[str, Any]] = Field(default_factory=list) + + +class EmailVerifierOutput(_Base): + success: bool + error: str | None = None + status: str | None = None + result: str | None = None + score: int | None = None + email: str | None = None + regexp: bool | None = None + gibberish: bool | None = None + disposable: bool | None = None + webmail: bool | None = None + mx_records: bool | None = None + smtp_server: bool | None = None + smtp_check: bool | None = None + accept_all: bool | None = None + block: bool | None = None + sources: list[dict[str, Any]] = Field(default_factory=list) + + +class GetLeadOutput(_Base): + success: bool + error: str | None = None + id: int | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + position: str | None = None + company: str | None = None + + +class GetLeadsListOutput(_Base): + success: bool + error: str | None = None + id: int | None = None + name: str | None = None + leads: list[dict[str, Any]] = Field(default_factory=list) + + +class ListLeadsListsOutput(_Base): + success: bool + error: str | None = None + leads_lists: list[dict[str, Any]] = Field(default_factory=list) + + +class ListLeadsOutput(_Base): + success: bool + error: str | None = None + leads: list[dict[str, Any]] = Field(default_factory=list) + total: int | None = None + + +class UpdateLeadOutput(_Base): + success: bool + error: str | None = None diff --git a/src/modulex_integrations/tools/hunter/tests/__init__.py b/src/modulex_integrations/tools/hunter/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/hunter/tests/test_hunter.py b/src/modulex_integrations/tools/hunter/tests/test_hunter.py new file mode 100644 index 0000000..5879a40 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/tests/test_hunter.py @@ -0,0 +1,387 @@ +"""Happy-path tests for every hunter @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.hunter import ( + TOOLS, + account_information, + combined_enrichment, + create_lead, + delete_lead, + domain_search, + email_count, + email_finder, + email_verifier, + get_lead, + get_leads_list, + list_leads, + list_leads_lists, + manifest, + update_lead, +) +from modulex_integrations.tools.hunter.outputs import ( + AccountInformationOutput, + CombinedEnrichmentOutput, + CreateLeadOutput, + DeleteLeadOutput, + DomainSearchOutput, + EmailCountOutput, + EmailFinderOutput, + EmailVerifierOutput, + GetLeadOutput, + GetLeadsListOutput, + ListLeadsListsOutput, + ListLeadsOutput, + UpdateLeadOutput, +) + +API = "https://api.hunter.io/v2" + +_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_13_actions(self) -> None: + assert len(manifest.actions) == 13 + + 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_account_information(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/account?api_key={_API_KEY}", + json={ + "data": { + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "plan_name": "Free", + "plan_level": 0, + "reset_date": "2026-06-01", + "team_id": 1, + "calls": {"used": 10, "available": 50}, + } + }, + ) + + result_dict = await account_information.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = AccountInformationOutput.model_validate(result_dict) + assert result.success is True + assert result.email == "user@example.com" + assert result.calls_used == 10 + + +@pytest.mark.asyncio +async def test_combined_enrichment(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/combined/find?api_key={_API_KEY}&email=test%40example.com", + json={ + "data": { + "person": {"first_name": "John"}, + "company": {"name": "Example"}, + } + }, + ) + + result_dict = await combined_enrichment.ainvoke(_args(email="test@example.com")) + + assert isinstance(result_dict, dict) + result = CombinedEnrichmentOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + + +@pytest.mark.asyncio +async def test_create_lead(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/leads?api_key={_API_KEY}", + json={ + "data": { + "id": 123, + "email": "lead@example.com", + "first_name": "Jane", + "last_name": "Smith", + } + }, + ) + + result_dict = await create_lead.ainvoke(_args(email="lead@example.com")) + + assert isinstance(result_dict, dict) + result = CreateLeadOutput.model_validate(result_dict) + assert result.success is True + assert result.id == 123 + + +@pytest.mark.asyncio +async def test_delete_lead(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/leads/456?api_key={_API_KEY}", + status_code=204, + content=b"", + ) + + result_dict = await delete_lead.ainvoke(_args(lead_id="456")) + + assert isinstance(result_dict, dict) + result = DeleteLeadOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_domain_search(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/domain-search?api_key={_API_KEY}&domain=stripe.com&limit=10", + json={ + "data": { + "domain": "stripe.com", + "disposable": False, + "webmail": False, + "accept_all": False, + "pattern": "{first}", + "organization": "Stripe", + "emails": [{"value": "john@stripe.com", "type": "personal"}], + }, + "meta": {"results": 1, "limit": 10, "offset": 0}, + }, + ) + + result_dict = await domain_search.ainvoke(_args(domain="stripe.com", limit=10)) + + assert isinstance(result_dict, dict) + result = DomainSearchOutput.model_validate(result_dict) + assert result.success is True + assert result.domain == "stripe.com" + assert len(result.emails) == 1 + + +@pytest.mark.asyncio +async def test_email_count(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/email-count?api_key={_API_KEY}&domain=stripe.com", + json={ + "data": { + "total": 100, + "personal_emails": 80, + "generic_emails": 20, + "department": {}, + } + }, + ) + + result_dict = await email_count.ainvoke(_args(domain="stripe.com")) + + assert isinstance(result_dict, dict) + result = EmailCountOutput.model_validate(result_dict) + assert result.success is True + assert result.total == 100 + + +@pytest.mark.asyncio +async def test_email_finder(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/email-finder?api_key={_API_KEY}&domain=stripe.com&first_name=John&last_name=Doe", + json={ + "data": { + "email": "john.doe@stripe.com", + "first_name": "John", + "last_name": "Doe", + "score": 92, + "domain": "stripe.com", + "accept_all": False, + "position": "Engineer", + "twitter": None, + "linkedin_url": None, + "phone_number": None, + "company": "Stripe", + "sources": [], + } + }, + ) + + result_dict = await email_finder.ainvoke( + _args(first_name="John", last_name="Doe", domain="stripe.com") + ) + + assert isinstance(result_dict, dict) + result = EmailFinderOutput.model_validate(result_dict) + assert result.success is True + assert result.email == "john.doe@stripe.com" + assert result.score == 92 + + +@pytest.mark.asyncio +async def test_email_verifier(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/email-verifier?api_key={_API_KEY}&email=test%40example.com", + json={ + "data": { + "status": "valid", + "result": "deliverable", + "score": 95, + "email": "test@example.com", + "regexp": True, + "gibberish": False, + "disposable": False, + "webmail": False, + "mx_records": True, + "smtp_server": True, + "smtp_check": True, + "accept_all": False, + "block": False, + "sources": [], + } + }, + ) + + result_dict = await email_verifier.ainvoke(_args(email="test@example.com")) + + assert isinstance(result_dict, dict) + result = EmailVerifierOutput.model_validate(result_dict) + assert result.success is True + assert result.status == "valid" + assert result.score == 95 + + +@pytest.mark.asyncio +async def test_get_lead(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/leads/789?api_key={_API_KEY}", + json={ + "data": { + "id": 789, + "email": "lead@example.com", + "first_name": "Alice", + "last_name": "Johnson", + "position": "CTO", + "company": "Acme", + } + }, + ) + + result_dict = await get_lead.ainvoke(_args(lead_id="789")) + + assert isinstance(result_dict, dict) + result = GetLeadOutput.model_validate(result_dict) + assert result.success is True + assert result.id == 789 + + +@pytest.mark.asyncio +async def test_get_leads_list(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/leads_lists/10?api_key={_API_KEY}&limit=20", + json={ + "data": { + "id": 10, + "name": "My List", + "leads": [{"id": 1, "email": "a@b.com"}], + } + }, + ) + + result_dict = await get_leads_list.ainvoke(_args(leads_list_id="10", limit=20)) + + assert isinstance(result_dict, dict) + result = GetLeadsListOutput.model_validate(result_dict) + assert result.success is True + assert result.name == "My List" + assert len(result.leads) == 1 + + +@pytest.mark.asyncio +async def test_list_leads(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/leads?api_key={_API_KEY}&limit=10", + json={ + "data": { + "leads": [{"id": 1, "email": "a@b.com"}], + }, + "meta": {"total": 1, "limit": 10, "offset": 0}, + }, + ) + + result_dict = await list_leads.ainvoke(_args(limit=10)) + + assert isinstance(result_dict, dict) + result = ListLeadsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.leads) == 1 + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_list_leads_lists(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/leads_lists?api_key={_API_KEY}&limit=10", + json={ + "data": { + "leads_lists": [{"id": 1, "name": "List A"}], + } + }, + ) + + result_dict = await list_leads_lists.ainvoke(_args(limit=10)) + + assert isinstance(result_dict, dict) + result = ListLeadsListsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.leads_lists) == 1 + + +@pytest.mark.asyncio +async def test_update_lead(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/leads/789?api_key={_API_KEY}", + status_code=200, + json={}, + ) + + result_dict = await update_lead.ainvoke( + _args(lead_id="789", first_name="Updated") + ) + + assert isinstance(result_dict, dict) + result = UpdateLeadOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_account_information_validates_empty_api_key() -> None: + result_dict = await account_information.ainvoke({"api_key": ""}) + result = AccountInformationOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/hunter/tools.py b/src/modulex_integrations/tools/hunter/tools.py new file mode 100644 index 0000000..5834c90 --- /dev/null +++ b/src/modulex_integrations/tools/hunter/tools.py @@ -0,0 +1,815 @@ +"""Hunter 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.hunter.outputs import ( + AccountInformationOutput, + CombinedEnrichmentOutput, + CreateLeadOutput, + DeleteLeadOutput, + DomainSearchOutput, + EmailCountOutput, + EmailFinderOutput, + EmailVerifierOutput, + GetLeadOutput, + GetLeadsListOutput, + ListLeadsListsOutput, + ListLeadsOutput, + UpdateLeadOutput, +) + +__all__ = [ + "account_information", + "combined_enrichment", + "create_lead", + "delete_lead", + "domain_search", + "email_count", + "email_finder", + "email_verifier", + "get_lead", + "get_leads_list", + "list_leads", + "list_leads_lists", + "update_lead", +] + +_BASE_URL = "https://api.hunter.io/v2" + + +def _params(api_key: str, **extra: Any) -> dict[str, Any]: + """Build query params with the API key included.""" + p: dict[str, Any] = {"api_key": api_key} + for k, v in extra.items(): + if v is not None: + p[k] = v + return p + + +# --- Input schemas -------------------------------------------------------- + + +class AccountInformationInput(BaseModel): + api_key: str = Field(description="Hunter API key") + + +class CombinedEnrichmentInput(BaseModel): + email: str = Field(description="The email address you want to find information about") + api_key: str = Field(description="Hunter API key") + + +class CreateLeadInput(BaseModel): + email: str = Field(description="The email address of the lead") + api_key: str = Field(description="Hunter API key") + first_name: str | None = Field(default=None, description="The first name of the lead") + last_name: str | None = Field(default=None, description="The last name of the lead") + position: str | None = Field(default=None, description="The job title of the lead") + company: str | None = Field(default=None, description="The name of the company the lead is working in") + company_industry: str | None = Field(default=None, description="The sector of the company") + company_size: str | None = Field(default=None, description="The size of the company") + confidence_score: int | None = Field(default=None, description="Probability the email address is correct, between 0 and 100") + website: str | None = Field(default=None, description="The domain name of the company") + country_code: str | None = Field(default=None, description="The country of the lead (ISO 3166-1 alpha-2)") + linkedin_url: str | None = Field(default=None, description="The public LinkedIn profile URL") + phone_number: str | None = Field(default=None, description="The phone number of the lead") + twitter: str | None = Field(default=None, description="The Twitter handle of the lead") + notes: str | None = Field(default=None, description="Personal notes about the lead") + source: str | None = Field(default=None, description="The source where the lead has been found") + leads_list_id: str | None = Field(default=None, description="The identifier of the list the lead belongs to") + + +class DeleteLeadInput(BaseModel): + lead_id: str = Field(description="The unique identifier of the lead") + api_key: str = Field(description="Hunter API key") + + +class DomainSearchInput(BaseModel): + api_key: str = Field(description="Hunter API key") + domain: str | None = Field(default=None, description="Domain name to search. Either domain or company must be provided") + company: str | None = Field(default=None, description="Company name to search. Either domain or company must be provided") + limit: int = Field(default=100, description="Max number of email addresses to return") + type: str | None = Field(default=None, description="Get only personal or generic email addresses. Allowed values: personal, generic") + seniority: str | None = Field(default=None, description="Seniority level(s), comma-separated: junior, senior, executive") + department: str | None = Field(default=None, description="Department(s), comma-separated: executive, it, finance, management, sales, legal, support, hr, marketing, communication, education, design, health, operations") + + +class EmailCountInput(BaseModel): + api_key: str = Field(description="Hunter API key") + domain: str | None = Field(default=None, description="Domain name. Either domain or company must be provided") + company: str | None = Field(default=None, description="Company name. Either domain or company must be provided") + type: str | None = Field(default=None, description="Get only personal or generic email addresses. Allowed values: personal, generic") + + +class EmailFinderInput(BaseModel): + first_name: str = Field(description="The person's first name") + last_name: str = Field(description="The person's last name") + api_key: str = Field(description="Hunter API key") + domain: str | None = Field(default=None, description="Domain name. Either domain or company must be provided") + company: str | None = Field(default=None, description="Company name. Either domain or company must be provided") + + +class EmailVerifierInput(BaseModel): + email: str = Field(description="The email address you want to verify") + api_key: str = Field(description="Hunter API key") + + +class GetLeadInput(BaseModel): + lead_id: str = Field(description="The unique identifier of the lead") + api_key: str = Field(description="Hunter API key") + + +class GetLeadsListInput(BaseModel): + leads_list_id: str = Field(description="Identifier of the leads list to retrieve") + api_key: str = Field(description="Hunter API key") + limit: int = Field(default=100, description="A limit on the number of leads to be returned (1-100)") + + +class ListLeadsInput(BaseModel): + api_key: str = Field(description="Hunter API key") + limit: int = Field(default=100, description="A limit on the number of leads to be returned (1-1000)") + leads_list_id: str | None = Field(default=None, description="Only returns leads belonging to this list") + email: str | None = Field(default=None, description="Filter leads by email") + first_name: str | None = Field(default=None, description="Filter leads by first name") + last_name: str | None = Field(default=None, description="Filter leads by last name") + position: str | None = Field(default=None, description="Filter leads by position") + company: str | None = Field(default=None, description="Filter leads by company") + industry: str | None = Field(default=None, description="Filter leads by industry") + website: str | None = Field(default=None, description="Filter leads by website") + country_code: str | None = Field(default=None, description="Filter leads by country code (ISO 3166-1 alpha-2)") + company_size: str | None = Field(default=None, description="Filter leads by company size") + source: str | None = Field(default=None, description="Filter leads by source") + twitter: str | None = Field(default=None, description="Filter leads by Twitter handle") + linkedin_url: str | None = Field(default=None, description="Filter leads by LinkedIn URL") + phone_number: str | None = Field(default=None, description="Filter leads by phone number") + sync_status: str | None = Field(default=None, description="Filter by synchronization status: pending, error, success") + sending_status: str | None = Field(default=None, description="Filter by sending status, comma-separated: clicked, opened, sent, pending, error, bounced, unsubscribed, replied") + verification_status: str | None = Field(default=None, description="Filter by verification status, comma-separated: accept_all, disposable, invalid, unknown, valid, webmail, pending") + last_activity_at: str | None = Field(default=None, description="Filter by last activity: * (any), ~ (unset)") + last_contacted_at: str | None = Field(default=None, description="Filter by last contact date: * (any), ~ (unset)") + query: str | None = Field(default=None, description="Search leads by First Name, Last Name, or Email") + + +class ListLeadsListsInput(BaseModel): + api_key: str = Field(description="Hunter API key") + limit: int = Field(default=100, description="A limit on the number of lists to be returned (1-100)") + + +class UpdateLeadInput(BaseModel): + lead_id: str = Field(description="The unique identifier of the lead") + api_key: str = Field(description="Hunter API key") + email: str | None = Field(default=None, description="The email address of the lead") + first_name: str | None = Field(default=None, description="The person's first name") + last_name: str | None = Field(default=None, description="The person's last name") + position: str | None = Field(default=None, description="The person's position in the company") + company: str | None = Field(default=None, description="The company name") + website: str | None = Field(default=None, description="The website URL of the company") + phone_number: str | None = Field(default=None, description="The person's phone number") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=AccountInformationInput) +@serialize_pydantic_return +async def account_information( + api_key: str, +) -> AccountInformationOutput: + """Get information about your Hunter account.""" + if not api_key or not api_key.strip(): + return AccountInformationOutput( + 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}/account", + params=_params(api_key), + ) + if response.status_code != 200: + return AccountInformationOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return AccountInformationOutput(success=False, error="Request timed out.") + except Exception as exc: + return AccountInformationOutput(success=False, error=f"Call failed: {exc}") + + calls = data.get("calls", {}) + return AccountInformationOutput( + success=True, + email=data.get("email"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + plan_name=data.get("plan_name"), + plan_level=data.get("plan_level"), + reset_date=data.get("reset_date"), + team_id=data.get("team_id"), + calls_used=calls.get("used"), + calls_available=calls.get("available"), + ) + + +@tool(args_schema=CombinedEnrichmentInput) +@serialize_pydantic_return +async def combined_enrichment( + email: str, + api_key: str, +) -> CombinedEnrichmentOutput: + """Returns all the information associated with an email address and its domain name.""" + if not api_key or not api_key.strip(): + return CombinedEnrichmentOutput( + 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}/combined/find", + params=_params(api_key, email=email), + ) + if response.status_code != 200: + return CombinedEnrichmentOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return CombinedEnrichmentOutput(success=False, error="Request timed out.") + except Exception as exc: + return CombinedEnrichmentOutput(success=False, error=f"Call failed: {exc}") + + return CombinedEnrichmentOutput(success=True, data=data) + + +@tool(args_schema=CreateLeadInput) +@serialize_pydantic_return +async def create_lead( + email: str, + api_key: str, + first_name: str | None = None, + last_name: str | None = None, + position: str | None = None, + company: str | None = None, + company_industry: str | None = None, + company_size: str | None = None, + confidence_score: int | None = None, + website: str | None = None, + country_code: str | None = None, + linkedin_url: str | None = None, + phone_number: str | None = None, + twitter: str | None = None, + notes: str | None = None, + source: str | None = None, + leads_list_id: str | None = None, +) -> CreateLeadOutput: + """Create a new lead in your Hunter account.""" + if not api_key or not api_key.strip(): + return CreateLeadOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = {"email": email} + for field_name, value in [ + ("first_name", first_name), + ("last_name", last_name), + ("position", position), + ("company", company), + ("company_industry", company_industry), + ("company_size", company_size), + ("confidence_score", confidence_score), + ("website", website), + ("country_code", country_code), + ("linkedin_url", linkedin_url), + ("phone_number", phone_number), + ("twitter", twitter), + ("notes", notes), + ("source", source), + ("leads_list_id", leads_list_id), + ]: + if value is not None: + body[field_name] = value + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/leads", + params=_params(api_key), + json=body, + ) + if response.status_code not in (200, 201): + return CreateLeadOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return CreateLeadOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateLeadOutput(success=False, error=f"Call failed: {exc}") + + return CreateLeadOutput( + success=True, + id=data.get("id"), + email=data.get("email"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + ) + + +@tool(args_schema=DeleteLeadInput) +@serialize_pydantic_return +async def delete_lead( + lead_id: str, + api_key: str, +) -> DeleteLeadOutput: + """Delete an existing lead from your Hunter account.""" + if not api_key or not api_key.strip(): + return DeleteLeadOutput( + 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.delete( + f"{_BASE_URL}/leads/{lead_id}", + params=_params(api_key), + ) + if response.status_code not in (200, 204): + return DeleteLeadOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return DeleteLeadOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteLeadOutput(success=False, error=f"Call failed: {exc}") + + return DeleteLeadOutput(success=True) + + +@tool(args_schema=DomainSearchInput) +@serialize_pydantic_return +async def domain_search( + api_key: str, + domain: str | None = None, + company: str | None = None, + limit: int = 100, + type: str | None = None, + seniority: str | None = None, + department: str | None = None, +) -> DomainSearchOutput: + """Search all the email addresses corresponding to one website or company.""" + if not api_key or not api_key.strip(): + return DomainSearchOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not domain and not company: + return DomainSearchOutput( + success=False, + error="Either domain or company must be provided.", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/domain-search", + params=_params( + api_key, + domain=domain, + company=company, + limit=limit, + type=type, + seniority=seniority, + department=department, + ), + ) + if response.status_code != 200: + return DomainSearchOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + resp = response.json() + data = resp.get("data", {}) + meta = resp.get("meta", {}) + except httpx.TimeoutException: + return DomainSearchOutput(success=False, error="Request timed out.") + except Exception as exc: + return DomainSearchOutput(success=False, error=f"Call failed: {exc}") + + return DomainSearchOutput( + success=True, + domain=data.get("domain"), + disposable=data.get("disposable"), + webmail=data.get("webmail"), + accept_all=data.get("accept_all"), + pattern=data.get("pattern"), + organization=data.get("organization"), + emails=data.get("emails", []), + total_results=meta.get("results"), + ) + + +@tool(args_schema=EmailCountInput) +@serialize_pydantic_return +async def email_count( + api_key: str, + domain: str | None = None, + company: str | None = None, + type: str | None = None, +) -> EmailCountOutput: + """Get the number of email addresses Hunter has for one domain or company.""" + if not api_key or not api_key.strip(): + return EmailCountOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not domain and not company: + return EmailCountOutput( + success=False, + error="Either domain or company must be provided.", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/email-count", + params=_params(api_key, domain=domain, company=company, type=type), + ) + if response.status_code != 200: + return EmailCountOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return EmailCountOutput(success=False, error="Request timed out.") + except Exception as exc: + return EmailCountOutput(success=False, error=f"Call failed: {exc}") + + return EmailCountOutput( + success=True, + total=data.get("total"), + personal_emails=data.get("personal_emails"), + generic_emails=data.get("generic_emails"), + department=data.get("department"), + ) + + +@tool(args_schema=EmailFinderInput) +@serialize_pydantic_return +async def email_finder( + first_name: str, + last_name: str, + api_key: str, + domain: str | None = None, + company: str | None = None, +) -> EmailFinderOutput: + """Find the most likely email address from a domain name, a first name and a last name.""" + if not api_key or not api_key.strip(): + return EmailFinderOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not domain and not company: + return EmailFinderOutput( + success=False, + error="Either domain or company must be provided.", + ) + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + f"{_BASE_URL}/email-finder", + params=_params( + api_key, + domain=domain, + company=company, + first_name=first_name, + last_name=last_name, + ), + ) + if response.status_code != 200: + return EmailFinderOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return EmailFinderOutput(success=False, error="Request timed out.") + except Exception as exc: + return EmailFinderOutput(success=False, error=f"Call failed: {exc}") + + return EmailFinderOutput( + success=True, + email=data.get("email"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + score=data.get("score"), + domain=data.get("domain"), + accept_all=data.get("accept_all"), + position=data.get("position"), + twitter=data.get("twitter"), + linkedin_url=data.get("linkedin_url"), + phone_number=data.get("phone_number"), + company=data.get("company"), + sources=data.get("sources", []), + ) + + +@tool(args_schema=EmailVerifierInput) +@serialize_pydantic_return +async def email_verifier( + email: str, + api_key: str, +) -> EmailVerifierOutput: + """Check the deliverability of a given email address, verify if it has been found in Hunter's database, and return their sources.""" + if not api_key or not api_key.strip(): + return EmailVerifierOutput( + 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}/email-verifier", + params=_params(api_key, email=email), + ) + if response.status_code != 200: + return EmailVerifierOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return EmailVerifierOutput(success=False, error="Request timed out.") + except Exception as exc: + return EmailVerifierOutput(success=False, error=f"Call failed: {exc}") + + return EmailVerifierOutput( + success=True, + status=data.get("status"), + result=data.get("result"), + score=data.get("score"), + email=data.get("email"), + regexp=data.get("regexp"), + gibberish=data.get("gibberish"), + disposable=data.get("disposable"), + webmail=data.get("webmail"), + mx_records=data.get("mx_records"), + smtp_server=data.get("smtp_server"), + smtp_check=data.get("smtp_check"), + accept_all=data.get("accept_all"), + block=data.get("block"), + sources=data.get("sources", []), + ) + + +@tool(args_schema=GetLeadInput) +@serialize_pydantic_return +async def get_lead( + lead_id: str, + api_key: str, +) -> GetLeadOutput: + """Retrieve one of your leads by ID.""" + if not api_key or not api_key.strip(): + return GetLeadOutput( + 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}/leads/{lead_id}", + params=_params(api_key), + ) + if response.status_code != 200: + return GetLeadOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return GetLeadOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetLeadOutput(success=False, error=f"Call failed: {exc}") + + return GetLeadOutput( + success=True, + id=data.get("id"), + email=data.get("email"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + position=data.get("position"), + company=data.get("company"), + ) + + +@tool(args_schema=GetLeadsListInput) +@serialize_pydantic_return +async def get_leads_list( + leads_list_id: str, + api_key: str, + limit: int = 100, +) -> GetLeadsListOutput: + """Retrieves all the fields of a leads list, including its leads.""" + if not api_key or not api_key.strip(): + return GetLeadsListOutput( + 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}/leads_lists/{leads_list_id}", + params=_params(api_key, limit=limit), + ) + if response.status_code != 200: + return GetLeadsListOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return GetLeadsListOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetLeadsListOutput(success=False, error=f"Call failed: {exc}") + + return GetLeadsListOutput( + success=True, + id=data.get("id"), + name=data.get("name"), + leads=data.get("leads", []), + ) + + +@tool(args_schema=ListLeadsInput) +@serialize_pydantic_return +async def list_leads( + api_key: str, + limit: int = 100, + leads_list_id: str | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + position: str | None = None, + company: str | None = None, + industry: str | None = None, + website: str | None = None, + country_code: str | None = None, + company_size: str | None = None, + source: str | None = None, + twitter: str | None = None, + linkedin_url: str | None = None, + phone_number: str | None = None, + sync_status: str | None = None, + sending_status: str | None = None, + verification_status: str | None = None, + last_activity_at: str | None = None, + last_contacted_at: str | None = None, + query: str | None = None, +) -> ListLeadsOutput: + """List all your leads with comprehensive filtering options.""" + if not api_key or not api_key.strip(): + return ListLeadsOutput( + 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}/leads", + params=_params( + api_key, + limit=limit, + leads_list_id=leads_list_id, + email=email, + first_name=first_name, + last_name=last_name, + position=position, + company=company, + industry=industry, + website=website, + country_code=country_code, + company_size=company_size, + source=source, + twitter=twitter, + linkedin_url=linkedin_url, + phone_number=phone_number, + sync_status=sync_status, + sending_status=sending_status, + verification_status=verification_status, + last_activity_at=last_activity_at, + last_contacted_at=last_contacted_at, + query=query, + ), + ) + if response.status_code != 200: + return ListLeadsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + resp = response.json() + data = resp.get("data", {}) + meta = resp.get("meta", {}) + except httpx.TimeoutException: + return ListLeadsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListLeadsOutput(success=False, error=f"Call failed: {exc}") + + return ListLeadsOutput( + success=True, + leads=data.get("leads", []), + total=meta.get("total"), + ) + + +@tool(args_schema=ListLeadsListsInput) +@serialize_pydantic_return +async def list_leads_lists( + api_key: str, + limit: int = 100, +) -> ListLeadsListsOutput: + """List all your leads lists, sorted with the most recent first.""" + if not api_key or not api_key.strip(): + return ListLeadsListsOutput( + 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}/leads_lists", + params=_params(api_key, limit=limit), + ) + if response.status_code != 200: + return ListLeadsListsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json().get("data", {}) + except httpx.TimeoutException: + return ListLeadsListsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListLeadsListsOutput(success=False, error=f"Call failed: {exc}") + + return ListLeadsListsOutput( + success=True, + leads_lists=data.get("leads_lists", []), + ) + + +@tool(args_schema=UpdateLeadInput) +@serialize_pydantic_return +async def update_lead( + lead_id: str, + api_key: str, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + position: str | None = None, + company: str | None = None, + website: str | None = None, + phone_number: str | None = None, +) -> UpdateLeadOutput: + """Update an existing lead in your Hunter account.""" + if not api_key or not api_key.strip(): + return UpdateLeadOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + body: dict[str, Any] = {} + for field_name, value in [ + ("email", email), + ("first_name", first_name), + ("last_name", last_name), + ("position", position), + ("company", company), + ("website", website), + ("phone_number", phone_number), + ]: + if value is not None: + body[field_name] = value + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.put( + f"{_BASE_URL}/leads/{lead_id}", + params=_params(api_key), + json=body, + ) + if response.status_code not in (200, 204): + return UpdateLeadOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return UpdateLeadOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateLeadOutput(success=False, error=f"Call failed: {exc}") + + return UpdateLeadOutput(success=True) From 139dacab3fc4dd10d17a61d102530e5e5089a3e9 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Thu, 28 May 2026 20:12:00 +0000 Subject: [PATCH 03/15] auto-integrate: square MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit integrates the **square** tool into the modulex-integrations consumer repository. Square provides payment processing, commerce, and business management capabilities via the Square Connect v2 API. **Actions shipped (6):** create_customer, create_invoice, create_order, list_event_types_options, list_location_options, send_invoice. **Auth:** OAuth2 (scopes: CUSTOMERS_WRITE, CUSTOMERS_READ, ORDERS_WRITE, ORDERS_READ, INVOICES_WRITE, INVOICES_READ, MERCHANT_PROFILE_READ). **Auditor patches applied (4):** 1. manifest.py — Logo identifier corrected from logos:square-icon to modulex:square-themed per the modulex theming convention (check 8.9). 2. tools.py — Credential-validity short-circuit guard added to all 6 tool functions (check 8.4). 3. tools.py — URL path-interpolation of invoice_id wrapped with urllib.parse.quote() to prevent path-traversal (check 8.1). 4. tests/test_square.py — Failure-path test appended (check 6.5). **Gates:** ruff ✓ / mypy --strict ✓ / pytest 10 passed, 0 deferred. Provider: primary Run: 26598672224 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 6 + .../tools/square/README.md | 37 ++ .../tools/square/__init__.py | 30 ++ .../tools/square/dependencies.toml | 3 + .../tools/square/manifest.py | 195 ++++++++ .../tools/square/outputs.py | 97 ++++ .../tools/square/tests/__init__.py | 1 + .../tools/square/tests/test_square.py | 237 ++++++++++ .../tools/square/tools.py | 444 ++++++++++++++++++ 10 files changed, 1056 insertions(+) create mode 100644 src/modulex_integrations/tools/square/README.md create mode 100644 src/modulex_integrations/tools/square/__init__.py create mode 100644 src/modulex_integrations/tools/square/dependencies.toml create mode 100644 src/modulex_integrations/tools/square/manifest.py create mode 100644 src/modulex_integrations/tools/square/outputs.py create mode 100644 src/modulex_integrations/tools/square/tests/__init__.py create mode 100644 src/modulex_integrations/tools/square/tests/test_square.py create mode 100644 src/modulex_integrations/tools/square/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 94b2e08..5ae9979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `square` integration — 6 actions, auth: oauth2. Payment processing, + commerce, and business management platform via the Square Connect v2 API + (create_customer, create_invoice, create_order, list_event_types_options, + list_location_options, send_invoice). Producer-staged by + integration-drafts; consumer-side audit applied 4 patches before merge. + - `hunter` integration — 13 actions, auth: api_key. Professional email finding and verification via the Hunter.io API (account_information, combined_enrichment, create_lead, delete_lead, domain_search, diff --git a/pyproject.toml b/pyproject.toml index 19b833b..82ce614 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ datadog = "modulex_integrations.tools.datadog" postgresql = "modulex_integrations.tools.postgresql" mysql = "modulex_integrations.tools.mysql" snowflake = "modulex_integrations.tools.snowflake" +square = "modulex_integrations.tools.square" supabase = "modulex_integrations.tools.supabase" hubspot = "modulex_integrations.tools.hubspot" notion = "modulex_integrations.tools.notion" @@ -593,6 +594,11 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] "src/modulex_integrations/tools/hunter/manifest.py" = ["E501"] "src/modulex_integrations/tools/hunter/tools.py" = ["E501"] "src/modulex_integrations/tools/hunter/tests/test_hunter.py" = ["E501"] +# square manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/square/manifest.py" = ["E501"] +"src/modulex_integrations/tools/square/tools.py" = ["E501"] +"src/modulex_integrations/tools/square/tests/test_square.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/square/README.md b/src/modulex_integrations/tools/square/README.md new file mode 100644 index 0000000..212c8c2 --- /dev/null +++ b/src/modulex_integrations/tools/square/README.md @@ -0,0 +1,37 @@ +# Square + +Payment processing, commerce, and business management via the Square Connect API (`connect.squareup.com/v2`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth app at . +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Scopes requested: `CUSTOMERS_WRITE`, `CUSTOMERS_READ`, `ORDERS_WRITE`, `ORDERS_READ`, `INVOICES_WRITE`, `INVOICES_READ`, `MERCHANT_PROFILE_READ` +- Required env vars (only when bringing your own OAuth app): + - `SQUARE_OAUTH2_CLIENT_ID` (format: `sq0idp-xxxxxxxxxxxxxxxx`) + - `SQUARE_OAUTH2_CLIENT_SECRET` (format: `sq0csp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_customer` | Create a new customer for a business | _(at least one of given_name, family_name, company_name, email_address, phone_number)_ | +| `create_invoice` | Create a draft invoice for an order | `location_id`, `order_id`, `customer_id`, `due_date`, `accepted_payment_methods` | +| `create_order` | Create a new order with product line items | `location_id` | +| `list_event_types_options` | Retrieve available webhook event types | _(none)_ | +| `list_location_options` | Retrieve locations for the authenticated account | _(none)_ | +| `send_invoice` | Publish the latest version of a specified invoice | `location_id`, `invoice_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**: Square enforces per-endpoint rate limits, typically 30-100 requests per 30 seconds depending on the endpoint category. +- **Sandbox**: Square provides a sandbox environment for testing at `connect.squareupsandbox.com`. +- **Error model**: Non-2xx responses and timeouts are caught and returned as `success=False` + `error` rather than raising. Square errors include a JSON `errors[]` array with `category`, `code`, and `detail` fields. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/square/__init__.py b/src/modulex_integrations/tools/square/__init__.py new file mode 100644 index 0000000..a7252ab --- /dev/null +++ b/src/modulex_integrations/tools/square/__init__.py @@ -0,0 +1,30 @@ +"""Square integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.square.manifest import manifest +from modulex_integrations.tools.square.tools import ( + create_customer, + create_invoice, + create_order, + list_event_types_options, + list_location_options, + send_invoice, +) + +TOOLS = ( + create_customer, + create_invoice, + create_order, + list_event_types_options, + list_location_options, + send_invoice, +) + +__all__ = [ + "TOOLS", + "create_customer", + "create_invoice", + "create_order", + "list_event_types_options", + "list_location_options", + "manifest", + "send_invoice", +] diff --git a/src/modulex_integrations/tools/square/dependencies.toml b/src/modulex_integrations/tools/square/dependencies.toml new file mode 100644 index 0000000..bea2095 --- /dev/null +++ b/src/modulex_integrations/tools/square/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the square integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/square/manifest.py b/src/modulex_integrations/tools/square/manifest.py new file mode 100644 index 0000000..40207d8 --- /dev/null +++ b/src/modulex_integrations/tools/square/manifest.py @@ -0,0 +1,195 @@ +"""Square integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="square", + display_name="Square", + description="Payment processing, commerce, and business management platform", + version="1.0.0", + author="ModuleX", + logo="modulex:square-themed", + app_url="https://squareup.com", + categories=["payments", "commerce", "finance"], + actions=[ + ActionDefinition( + name="create_customer", + description="Create a new customer for a business. Must provide at least one of: given_name, family_name, company_name, email_address, or phone_number.", + parameters={ + "given_name": ParameterDef( + type="string", + description="The first name associated with the customer profile", + ), + "family_name": ParameterDef( + type="string", + description="The last name associated with the customer profile", + ), + "company_name": ParameterDef( + type="string", + description="A business name associated with the customer profile", + ), + "email_address": ParameterDef( + type="string", + description="The email address associated with the customer profile", + ), + "phone_number": ParameterDef( + type="string", + description="Phone number (9-16 digits, optional + prefix and country code)", + ), + "reference_id": ParameterDef( + type="string", + description="An optional second ID to associate the customer with an entity in another system", + ), + "note": ParameterDef( + type="string", + description="A custom note associated with the customer profile", + ), + }, + ), + ActionDefinition( + name="create_invoice", + description="Create a draft invoice for an order. You must publish (send) the invoice before Square can process it.", + parameters={ + "location_id": ParameterDef( + type="string", + description="The ID of the Square location", + required=True, + ), + "order_id": ParameterDef( + type="string", + description="The ID of the order associated with the invoice", + required=True, + ), + "customer_id": ParameterDef( + type="string", + description="The ID of the customer who will receive the invoice", + required=True, + ), + "due_date": ParameterDef( + type="string", + description="The due date for the payment request, in YYYY-MM-DD format", + required=True, + ), + "accepted_payment_methods": ParameterDef( + type="array", + description="Payment methods customers can use. Valid values: card, square_gift_card, bank_account, buy_now_pay_later, cash_app_pay", + required=True, + ), + }, + ), + ActionDefinition( + name="create_order", + description="Create a new order that can include information about products for purchase.", + parameters={ + "location_id": ParameterDef( + type="string", + description="The ID of the Square location for the order", + required=True, + ), + "customer_id": ParameterDef( + type="string", + description="The ID of the customer associated with the order", + ), + "reference_id": ParameterDef( + type="string", + description="An optional second ID to associate the order with an entity in another system", + ), + "line_items": ParameterDef( + type="object", + description="Line items for the order. Array of objects, each with: quantity (string), name (string), base_price_money ({amount: int in cents, currency: string e.g. 'USD'})", + ), + }, + ), + ActionDefinition( + name="list_event_types_options", + description="Retrieve the list of available webhook event types from Square.", + parameters={}, + ), + ActionDefinition( + name="list_location_options", + description="Retrieve the list of locations for the authenticated Square account.", + parameters={}, + ), + ActionDefinition( + name="send_invoice", + description="Publish the latest version of a specified invoice so Square can process it.", + parameters={ + "location_id": ParameterDef( + type="string", + description="The ID of the Square location", + required=True, + ), + "invoice_id": ParameterDef( + type="string", + description="The ID of the invoice to publish", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Square OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="SQUARE_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Square OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + sample_format="sq0idp-xxxxxxxxxxxxxxxx", + about_url="https://developer.squareup.com/apps", + ), + EnvVar( + name="SQUARE_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Square OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + sample_format="sq0csp-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://developer.squareup.com/apps", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://connect.squareup.com/oauth2/authorize", + token_url="https://connect.squareup.com/oauth2/token", + scopes=[ + "CUSTOMERS_WRITE", + "CUSTOMERS_READ", + "ORDERS_WRITE", + "ORDERS_READ", + "INVOICES_WRITE", + "INVOICES_READ", + "MERCHANT_PROFILE_READ", + ], + ), + test_endpoint=TestEndpoint( + url="https://connect.squareup.com/v2/locations", + method="GET", + headers={"Authorization": "Bearer {access_token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["locations"], + ), + cost_level="free", + description="Validates OAuth token by listing locations", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/square/outputs.py b/src/modulex_integrations/tools/square/outputs.py new file mode 100644 index 0000000..af3a20a --- /dev/null +++ b/src/modulex_integrations/tools/square/outputs.py @@ -0,0 +1,97 @@ +"""Pydantic response models for the square integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateCustomerOutput", + "CreateInvoiceOutput", + "CreateOrderOutput", + "CustomerResource", + "InvoiceResource", + "ListEventTypesOptionsOutput", + "ListLocationOptionsOutput", + "LocationOption", + "OrderResource", + "SendInvoiceOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class CustomerResource(_Base): + id: str | None = None + given_name: str | None = None + family_name: str | None = None + company_name: str | None = None + email_address: str | None = None + phone_number: str | None = None + reference_id: str | None = None + note: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class InvoiceResource(_Base): + id: str | None = None + version: int | None = None + location_id: str | None = None + order_id: str | None = None + status: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class OrderResource(_Base): + id: str | None = None + location_id: str | None = None + reference_id: str | None = None + state: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class LocationOption(_Base): + id: str | None = None + name: str | None = None + status: str | None = None + + +class CreateCustomerOutput(_Base): + success: bool + error: str | None = None + customer: CustomerResource | None = None + + +class CreateInvoiceOutput(_Base): + success: bool + error: str | None = None + invoice: InvoiceResource | None = None + + +class CreateOrderOutput(_Base): + success: bool + error: str | None = None + order: OrderResource | None = None + + +class ListEventTypesOptionsOutput(_Base): + success: bool + error: str | None = None + event_types: list[str] = Field(default_factory=list) + + +class ListLocationOptionsOutput(_Base): + success: bool + error: str | None = None + locations: list[LocationOption] = Field(default_factory=list) + + +class SendInvoiceOutput(_Base): + success: bool + error: str | None = None + invoice: InvoiceResource | None = None diff --git a/src/modulex_integrations/tools/square/tests/__init__.py b/src/modulex_integrations/tools/square/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/square/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/square/tests/test_square.py b/src/modulex_integrations/tools/square/tests/test_square.py new file mode 100644 index 0000000..ee33493 --- /dev/null +++ b/src/modulex_integrations/tools/square/tests/test_square.py @@ -0,0 +1,237 @@ +"""Happy-path tests for every square @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.square import ( + TOOLS, + create_customer, + create_invoice, + create_order, + list_event_types_options, + list_location_options, + manifest, + send_invoice, +) +from modulex_integrations.tools.square.outputs import ( + CreateCustomerOutput, + CreateInvoiceOutput, + CreateOrderOutput, + ListEventTypesOptionsOutput, + ListLocationOptionsOutput, + SendInvoiceOutput, +) + +API = "https://connect.squareup.com/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_6_actions(self) -> None: + assert len(manifest.actions) == 6 + + 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_customer(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/customers", + json={ + # TODO: fill in a representative response shape from the Square API docs + "customer": { + "id": "CUST123", + "given_name": "John", + "family_name": "Doe", + "email_address": "john@example.com", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, + }, + ) + + result_dict = await create_customer.ainvoke(_args(given_name="John", family_name="Doe", email_address="john@example.com")) + + assert isinstance(result_dict, dict) + result = CreateCustomerOutput.model_validate(result_dict) + assert result.success is True + assert result.customer is not None + assert result.customer.id == "CUST123" + + +@pytest.mark.asyncio +async def test_create_invoice(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/invoices", + json={ + # TODO: fill in a representative response shape from the Square API docs + "invoice": { + "id": "INV123", + "version": 0, + "location_id": "LOC1", + "order_id": "ORD1", + "status": "DRAFT", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, + }, + ) + + result_dict = await create_invoice.ainvoke( + _args( + location_id="LOC1", + order_id="ORD1", + customer_id="CUST1", + due_date="2024-02-01", + accepted_payment_methods=["card", "bank_account"], + ) + ) + + assert isinstance(result_dict, dict) + result = CreateInvoiceOutput.model_validate(result_dict) + assert result.success is True + assert result.invoice is not None + assert result.invoice.id == "INV123" + + +@pytest.mark.asyncio +async def test_create_order(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/orders", + json={ + # TODO: fill in a representative response shape from the Square API docs + "order": { + "id": "ORD123", + "location_id": "LOC1", + "state": "OPEN", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, + }, + ) + + result_dict = await create_order.ainvoke(_args(location_id="LOC1")) + + assert isinstance(result_dict, dict) + result = CreateOrderOutput.model_validate(result_dict) + assert result.success is True + assert result.order is not None + assert result.order.id == "ORD123" + + +@pytest.mark.asyncio +async def test_list_event_types_options(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/webhooks/event-types", + json={ + # TODO: fill in a representative response shape from the Square API docs + "event_types": ["payment.created", "payment.updated", "order.created"], + }, + ) + + result_dict = await list_event_types_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListEventTypesOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.event_types) == 3 + + +@pytest.mark.asyncio +async def test_list_location_options(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/locations", + json={ + # TODO: fill in a representative response shape from the Square API docs + "locations": [ + {"id": "LOC1", "name": "Main Store", "status": "ACTIVE"}, + {"id": "LOC2", "name": "Warehouse", "status": "ACTIVE"}, + ], + }, + ) + + result_dict = await list_location_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListLocationOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.locations) == 2 + assert result.locations[0].id == "LOC1" + + +@pytest.mark.asyncio +async def test_send_invoice(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/invoices/INV123", + json={ + "invoice": { + "id": "INV123", + "version": 1, + "status": "DRAFT", + }, + }, + ) + httpx_mock.add_response( + method="POST", + url=f"{API}/invoices/INV123/publish", + json={ + # TODO: fill in a representative response shape from the Square API docs + "invoice": { + "id": "INV123", + "version": 2, + "location_id": "LOC1", + "order_id": "ORD1", + "status": "PUBLISHED", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z", + }, + }, + ) + + result_dict = await send_invoice.ainvoke(_args(location_id="LOC1", invoice_id="INV123")) + + assert isinstance(result_dict, dict) + result = SendInvoiceOutput.model_validate(result_dict) + assert result.success is True + assert result.invoice is not None + assert result.invoice.status == "PUBLISHED" + + +@pytest.mark.asyncio +async def test_create_customer_empty_credentials() -> None: + """Verify that empty credentials return an inline error without hitting the network.""" + result_dict = await create_customer.ainvoke( + _args(auth_data={"access_token": ""}, given_name="Test") + ) + assert isinstance(result_dict, dict) + result = CreateCustomerOutput.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/square/tools.py b/src/modulex_integrations/tools/square/tools.py new file mode 100644 index 0000000..adc4d66 --- /dev/null +++ b/src/modulex_integrations/tools/square/tools.py @@ -0,0 +1,444 @@ +"""Square LangChain @tool functions.""" +from __future__ import annotations + +import uuid +from typing import Any +from urllib.parse import quote + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.square.outputs import ( + CreateCustomerOutput, + CreateInvoiceOutput, + CreateOrderOutput, + CustomerResource, + InvoiceResource, + ListEventTypesOptionsOutput, + ListLocationOptionsOutput, + LocationOption, + OrderResource, + SendInvoiceOutput, +) + +__all__ = [ + "create_customer", + "create_invoice", + "create_order", + "list_event_types_options", + "list_location_options", + "send_invoice", +] + +_BASE_URL = "https://connect.squareup.com/v2" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Square 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 CreateCustomerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + given_name: str | None = Field(default=None, description="The first name associated with the customer profile") + family_name: str | None = Field(default=None, description="The last name associated with the customer profile") + company_name: str | None = Field(default=None, description="A business name associated with the customer profile") + email_address: str | None = Field(default=None, description="The email address associated with the customer profile") + phone_number: str | None = Field(default=None, description="Phone number (9-16 digits, optional + prefix and country code)") + reference_id: str | None = Field(default=None, description="An optional second ID to associate the customer with an entity in another system") + note: str | None = Field(default=None, description="A custom note associated with the customer profile") + + +class CreateInvoiceInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + location_id: str = Field(description="The ID of the Square location") + order_id: str = Field(description="The ID of the order associated with the invoice") + customer_id: str = Field(description="The ID of the customer who will receive the invoice") + due_date: str = Field(description="The due date for the payment request, in YYYY-MM-DD format") + accepted_payment_methods: list[str] = Field(description="Payment methods customers can use. Valid values: card, square_gift_card, bank_account, buy_now_pay_later, cash_app_pay") + + +class CreateOrderInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + location_id: str = Field(description="The ID of the Square location for the order") + customer_id: str | None = Field(default=None, description="The ID of the customer associated with the order") + reference_id: str | None = Field(default=None, description="An optional second ID to associate the order with an entity in another system") + line_items: list[dict[str, Any]] | None = Field(default=None, description="Line items for the order. Array of objects, each with: quantity (string), name (string), base_price_money ({amount: int in cents, currency: string e.g. 'USD'})") + + +class ListEventTypesOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class ListLocationOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class SendInvoiceInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + location_id: str = Field(description="The ID of the Square location") + invoice_id: str = Field(description="The ID of the invoice to publish") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateCustomerInput) +@serialize_pydantic_return +async def create_customer( + auth_type: str, + auth_data: dict[str, Any], + given_name: str | None = None, + family_name: str | None = None, + company_name: str | None = None, + email_address: str | None = None, + phone_number: str | None = None, + reference_id: str | None = None, + note: str | None = None, +) -> CreateCustomerOutput: + """Create a new customer for a business. Must provide at least one of: given_name, family_name, company_name, email_address, or phone_number.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateCustomerOutput(success=False, error="Missing OAuth access token.") + headers = _get_auth_headers(auth_type, auth_data) + body: dict[str, Any] = {"idempotency_key": str(uuid.uuid4())} + if given_name: + body["given_name"] = given_name + if family_name: + body["family_name"] = family_name + if company_name: + body["company_name"] = company_name + if email_address: + body["email_address"] = email_address + if phone_number: + body["phone_number"] = phone_number + if reference_id: + body["reference_id"] = reference_id + if note: + body["note"] = note + + 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 != 200: + return CreateCustomerOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateCustomerOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateCustomerOutput(success=False, error=f"Call failed: {exc}") + + c = data.get("customer", {}) + return CreateCustomerOutput( + success=True, + customer=CustomerResource( + id=c.get("id"), + given_name=c.get("given_name"), + family_name=c.get("family_name"), + company_name=c.get("company_name"), + email_address=c.get("email_address"), + phone_number=c.get("phone_number"), + reference_id=c.get("reference_id"), + note=c.get("note"), + created_at=c.get("created_at"), + updated_at=c.get("updated_at"), + ), + ) + + +@tool(args_schema=CreateInvoiceInput) +@serialize_pydantic_return +async def create_invoice( + auth_type: str, + auth_data: dict[str, Any], + location_id: str, + order_id: str, + customer_id: str, + due_date: str, + accepted_payment_methods: list[str], +) -> CreateInvoiceOutput: + """Create a draft invoice for an order. You must publish (send) the invoice before Square can process it.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateInvoiceOutput(success=False, error="Missing OAuth access token.") + headers = _get_auth_headers(auth_type, auth_data) + payment_methods_obj: dict[str, bool] = {} + for method in accepted_payment_methods: + payment_methods_obj[method] = True + + body: dict[str, Any] = { + "idempotency_key": str(uuid.uuid4()), + "invoice": { + "location_id": location_id, + "order_id": order_id, + "primary_recipient": {"customer_id": customer_id}, + "payment_requests": [ + { + "request_type": "BALANCE", + "due_date": due_date, + "automatic_payment_source": "NONE", + "reminders": [], + }, + ], + "delivery_method": "EMAIL", + "accepted_payment_methods": payment_methods_obj, + }, + } + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/invoices", + headers=headers, + json=body, + ) + if response.status_code != 200: + return CreateInvoiceOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateInvoiceOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateInvoiceOutput(success=False, error=f"Call failed: {exc}") + + inv = data.get("invoice", {}) + return CreateInvoiceOutput( + success=True, + invoice=InvoiceResource( + id=inv.get("id"), + version=inv.get("version"), + location_id=inv.get("location_id"), + order_id=inv.get("order_id"), + status=inv.get("status"), + created_at=inv.get("created_at"), + updated_at=inv.get("updated_at"), + ), + ) + + +@tool(args_schema=CreateOrderInput) +@serialize_pydantic_return +async def create_order( + auth_type: str, + auth_data: dict[str, Any], + location_id: str, + customer_id: str | None = None, + reference_id: str | None = None, + line_items: list[dict[str, Any]] | None = None, +) -> CreateOrderOutput: + """Create a new order that can include information about products for purchase.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateOrderOutput(success=False, error="Missing OAuth access token.") + headers = _get_auth_headers(auth_type, auth_data) + order: dict[str, Any] = {"location_id": location_id} + if customer_id: + order["customer_id"] = customer_id + if reference_id: + order["reference_id"] = reference_id + if line_items: + order["line_items"] = line_items + + body: dict[str, Any] = { + "idempotency_key": str(uuid.uuid4()), + "order": order, + } + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/orders", + headers=headers, + json=body, + ) + if response.status_code != 200: + return CreateOrderOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateOrderOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateOrderOutput(success=False, error=f"Call failed: {exc}") + + o = data.get("order", {}) + return CreateOrderOutput( + success=True, + order=OrderResource( + id=o.get("id"), + location_id=o.get("location_id"), + reference_id=o.get("reference_id"), + state=o.get("state"), + created_at=o.get("created_at"), + updated_at=o.get("updated_at"), + ), + ) + + +@tool(args_schema=ListEventTypesOptionsInput) +@serialize_pydantic_return +async def list_event_types_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListEventTypesOptionsOutput: + """Retrieve the list of available webhook event types from Square.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return ListEventTypesOptionsOutput(success=False, error="Missing OAuth 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}/webhooks/event-types", + headers=headers, + ) + if response.status_code != 200: + return ListEventTypesOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListEventTypesOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListEventTypesOptionsOutput(success=False, error=f"Call failed: {exc}") + + return ListEventTypesOptionsOutput( + success=True, + event_types=data.get("event_types", []), + ) + + +@tool(args_schema=ListLocationOptionsInput) +@serialize_pydantic_return +async def list_location_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListLocationOptionsOutput: + """Retrieve the list of locations for the authenticated Square account.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return ListLocationOptionsOutput(success=False, error="Missing OAuth 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}/locations", + headers=headers, + ) + if response.status_code != 200: + return ListLocationOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListLocationOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListLocationOptionsOutput(success=False, error=f"Call failed: {exc}") + + locations_data = data.get("locations", []) + locations = [ + LocationOption( + id=loc.get("id"), + name=loc.get("name"), + status=loc.get("status"), + ) + for loc in locations_data + ] + return ListLocationOptionsOutput(success=True, locations=locations) + + +@tool(args_schema=SendInvoiceInput) +@serialize_pydantic_return +async def send_invoice( + auth_type: str, + auth_data: dict[str, Any], + location_id: str, + invoice_id: str, +) -> SendInvoiceOutput: + """Publish the latest version of a specified invoice so Square can process it.""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return SendInvoiceOutput(success=False, error="Missing OAuth access token.") + headers = _get_auth_headers(auth_type, auth_data) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + get_response = await client.get( + f"{_BASE_URL}/invoices/{quote(invoice_id, safe='')}", + headers=headers, + ) + if get_response.status_code != 200: + return SendInvoiceOutput( + success=False, + error=f"Failed to retrieve invoice ({get_response.status_code}): {get_response.text}", + ) + invoice_data = get_response.json().get("invoice", {}) + version = invoice_data.get("version", 0) + + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/invoices/{quote(invoice_id, safe='')}/publish", + headers=headers, + json={ + "idempotency_key": str(uuid.uuid4()), + "version": version, + }, + ) + if response.status_code != 200: + return SendInvoiceOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SendInvoiceOutput(success=False, error="Request timed out.") + except Exception as exc: + return SendInvoiceOutput(success=False, error=f"Call failed: {exc}") + + inv = data.get("invoice", {}) + return SendInvoiceOutput( + success=True, + invoice=InvoiceResource( + id=inv.get("id"), + version=inv.get("version"), + location_id=inv.get("location_id"), + order_id=inv.get("order_id"), + status=inv.get("status"), + created_at=inv.get("created_at"), + updated_at=inv.get("updated_at"), + ), + ) From 178dfffef12cb55047eb1d951efc5c64a6d3e416 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Fri, 29 May 2026 01:43:42 +0000 Subject: [PATCH 04/15] auto-integrate: yelp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Add `yelp` integration — 4 actions, auth: api_key** Integrates the Yelp Fusion API (v3) with the following actions: - `search_businesses` — Search businesses by location, term, categories, price, and attributes with automatic pagination - `get_business_details` — Get detailed information about a specific business by Yelp ID or alias - `list_business_reviews` — List reviews for a specific business - `search_businesses_by_phone_number` — Search for businesses by phone number (E.164 format) **Authentication:** Single `api_key` auth schema using Bearer token against `https://api.yelp.com/v3`. **Patches applied (1):** - `manifest.py` line 23 — mechanical fix: changed `logo="logos:yelp-icon"` to `logo="modulex:yelp-themed"` per project logo convention (check 8.9). **Test fixes applied during merge:** - `tests/test_yelp.py` — Fixed `httpx_mock` URL matching to use regex patterns for URLs with query strings. **Drift warnings:** - [WARN] 8.1 — URL path interpolation of `business_id_or_alias` at tools.py:199,241. REST path segment, low risk. **Producer feedback:** - Logo value `logos:yelp-icon` emitted instead of `modulex:yelp-themed`. Recurring pattern — producer template should emit correct convention directly. Provider: primary Run: 26612509562 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 5 + src/modulex_integrations/tools/yelp/README.md | 34 ++ .../tools/yelp/__init__.py | 24 ++ .../tools/yelp/dependencies.toml | 3 + .../tools/yelp/manifest.py | 155 ++++++++ .../tools/yelp/outputs.py | 83 +++++ .../tools/yelp/tests/__init__.py | 1 + .../tools/yelp/tests/test_yelp.py | 182 ++++++++++ src/modulex_integrations/tools/yelp/tools.py | 332 ++++++++++++++++++ 10 files changed, 825 insertions(+) create mode 100644 src/modulex_integrations/tools/yelp/README.md create mode 100644 src/modulex_integrations/tools/yelp/__init__.py create mode 100644 src/modulex_integrations/tools/yelp/dependencies.toml create mode 100644 src/modulex_integrations/tools/yelp/manifest.py create mode 100644 src/modulex_integrations/tools/yelp/outputs.py create mode 100644 src/modulex_integrations/tools/yelp/tests/__init__.py create mode 100644 src/modulex_integrations/tools/yelp/tests/test_yelp.py create mode 100644 src/modulex_integrations/tools/yelp/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ae9979..f6d77ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `yelp` integration — 4 actions, auth: api_key. Search businesses, read + reviews, and get business details via the Yelp Fusion API + (search_businesses, get_business_details, list_business_reviews, + search_businesses_by_phone_number). Producer-staged by + integration-drafts; consumer-side audit applied 1 patch before merge. + - `square` integration — 6 actions, auth: oauth2. Payment processing, commerce, and business management platform via the Square Connect v2 API (create_customer, create_invoice, create_order, list_event_types_options, diff --git a/pyproject.toml b/pyproject.toml index 82ce614..cf8a4da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -187,6 +187,7 @@ okta = "modulex_integrations.tools.okta" pagerduty = "modulex_integrations.tools.pagerduty" shopify = "modulex_integrations.tools.shopify" shopify_partner = "modulex_integrations.tools.shopify_partner" +yelp = "modulex_integrations.tools.yelp" zoom = "modulex_integrations.tools.zoom" [tool.hatch.version] @@ -599,6 +600,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] "src/modulex_integrations/tools/square/manifest.py" = ["E501"] "src/modulex_integrations/tools/square/tools.py" = ["E501"] "src/modulex_integrations/tools/square/tests/test_square.py" = ["E501"] +# yelp manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/yelp/manifest.py" = ["E501"] +"src/modulex_integrations/tools/yelp/tools.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/yelp/README.md b/src/modulex_integrations/tools/yelp/README.md new file mode 100644 index 0000000..5c9684d --- /dev/null +++ b/src/modulex_integrations/tools/yelp/README.md @@ -0,0 +1,34 @@ +# Yelp + +Search for businesses, read reviews, and get business details via the Yelp Fusion API (`api.yelp.com/v3`). + +## Authentication + +### API Key Authentication + +- Sign in at and navigate to "Manage App" or create a new app. +- Copy your API Key from the app settings page. +- Required env var: `YELP_API_KEY` (format: long alphanumeric string). +- The API key is sent as a Bearer token in the Authorization header. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `search_businesses` | Search businesses matching given criteria such as location, term, categories, price, and attributes | (one of `location` or `latitude`+`longitude`) | +| `get_business_details` | Get detailed information about a specific business by its Yelp ID or alias | `business_id_or_alias` | +| `list_business_reviews` | List the reviews for a specific business | `business_id_or_alias` | +| `search_businesses_by_phone_number` | Search for businesses by phone number | `phone` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Rate limit**: 5,000 API calls per day (per Yelp Fusion free tier). +- **Search pagination**: Maximum offset of 1,000 results; each page returns up to 50 businesses. +- **Reviews**: Returns up to 3 reviews per business (Yelp API limitation). +- **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/yelp/__init__.py b/src/modulex_integrations/tools/yelp/__init__.py new file mode 100644 index 0000000..687bccb --- /dev/null +++ b/src/modulex_integrations/tools/yelp/__init__.py @@ -0,0 +1,24 @@ +"""Yelp integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.yelp.manifest import manifest +from modulex_integrations.tools.yelp.tools import ( + get_business_details, + list_business_reviews, + search_businesses, + search_businesses_by_phone_number, +) + +TOOLS = ( + search_businesses, + get_business_details, + list_business_reviews, + search_businesses_by_phone_number, +) + +__all__ = [ + "TOOLS", + "get_business_details", + "list_business_reviews", + "manifest", + "search_businesses", + "search_businesses_by_phone_number", +] diff --git a/src/modulex_integrations/tools/yelp/dependencies.toml b/src/modulex_integrations/tools/yelp/dependencies.toml new file mode 100644 index 0000000..895b8e7 --- /dev/null +++ b/src/modulex_integrations/tools/yelp/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the yelp integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/yelp/manifest.py b/src/modulex_integrations/tools/yelp/manifest.py new file mode 100644 index 0000000..4c1d45f --- /dev/null +++ b/src/modulex_integrations/tools/yelp/manifest.py @@ -0,0 +1,155 @@ +"""Yelp integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="yelp", + display_name="Yelp", + description="Search for businesses, read reviews, and get business details via the Yelp Fusion API", + version="1.0.0", + author="ModuleX", + logo="modulex:yelp-themed", + app_url="https://www.yelp.com", + categories=["Local Services", "Reviews", "Business Data"], + actions=[ + ActionDefinition( + name="search_businesses", + description="Search businesses matching given criteria such as location, term, categories, price, and attributes", + parameters={ + "location": ParameterDef( + type="string", + description="Geographic area to search. Examples: 'New York City', '350 5th Ave, New York, NY 10118'. Required if latitude and longitude are not provided.", + ), + "latitude": ParameterDef( + type="string", + description="Latitude of the location to search from. Required if location is not provided.", + ), + "longitude": ParameterDef( + type="string", + description="Longitude of the location to search from. Required if location is not provided.", + ), + "term": ParameterDef( + type="string", + description="Search term, e.g. 'food' or 'restaurants'. May also be a business name like 'Starbucks'.", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of businesses to return. Yelp enforces a limit of 1000.", + default=200, + ), + "categories": ParameterDef( + type="string", + description="Comma-separated category aliases to filter results (e.g. 'discgolf,restaurants'). See Yelp docs for supported categories.", + ), + "price": ParameterDef( + type="string", + description="Comma-separated pricing levels: 1 ($), 2 ($$), 3 ($$$), 4 ($$$$). Example: '1,2'.", + ), + "attributes": ParameterDef( + type="string", + description="Comma-separated additional filters: hot_and_new, request_a_quote, reservation, waitlist_reservation, deals, gender_neutral_restrooms, open_to_all, wheelchair_accessible.", + ), + }, + ), + ActionDefinition( + name="get_business_details", + description="Get detailed information about a specific business by its Yelp ID or alias", + parameters={ + "business_id_or_alias": ParameterDef( + type="string", + description="A unique identifier for a Yelp Business. Can be a 22-character Yelp Business ID or a Yelp Business Alias.", + required=True, + ), + "device_platform": ParameterDef( + type="string", + description="Determines the platform for mobile_link. Allowed values: android, ios, mobile-generic.", + ), + "locale": ParameterDef( + type="string", + description="Locale code in the format {language}_{country} (e.g. en_US).", + ), + }, + ), + ActionDefinition( + name="list_business_reviews", + description="List the reviews for a specific business", + parameters={ + "business_id_or_alias": ParameterDef( + type="string", + description="A unique identifier for a Yelp Business. Can be a 22-character Yelp Business ID or a Yelp Business Alias.", + required=True, + ), + "locale": ParameterDef( + type="string", + description="Locale code in the format {language}_{country} (e.g. en_US).", + ), + "sort_by": ParameterDef( + type="string", + description="Sort order for reviews. Allowed values: yelp_sort, newest.", + ), + }, + ), + ActionDefinition( + name="search_businesses_by_phone_number", + description="Search for businesses by phone number", + parameters={ + "phone": ParameterDef( + type="string", + description="Phone number to search for. Must start with + and include the country code, e.g. +14159083801.", + required=True, + ), + "locale": ParameterDef( + type="string", + description="Locale code in the format {language}_{country} (e.g. en_US).", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Yelp Fusion API key", + setup_instructions=[ + "Go to https://www.yelp.com/developers and sign in", + "Navigate to 'Manage App' or create a new app", + "Copy your API Key from the app settings", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="YELP_API_KEY", + display_name="Yelp API Key", + description="Your Yelp Fusion API key from yelp.com/developers", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://www.yelp.com/developers/v3/manage_app", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.yelp.com/v3/businesses/search", + method="GET", + headers={"Authorization": "Bearer {api_key}"}, + params={"location": "San Francisco", "limit": "1"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["businesses"], + ), + cost_level="free", + description="Validates the API key by searching for one business in San Francisco", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/yelp/outputs.py b/src/modulex_integrations/tools/yelp/outputs.py new file mode 100644 index 0000000..8914590 --- /dev/null +++ b/src/modulex_integrations/tools/yelp/outputs.py @@ -0,0 +1,83 @@ +"""Pydantic response models for the yelp integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "BusinessSummary", + "GetBusinessDetailsOutput", + "ListBusinessReviewsOutput", + "ReviewSummary", + "SearchBusinessesByPhoneNumberOutput", + "SearchBusinessesOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class BusinessSummary(_Base): + """A business object returned by Yelp search endpoints.""" + + id: str | None = None + alias: str | None = None + name: str | None = None + image_url: str | None = None + url: str | None = None + review_count: int | None = None + categories: list[dict[str, Any]] = Field(default_factory=list) + rating: float | None = None + coordinates: dict[str, Any] | None = None + location: dict[str, Any] | None = None + phone: str | None = None + display_phone: str | None = None + distance: float | None = None + + +class ReviewSummary(_Base): + """A review object returned by the reviews endpoint.""" + + id: str | None = None + url: str | None = None + text: str | None = None + rating: int | None = None + time_created: str | None = None + user: dict[str, Any] | None = None + + +# --- Per-action output models --------------------------------------------- + + +class SearchBusinessesOutput(_Base): + success: bool + error: str | None = None + businesses: list[BusinessSummary] = Field(default_factory=list) + total: int = 0 + + +class GetBusinessDetailsOutput(_Base): + success: bool + error: str | None = None + business: dict[str, Any] | None = None + + +class ListBusinessReviewsOutput(_Base): + success: bool + error: str | None = None + reviews: list[ReviewSummary] = Field(default_factory=list) + total: int = 0 + possible_languages: list[str] = Field(default_factory=list) + + +class SearchBusinessesByPhoneNumberOutput(_Base): + success: bool + error: str | None = None + businesses: list[BusinessSummary] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/yelp/tests/__init__.py b/src/modulex_integrations/tools/yelp/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/yelp/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/yelp/tests/test_yelp.py b/src/modulex_integrations/tools/yelp/tests/test_yelp.py new file mode 100644 index 0000000..3ffef5c --- /dev/null +++ b/src/modulex_integrations/tools/yelp/tests/test_yelp.py @@ -0,0 +1,182 @@ +"""Happy-path tests for every yelp @tool, plus a manifest sanity check.""" +from __future__ import annotations + +import re +from typing import Any + +import pytest + +from modulex_integrations.tools.yelp import ( + TOOLS, + get_business_details, + list_business_reviews, + manifest, + search_businesses, + search_businesses_by_phone_number, +) +from modulex_integrations.tools.yelp.outputs import ( + GetBusinessDetailsOutput, + ListBusinessReviewsOutput, + SearchBusinessesByPhoneNumberOutput, + SearchBusinessesOutput, +) + +API = "https://api.yelp.com/v3" + +_API_KEY = "fake-yelp-api-key" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(api_key=_API_KEY, **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_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_search_businesses(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=re.compile(rf"^{re.escape(API)}/businesses/search\?"), + json={ + "total": 1, + "businesses": [ + { + "id": "abc123", + "alias": "test-biz-sf", + "name": "Test Biz", + "rating": 4.5, + "review_count": 100, + "categories": [{"alias": "restaurants", "title": "Restaurants"}], + "coordinates": {"latitude": 37.7749, "longitude": -122.4194}, + "location": {"city": "San Francisco", "state": "CA"}, + "phone": "+14151234567", + "display_phone": "(415) 123-4567", + } + ], + }, + ) + + result_dict = await search_businesses.ainvoke( + _args(location="San Francisco", max_results=50) + ) + + assert isinstance(result_dict, dict) + result = SearchBusinessesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.businesses) == 1 + assert result.businesses[0].name == "Test Biz" + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_search_businesses_validates_empty_api_key() -> None: + result_dict = await search_businesses.ainvoke( + {"location": "NYC", "api_key": ""} + ) + result = SearchBusinessesOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") + + +@pytest.mark.asyncio +async def test_get_business_details(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/businesses/test-biz-sf", + json={ + # TODO: fill in a representative response shape from the Yelp Fusion API docs + "id": "abc123", + "alias": "test-biz-sf", + "name": "Test Biz", + "rating": 4.5, + "review_count": 100, + "url": "https://www.yelp.com/biz/test-biz-sf", + }, + ) + + result_dict = await get_business_details.ainvoke( + _args(business_id_or_alias="test-biz-sf") + ) + + assert isinstance(result_dict, dict) + result = GetBusinessDetailsOutput.model_validate(result_dict) + assert result.success is True + assert result.business is not None + assert result.business["name"] == "Test Biz" + + +@pytest.mark.asyncio +async def test_list_business_reviews(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/businesses/test-biz-sf/reviews", + json={ + # TODO: fill in a representative response shape from the Yelp Fusion API docs + "total": 1, + "possible_languages": ["en"], + "reviews": [ + { + "id": "rev123", + "url": "https://www.yelp.com/biz/test-biz-sf?hrid=rev123", + "text": "Great place!", + "rating": 5, + "time_created": "2024-01-15 12:00:00", + "user": {"id": "user1", "name": "John D."}, + } + ], + }, + ) + + result_dict = await list_business_reviews.ainvoke( + _args(business_id_or_alias="test-biz-sf") + ) + + assert isinstance(result_dict, dict) + result = ListBusinessReviewsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.reviews) == 1 + assert result.reviews[0].rating == 5 + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_search_businesses_by_phone_number(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=re.compile(rf"^{re.escape(API)}/businesses/search/phone\?"), + json={ + "businesses": [ + { + "id": "abc123", + "alias": "test-biz-sf", + "name": "Test Biz", + "phone": "+14151234567", + } + ], + }, + ) + + result_dict = await search_businesses_by_phone_number.ainvoke( + _args(phone="+14151234567") + ) + + assert isinstance(result_dict, dict) + result = SearchBusinessesByPhoneNumberOutput.model_validate(result_dict) + assert result.success is True + assert len(result.businesses) == 1 + assert result.businesses[0].phone == "+14151234567" diff --git a/src/modulex_integrations/tools/yelp/tools.py b/src/modulex_integrations/tools/yelp/tools.py new file mode 100644 index 0000000..41e55f7 --- /dev/null +++ b/src/modulex_integrations/tools/yelp/tools.py @@ -0,0 +1,332 @@ +"""Yelp 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.yelp.outputs import ( + BusinessSummary, + GetBusinessDetailsOutput, + ListBusinessReviewsOutput, + ReviewSummary, + SearchBusinessesByPhoneNumberOutput, + SearchBusinessesOutput, +) + +__all__ = [ + "get_business_details", + "list_business_reviews", + "search_businesses", + "search_businesses_by_phone_number", +] + +_BASE_URL = "https://api.yelp.com/v3" +_TIMEOUT = 30.0 +_PAGE_SIZE = 50 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class SearchBusinessesInput(BaseModel): + location: str | None = Field(default=None, description="Geographic area to search. Required if latitude and longitude are not provided.") + latitude: str | None = Field(default=None, description="Latitude of the location to search from.") + longitude: str | None = Field(default=None, description="Longitude of the location to search from.") + term: str | None = Field(default=None, description="Search term, e.g. 'food' or 'restaurants'.") + max_results: int = Field(default=200, description="Maximum number of businesses to return (max 1000).") + categories: str | None = Field(default=None, description="Comma-separated category aliases to filter results.") + price: str | None = Field(default=None, description="Comma-separated pricing levels: 1, 2, 3, 4.") + attributes: str | None = Field(default=None, description="Comma-separated additional filters.") + api_key: str = Field(description="Yelp Fusion API key") + + +class GetBusinessDetailsInput(BaseModel): + business_id_or_alias: str = Field(description="A unique identifier for a Yelp Business (ID or alias).") + device_platform: str | None = Field(default=None, description="Platform for mobile_link: android, ios, mobile-generic.") + locale: str | None = Field(default=None, description="Locale code (e.g. en_US).") + api_key: str = Field(description="Yelp Fusion API key") + + +class ListBusinessReviewsInput(BaseModel): + business_id_or_alias: str = Field(description="A unique identifier for a Yelp Business (ID or alias).") + locale: str | None = Field(default=None, description="Locale code (e.g. en_US).") + sort_by: str | None = Field(default=None, description="Sort order: yelp_sort or newest.") + api_key: str = Field(description="Yelp Fusion API key") + + +class SearchBusinessesByPhoneNumberInput(BaseModel): + phone: str = Field(description="Phone number starting with + and country code, e.g. +14159083801.") + locale: str | None = Field(default=None, description="Locale code (e.g. en_US).") + api_key: str = Field(description="Yelp Fusion API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=SearchBusinessesInput) +@serialize_pydantic_return +async def search_businesses( + api_key: str, + location: str | None = None, + latitude: str | None = None, + longitude: str | None = None, + term: str | None = None, + max_results: int = 200, + categories: str | None = None, + price: str | None = None, + attributes: str | None = None, +) -> SearchBusinessesOutput: + """Search businesses matching given criteria such as location, term, categories, price, and attributes""" + if not api_key or not api_key.strip(): + return SearchBusinessesOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not location and not (latitude and longitude): + return SearchBusinessesOutput( + success=False, + error="Either 'location' or both 'latitude' and 'longitude' must be provided.", + ) + + all_businesses: list[dict[str, Any]] = [] + total = 0 + offset = 0 + limit = min(max_results, _PAGE_SIZE) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while len(all_businesses) < max_results: + params: dict[str, Any] = {"limit": limit, "offset": offset} + if location: + params["location"] = location + if latitude: + params["latitude"] = latitude + if longitude: + params["longitude"] = longitude + if term: + params["term"] = term + if categories: + params["categories"] = categories + if price: + params["price"] = price + if attributes: + params["attributes"] = attributes + + response = await client.get( + f"{_BASE_URL}/businesses/search", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return SearchBusinessesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + total = data.get("total", 0) + businesses = data.get("businesses", []) + if not businesses: + break + all_businesses.extend(businesses) + offset += len(businesses) + if offset >= total or len(businesses) < limit: + break + except httpx.TimeoutException: + return SearchBusinessesOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchBusinessesOutput(success=False, error=f"Call failed: {exc}") + + trimmed = all_businesses[:max_results] + return SearchBusinessesOutput( + success=True, + businesses=[ + BusinessSummary( + id=b.get("id"), + alias=b.get("alias"), + name=b.get("name"), + image_url=b.get("image_url"), + url=b.get("url"), + review_count=b.get("review_count"), + categories=b.get("categories", []), + rating=b.get("rating"), + coordinates=b.get("coordinates"), + location=b.get("location"), + phone=b.get("phone"), + display_phone=b.get("display_phone"), + distance=b.get("distance"), + ) + for b in trimmed + ], + total=total, + ) + + +@tool(args_schema=GetBusinessDetailsInput) +@serialize_pydantic_return +async def get_business_details( + business_id_or_alias: str, + api_key: str, + device_platform: str | None = None, + locale: str | None = None, +) -> GetBusinessDetailsOutput: + """Get detailed information about a specific business by its Yelp ID or alias""" + if not api_key or not api_key.strip(): + return GetBusinessDetailsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + + params: dict[str, str] = {} + if device_platform: + params["device_platform"] = device_platform + if locale: + params["locale"] = locale + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/businesses/{business_id_or_alias}", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return GetBusinessDetailsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetBusinessDetailsOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetBusinessDetailsOutput(success=False, error=f"Call failed: {exc}") + + return GetBusinessDetailsOutput(success=True, business=data) + + +@tool(args_schema=ListBusinessReviewsInput) +@serialize_pydantic_return +async def list_business_reviews( + business_id_or_alias: str, + api_key: str, + locale: str | None = None, + sort_by: str | None = None, +) -> ListBusinessReviewsOutput: + """List the reviews for a specific business""" + if not api_key or not api_key.strip(): + return ListBusinessReviewsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + + params: dict[str, str] = {} + if locale: + params["locale"] = locale + if sort_by: + params["sort_by"] = sort_by + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/businesses/{business_id_or_alias}/reviews", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return ListBusinessReviewsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListBusinessReviewsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListBusinessReviewsOutput(success=False, error=f"Call failed: {exc}") + + reviews_raw = data.get("reviews", []) + return ListBusinessReviewsOutput( + success=True, + reviews=[ + ReviewSummary( + id=r.get("id"), + url=r.get("url"), + text=r.get("text"), + rating=r.get("rating"), + time_created=r.get("time_created"), + user=r.get("user"), + ) + for r in reviews_raw + ], + total=data.get("total", 0), + possible_languages=data.get("possible_languages", []), + ) + + +@tool(args_schema=SearchBusinessesByPhoneNumberInput) +@serialize_pydantic_return +async def search_businesses_by_phone_number( + phone: str, + api_key: str, + locale: str | None = None, +) -> SearchBusinessesByPhoneNumberOutput: + """Search for businesses by phone number""" + if not api_key or not api_key.strip(): + return SearchBusinessesByPhoneNumberOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + + params: dict[str, str] = {"phone": phone} + if locale: + params["locale"] = locale + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/businesses/search/phone", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return SearchBusinessesByPhoneNumberOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SearchBusinessesByPhoneNumberOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchBusinessesByPhoneNumberOutput(success=False, error=f"Call failed: {exc}") + + businesses_raw = data.get("businesses", []) + return SearchBusinessesByPhoneNumberOutput( + success=True, + businesses=[ + BusinessSummary( + id=b.get("id"), + alias=b.get("alias"), + name=b.get("name"), + image_url=b.get("image_url"), + url=b.get("url"), + review_count=b.get("review_count"), + categories=b.get("categories", []), + rating=b.get("rating"), + coordinates=b.get("coordinates"), + location=b.get("location"), + phone=b.get("phone"), + display_phone=b.get("display_phone"), + distance=b.get("distance"), + ) + for b in businesses_raw + ], + ) From 487a069240b345037863f20191e8c137e192ffc8 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Fri, 29 May 2026 06:09:29 +0000 Subject: [PATCH 05/15] auto-integrate: heygen Add `heygen` integration -- AI video generation platform (5 actions, api_key auth). **Source:** Pipedream `components/heygen` v0.2.0, converted by the `pdream-to-modulex` producer and staged in `integration-drafts`. **Actions:** - `create_talking_photo` -- Creates a talking photo video from an image, text, and voice - `create_video_from_template` -- Generates a video from a template with optional variable overrides - `list_custom_events_options` -- Retrieves available webhook custom event options - `list_voice_id_options` -- Retrieves available voice options - `retrieve_video_link` -- Fetches the status and download link for a video **Authentication:** API key via `X-Api-Key` header. **Consumer-side audit patches applied (1):** 1. `manifest.py` -- Logo convention enforcement (check 8.9): replaced CDN URL with `modulex:heygen-themed` per the project logo-string contract. **Auditor warnings (1):** - 8.1: URL path interpolation of `template_id` at tools.py:202 is standard REST practice; no patch needed. Flagged for reviewer awareness only. Provider: primary Run: 26620750788 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 7 + pyproject.toml | 5 + .../tools/heygen/README.md | 33 ++ .../tools/heygen/__init__.py | 27 ++ .../tools/heygen/dependencies.toml | 3 + .../tools/heygen/manifest.py | 167 +++++++++ .../tools/heygen/outputs.py | 69 ++++ .../tools/heygen/tests/__init__.py | 1 + .../tools/heygen/tests/test_heygen.py | 183 ++++++++++ .../tools/heygen/tools.py | 338 ++++++++++++++++++ 10 files changed, 833 insertions(+) create mode 100644 src/modulex_integrations/tools/heygen/README.md create mode 100644 src/modulex_integrations/tools/heygen/__init__.py create mode 100644 src/modulex_integrations/tools/heygen/dependencies.toml create mode 100644 src/modulex_integrations/tools/heygen/manifest.py create mode 100644 src/modulex_integrations/tools/heygen/outputs.py create mode 100644 src/modulex_integrations/tools/heygen/tests/__init__.py create mode 100644 src/modulex_integrations/tools/heygen/tests/test_heygen.py create mode 100644 src/modulex_integrations/tools/heygen/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d77ee..d54435c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `heygen` integration — 5 actions, auth: api_key. AI video generation + platform for creating talking avatar videos via the HeyGen API + (create_talking_photo, create_video_from_template, + list_custom_events_options, list_voice_id_options, retrieve_video_link). + Producer-staged by integration-drafts; consumer-side audit applied + 1 patch before merge. + - `yelp` integration — 4 actions, auth: api_key. Search businesses, read reviews, and get business details via the Yelp Fusion API (search_businesses, get_business_details, list_business_reviews, diff --git a/pyproject.toml b/pyproject.toml index cf8a4da..b9de60e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ crunchbase = "modulex_integrations.tools.crunchbase" dropbox = "modulex_integrations.tools.dropbox" docusign = "modulex_integrations.tools.docusign" hackernews = "modulex_integrations.tools.hackernews" +heygen = "modulex_integrations.tools.heygen" help_scout = "modulex_integrations.tools.help_scout" heroku = "modulex_integrations.tools.heroku" hootsuite = "modulex_integrations.tools.hootsuite" @@ -600,6 +601,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] "src/modulex_integrations/tools/square/manifest.py" = ["E501"] "src/modulex_integrations/tools/square/tools.py" = ["E501"] "src/modulex_integrations/tools/square/tests/test_square.py" = ["E501"] +# heygen manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/heygen/manifest.py" = ["E501"] +"src/modulex_integrations/tools/heygen/tools.py" = ["E501"] # yelp manifest and tools have long description string literals in # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/yelp/manifest.py" = ["E501"] diff --git a/src/modulex_integrations/tools/heygen/README.md b/src/modulex_integrations/tools/heygen/README.md new file mode 100644 index 0000000..a2ddb82 --- /dev/null +++ b/src/modulex_integrations/tools/heygen/README.md @@ -0,0 +1,33 @@ +# HeyGen + +AI video generation platform for creating talking avatar videos via the HeyGen REST API (`api.heygen.com`). + +## Authentication + +### API Key Authentication + +- Sign in at , go to **Settings > API**, and copy your API key. +- Required env var: `HEYGEN_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_talking_photo` | Creates a talking photo video from a provided image, text, and voice | `talking_photo_id`, `text`, `voice_id` | +| `create_video_from_template` | Generates a video from a selected template with optional variable overrides | `template_id` | +| `list_custom_events_options` | Retrieves available options for webhook custom events | | +| `list_voice_id_options` | Retrieves available voice options for video generation | | +| `retrieve_video_link` | Fetches the status and download link for a specific HeyGen video | `video_id` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- Rate limits depend on your HeyGen plan tier (Starter, Business, Enterprise). +- Video generation consumes credits; use `test=true` to avoid credit charges during development. +- No documented per-minute rate limit; excessive requests may trigger throttling. +- 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/heygen/__init__.py b/src/modulex_integrations/tools/heygen/__init__.py new file mode 100644 index 0000000..a2c2f4c --- /dev/null +++ b/src/modulex_integrations/tools/heygen/__init__.py @@ -0,0 +1,27 @@ +"""HeyGen integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.heygen.manifest import manifest +from modulex_integrations.tools.heygen.tools import ( + create_talking_photo, + create_video_from_template, + list_custom_events_options, + list_voice_id_options, + retrieve_video_link, +) + +TOOLS = ( + create_talking_photo, + create_video_from_template, + list_custom_events_options, + list_voice_id_options, + retrieve_video_link, +) + +__all__ = [ + "TOOLS", + "create_talking_photo", + "create_video_from_template", + "list_custom_events_options", + "list_voice_id_options", + "manifest", + "retrieve_video_link", +] diff --git a/src/modulex_integrations/tools/heygen/dependencies.toml b/src/modulex_integrations/tools/heygen/dependencies.toml new file mode 100644 index 0000000..4e7b90b --- /dev/null +++ b/src/modulex_integrations/tools/heygen/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the heygen integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/heygen/manifest.py b/src/modulex_integrations/tools/heygen/manifest.py new file mode 100644 index 0000000..cba354e --- /dev/null +++ b/src/modulex_integrations/tools/heygen/manifest.py @@ -0,0 +1,167 @@ +"""HeyGen integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="heygen", + display_name="HeyGen", + description="AI video generation platform for creating talking avatar videos", + version="1.0.0", + author="ModuleX", + logo="modulex:heygen-themed", + app_url="https://www.heygen.com", + categories=["AI", "Video", "Content Creation"], + actions=[ + ActionDefinition( + name="create_talking_photo", + description="Creates a talking photo video from a provided image, text, and voice", + parameters={ + "talking_photo_id": ParameterDef( + type="string", + description="Identifier of the talking photo to use", + required=True, + ), + "text": ParameterDef( + type="string", + description="The text that the character will speak", + required=True, + ), + "voice_id": ParameterDef( + type="string", + description="Identifier of the voice to use", + required=True, + ), + "title": ParameterDef( + type="string", + description="Title of the video", + ), + "test": ParameterDef( + type="boolean", + description="Set to true to use test mode (no credits charged, watermark added)", + ), + "caption": ParameterDef( + type="boolean", + description="Set to true to create video with captions", + ), + "scale": ParameterDef( + type="string", + description="Talking photo scale, value between 0 and 2.0 (default 1.0)", + ), + "talking_photo_style": ParameterDef( + type="string", + description="Talking photo crop style: square, circle", + ), + "talking_style": ParameterDef( + type="string", + description="Talking photo talking style: stable, expressive", + ), + "expression": ParameterDef( + type="string", + description="Talking photo expression style: default, happy", + ), + "super_resolution": ParameterDef( + type="boolean", + description="Whether to enhance the photo image", + ), + "matting": ParameterDef( + type="boolean", + description="Whether to apply matting to the photo", + ), + }, + ), + ActionDefinition( + name="create_video_from_template", + description="Generates a video from a selected template with optional variable overrides", + parameters={ + "template_id": ParameterDef( + type="string", + description="Identifier of the template to use", + required=True, + ), + "title": ParameterDef( + type="string", + description="Title of the video", + ), + "test": ParameterDef( + type="boolean", + description="Set to true to use test mode (no credits charged, watermark added)", + ), + "caption": ParameterDef( + type="boolean", + description="Set to true to create video with captions", + ), + "variables": ParameterDef( + type="object", + description="Template variable overrides as a JSON object where keys are variable names and values are objects with variable properties", + ), + }, + ), + ActionDefinition( + name="list_custom_events_options", + description="Retrieves available options for webhook custom events", + parameters={}, + ), + ActionDefinition( + name="list_voice_id_options", + description="Retrieves available voice options for video generation", + parameters={}, + ), + ActionDefinition( + name="retrieve_video_link", + description="Fetches the status and download link for a specific HeyGen video", + parameters={ + "video_id": ParameterDef( + type="string", + description="Identifier of the HeyGen video to retrieve", + required=True, + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your HeyGen API key", + setup_instructions=[ + "Go to https://app.heygen.com and sign in", + "Navigate to Settings > API", + "Copy your API key", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="HEYGEN_API_KEY", + display_name="HeyGen API Key", + description="Your HeyGen API key from app.heygen.com/settings", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://app.heygen.com/settings", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.heygen.com/v2/voices", + method="GET", + headers={"X-Api-Key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates the API key by listing available voices", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/heygen/outputs.py b/src/modulex_integrations/tools/heygen/outputs.py new file mode 100644 index 0000000..8f9e184 --- /dev/null +++ b/src/modulex_integrations/tools/heygen/outputs.py @@ -0,0 +1,69 @@ +"""Pydantic response models for the heygen integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateTalkingPhotoOutput", + "CreateVideoFromTemplateOutput", + "ListCustomEventsOptionsOutput", + "ListVoiceIdOptionsOutput", + "RetrieveVideoLinkOutput", + "VoiceInfo", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class VoiceInfo(_Base): + """A voice entry returned by the list voices endpoint.""" + + voice_id: str | None = None + name: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class CreateTalkingPhotoOutput(_Base): + success: bool + error: str | None = None + video_id: str | None = None + status: str | None = None + + +class CreateVideoFromTemplateOutput(_Base): + success: bool + error: str | None = None + video_id: str | None = None + status: str | None = None + + +class ListCustomEventsOptionsOutput(_Base): + success: bool + error: str | None = None + events: list[str] = Field(default_factory=list) + + +class ListVoiceIdOptionsOutput(_Base): + success: bool + error: str | None = None + voices: list[VoiceInfo] = Field(default_factory=list) + + +class RetrieveVideoLinkOutput(_Base): + success: bool + error: str | None = None + video_id: str | None = None + status: str | None = None + video_url: str | None = None + thumbnail_url: str | None = None + duration: float | None = None + caption_url: str | None = None diff --git a/src/modulex_integrations/tools/heygen/tests/__init__.py b/src/modulex_integrations/tools/heygen/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/heygen/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/heygen/tests/test_heygen.py b/src/modulex_integrations/tools/heygen/tests/test_heygen.py new file mode 100644 index 0000000..d9bb3cb --- /dev/null +++ b/src/modulex_integrations/tools/heygen/tests/test_heygen.py @@ -0,0 +1,183 @@ +"""Happy-path tests for every heygen @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.heygen import ( + TOOLS, + create_talking_photo, + create_video_from_template, + list_custom_events_options, + list_voice_id_options, + manifest, + retrieve_video_link, +) +from modulex_integrations.tools.heygen.outputs import ( + CreateTalkingPhotoOutput, + CreateVideoFromTemplateOutput, + ListCustomEventsOptionsOutput, + ListVoiceIdOptionsOutput, + RetrieveVideoLinkOutput, +) + +API = "https://api.heygen.com" + +_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_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_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_talking_photo(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/v2/video/generate", + json={ + "data": { + "video_id": "vid_123", + "status": "processing", + }, + }, + ) + + result_dict = await create_talking_photo.ainvoke( + _args( + talking_photo_id="tp_abc", + text="Hello world", + voice_id="voice_xyz", + ) + ) + + assert isinstance(result_dict, dict) + result = CreateTalkingPhotoOutput.model_validate(result_dict) + assert result.success is True + assert result.video_id == "vid_123" + assert result.status == "processing" + + +@pytest.mark.asyncio +async def test_create_video_from_template(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/v2/template/tmpl_001/generate", + json={ + "data": { + "video_id": "vid_456", + "status": "processing", + }, + }, + ) + + result_dict = await create_video_from_template.ainvoke( + _args(template_id="tmpl_001") + ) + + assert isinstance(result_dict, dict) + result = CreateVideoFromTemplateOutput.model_validate(result_dict) + assert result.success is True + assert result.video_id == "vid_456" + assert result.status == "processing" + + +@pytest.mark.asyncio +async def test_list_custom_events_options(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/webhook/webhook.list", + json={ + "data": [ + "avatar_video.success", + "avatar_video.fail", + ], + }, + ) + + result_dict = await list_custom_events_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListCustomEventsOptionsOutput.model_validate(result_dict) + assert result.success is True + assert "avatar_video.success" in result.events + + +@pytest.mark.asyncio +async def test_list_voice_id_options(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v2/voices", + json={ + "data": { + "voices": [ + {"voice_id": "v1", "name": "Sara"}, + {"voice_id": "v2", "name": "Mark"}, + ], + }, + }, + ) + + result_dict = await list_voice_id_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListVoiceIdOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.voices) == 2 + assert result.voices[0].voice_id == "v1" + + +@pytest.mark.asyncio +async def test_retrieve_video_link(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/video_status.get?video_id=vid_123", + json={ + "data": { + "video_id": "vid_123", + "status": "completed", + "video_url": "https://files.heygen.ai/video.mp4", + "thumbnail_url": "https://files.heygen.ai/thumb.jpg", + "duration": 12.5, + "caption_url": "https://files.heygen.ai/caption.srt", + }, + }, + ) + + result_dict = await retrieve_video_link.ainvoke(_args(video_id="vid_123")) + + assert isinstance(result_dict, dict) + result = RetrieveVideoLinkOutput.model_validate(result_dict) + assert result.success is True + assert result.video_id == "vid_123" + assert result.status == "completed" + assert result.video_url == "https://files.heygen.ai/video.mp4" + assert result.duration == 12.5 + + +@pytest.mark.asyncio +async def test_create_talking_photo_validates_empty_api_key() -> None: + result_dict = await create_talking_photo.ainvoke( + {"talking_photo_id": "tp", "text": "hi", "voice_id": "v", "api_key": ""} + ) + result = CreateTalkingPhotoOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/heygen/tools.py b/src/modulex_integrations/tools/heygen/tools.py new file mode 100644 index 0000000..ad85aba --- /dev/null +++ b/src/modulex_integrations/tools/heygen/tools.py @@ -0,0 +1,338 @@ +"""HeyGen 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.heygen.outputs import ( + CreateTalkingPhotoOutput, + CreateVideoFromTemplateOutput, + ListCustomEventsOptionsOutput, + ListVoiceIdOptionsOutput, + RetrieveVideoLinkOutput, + VoiceInfo, +) + +__all__ = [ + "create_talking_photo", + "create_video_from_template", + "list_custom_events_options", + "list_voice_id_options", + "retrieve_video_link", +] + +_BASE_URL = "https://api.heygen.com" +_TIMEOUT = 30.0 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "X-Api-Key": api_key, + "Content-Type": "application/json", + "Accept": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateTalkingPhotoInput(BaseModel): + talking_photo_id: str = Field(description="Identifier of the talking photo to use") + text: str = Field(description="The text that the character will speak") + voice_id: str = Field(description="Identifier of the voice to use") + api_key: str = Field(description="HeyGen API key") + title: str | None = Field(default=None, description="Title of the video") + test: bool | None = Field(default=None, description="Set to true to use test mode (no credits charged, watermark added)") + caption: bool | None = Field(default=None, description="Set to true to create video with captions") + scale: str | None = Field(default=None, description="Talking photo scale, value between 0 and 2.0 (default 1.0)") + talking_photo_style: str | None = Field(default=None, description="Talking photo crop style: square, circle") + talking_style: str | None = Field(default=None, description="Talking photo talking style: stable, expressive") + expression: str | None = Field(default=None, description="Talking photo expression style: default, happy") + super_resolution: bool | None = Field(default=None, description="Whether to enhance the photo image") + matting: bool | None = Field(default=None, description="Whether to apply matting to the photo") + + +class CreateVideoFromTemplateInput(BaseModel): + template_id: str = Field(description="Identifier of the template to use") + api_key: str = Field(description="HeyGen API key") + title: str | None = Field(default=None, description="Title of the video") + test: bool | None = Field(default=None, description="Set to true to use test mode (no credits charged, watermark added)") + caption: bool | None = Field(default=None, description="Set to true to create video with captions") + variables: dict[str, Any] | None = Field(default=None, description="Template variable overrides as a JSON object where keys are variable names and values are objects with variable properties") + + +class ListCustomEventsOptionsInput(BaseModel): + api_key: str = Field(description="HeyGen API key") + + +class ListVoiceIdOptionsInput(BaseModel): + api_key: str = Field(description="HeyGen API key") + + +class RetrieveVideoLinkInput(BaseModel): + video_id: str = Field(description="Identifier of the HeyGen video to retrieve") + api_key: str = Field(description="HeyGen API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateTalkingPhotoInput) +@serialize_pydantic_return +async def create_talking_photo( + talking_photo_id: str, + text: str, + voice_id: str, + api_key: str, + title: str | None = None, + test: bool | None = None, + caption: bool | None = None, + scale: str | None = None, + talking_photo_style: str | None = None, + talking_style: str | None = None, + expression: str | None = None, + super_resolution: bool | None = None, + matting: bool | None = None, +) -> CreateTalkingPhotoOutput: + """Creates a talking photo video from a provided image, text, and voice""" + if not api_key or not api_key.strip(): + return CreateTalkingPhotoOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + + talking_photo_config: dict[str, Any] = { + "type": "talking_photo", + "talking_photo_id": talking_photo_id, + } + if talking_photo_style is not None: + talking_photo_config["talking_photo_style"] = talking_photo_style + if talking_style is not None: + talking_photo_config["talking_style"] = talking_style + if expression is not None: + talking_photo_config["expression"] = expression + if super_resolution is not None: + talking_photo_config["super_resolution"] = super_resolution + if matting is not None: + talking_photo_config["matting"] = matting + if scale is not None: + talking_photo_config["scale"] = float(scale) + + voice_config: dict[str, Any] = { + "type": "text", + "voice_id": voice_id, + "input_text": text, + } + + video_input: dict[str, Any] = { + "character": talking_photo_config, + "voice": voice_config, + } + + payload: dict[str, Any] = { + "video_inputs": [video_input], + } + if title is not None: + payload["title"] = title + if test is not None: + payload["test"] = test + if caption is not None: + payload["caption"] = caption + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/v2/video/generate", + headers=_headers(api_key), + json=payload, + ) + if response.status_code != 200: + return CreateTalkingPhotoOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateTalkingPhotoOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateTalkingPhotoOutput(success=False, error=f"Call failed: {exc}") + + video_data = data.get("data", {}) + return CreateTalkingPhotoOutput( + success=True, + video_id=video_data.get("video_id"), + status=video_data.get("status"), + ) + + +@tool(args_schema=CreateVideoFromTemplateInput) +@serialize_pydantic_return +async def create_video_from_template( + template_id: str, + api_key: str, + title: str | None = None, + test: bool | None = None, + caption: bool | None = None, + variables: dict[str, Any] | None = None, +) -> CreateVideoFromTemplateOutput: + """Generates a video from a selected template with optional variable overrides""" + if not api_key or not api_key.strip(): + return CreateVideoFromTemplateOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + + payload: dict[str, Any] = {} + if title is not None: + payload["title"] = title + if test is not None: + payload["test"] = test + if caption is not None: + payload["caption"] = caption + if variables is not None: + payload["variables"] = variables + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/v2/template/{template_id}/generate", + headers=_headers(api_key), + json=payload, + ) + if response.status_code != 200: + return CreateVideoFromTemplateOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateVideoFromTemplateOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateVideoFromTemplateOutput(success=False, error=f"Call failed: {exc}") + + video_data = data.get("data", {}) + return CreateVideoFromTemplateOutput( + success=True, + video_id=video_data.get("video_id"), + status=video_data.get("status"), + ) + + +@tool(args_schema=ListCustomEventsOptionsInput) +@serialize_pydantic_return +async def list_custom_events_options( + api_key: str, +) -> ListCustomEventsOptionsOutput: + """Retrieves available options for webhook custom events""" + if not api_key or not api_key.strip(): + return ListCustomEventsOptionsOutput( + 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}/v1/webhook/webhook.list", + headers=_headers(api_key), + ) + if response.status_code != 200: + return ListCustomEventsOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListCustomEventsOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListCustomEventsOptionsOutput(success=False, error=f"Call failed: {exc}") + + events = data.get("data", []) + if isinstance(events, list): + return ListCustomEventsOptionsOutput(success=True, events=events) + return ListCustomEventsOptionsOutput(success=True, events=[]) + + +@tool(args_schema=ListVoiceIdOptionsInput) +@serialize_pydantic_return +async def list_voice_id_options( + api_key: str, +) -> ListVoiceIdOptionsOutput: + """Retrieves available voice options for video generation""" + if not api_key or not api_key.strip(): + return ListVoiceIdOptionsOutput( + 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}/v2/voices", + headers=_headers(api_key), + ) + if response.status_code != 200: + return ListVoiceIdOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListVoiceIdOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListVoiceIdOptionsOutput(success=False, error=f"Call failed: {exc}") + + voices_data = data.get("data", {}).get("voices", []) + voices = [ + VoiceInfo(voice_id=v.get("voice_id"), name=v.get("name")) + for v in voices_data + if isinstance(v, dict) + ] + return ListVoiceIdOptionsOutput(success=True, voices=voices) + + +@tool(args_schema=RetrieveVideoLinkInput) +@serialize_pydantic_return +async def retrieve_video_link( + video_id: str, + api_key: str, +) -> RetrieveVideoLinkOutput: + """Fetches the status and download link for a specific HeyGen video""" + if not api_key or not api_key.strip(): + return RetrieveVideoLinkOutput( + 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}/v1/video_status.get", + headers=_headers(api_key), + params={"video_id": video_id}, + ) + if response.status_code != 200: + return RetrieveVideoLinkOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return RetrieveVideoLinkOutput(success=False, error="Request timed out.") + except Exception as exc: + return RetrieveVideoLinkOutput(success=False, error=f"Call failed: {exc}") + + video_data = data.get("data", {}) + return RetrieveVideoLinkOutput( + success=True, + video_id=video_data.get("video_id"), + status=video_data.get("status"), + video_url=video_data.get("video_url"), + thumbnail_url=video_data.get("thumbnail_url"), + duration=video_data.get("duration"), + caption_url=video_data.get("caption_url"), + ) From 63630b158207292edf423ffc662b52ef6f688302 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Fri, 29 May 2026 12:48:52 +0000 Subject: [PATCH 06/15] auto-integrate: livestorm Auto-integrated livestorm from integration-drafts producer. Provider: primary Run: 26637617143 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 7 + pyproject.toml | 5 + .../tools/livestorm/README.md | 38 ++ .../tools/livestorm/__init__.py | 33 ++ .../tools/livestorm/dependencies.toml | 3 + .../tools/livestorm/manifest.py | 299 +++++++++++ .../tools/livestorm/outputs.py | 67 +++ .../tools/livestorm/tests/__init__.py | 1 + .../tools/livestorm/tests/test_livestorm.py | 254 +++++++++ .../tools/livestorm/tools.py | 507 ++++++++++++++++++ 10 files changed, 1214 insertions(+) create mode 100644 src/modulex_integrations/tools/livestorm/README.md create mode 100644 src/modulex_integrations/tools/livestorm/__init__.py create mode 100644 src/modulex_integrations/tools/livestorm/dependencies.toml create mode 100644 src/modulex_integrations/tools/livestorm/manifest.py create mode 100644 src/modulex_integrations/tools/livestorm/outputs.py create mode 100644 src/modulex_integrations/tools/livestorm/tests/__init__.py create mode 100644 src/modulex_integrations/tools/livestorm/tests/test_livestorm.py create mode 100644 src/modulex_integrations/tools/livestorm/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d54435c..d323221 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `livestorm` integration — 7 actions, auth: oauth2. Video engagement + platform for webinars and virtual events via the Livestorm REST API + (create_event, get_event, list_attendees_from_event, list_events, + list_sessions, register_someone_for_session, update_event). + Producer-staged by integration-drafts; consumer-side audit applied + 12 patches before merge. + - `heygen` integration — 5 actions, auth: api_key. AI video generation platform for creating talking avatar videos via the HeyGen API (create_talking_photo, create_video_from_template, diff --git a/pyproject.toml b/pyproject.toml index b9de60e..d9ab751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ canva = "modulex_integrations.tools.canva" instructure_canvas = "modulex_integrations.tools.instructure_canvas" linear = "modulex_integrations.tools.linear" linkedin = "modulex_integrations.tools.linkedin" +livestorm = "modulex_integrations.tools.livestorm" luma = "modulex_integrations.tools.luma" ahrefs = "modulex_integrations.tools.ahrefs" airtable = "modulex_integrations.tools.airtable" @@ -605,6 +606,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/heygen/manifest.py" = ["E501"] "src/modulex_integrations/tools/heygen/tools.py" = ["E501"] +# livestorm manifest and tools have long description string literals in +# ParameterDef / Field kwargs and credential guard lines that cannot be wrapped. +"src/modulex_integrations/tools/livestorm/manifest.py" = ["E501"] +"src/modulex_integrations/tools/livestorm/tools.py" = ["E501"] # yelp manifest and tools have long description string literals in # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/yelp/manifest.py" = ["E501"] diff --git a/src/modulex_integrations/tools/livestorm/README.md b/src/modulex_integrations/tools/livestorm/README.md new file mode 100644 index 0000000..d3df9ba --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/README.md @@ -0,0 +1,38 @@ +# Livestorm + +Video engagement platform for webinars and virtual events via the Livestorm REST API (`api.livestorm.co/v1`). + +## Authentication + +### OAuth2 Authentication (recommended) + +- Register an OAuth application at the [Livestorm developer portal](https://developers.livestorm.co). +- Redirect URI: `https://api.modulex.dev/credentials/oauth2/callback` +- Required env vars (custom app only): + - `LIVESTORM_OAUTH2_CLIENT_ID` — OAuth App Client ID + - `LIVESTORM_OAUTH2_CLIENT_SECRET` — OAuth App Client Secret +- Scopes: none documented; the platform grants full API access upon authorization. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_event` | Create a new event | `owner_id`, `title` | +| `get_event` | Retrieve a single event | `event_id` | +| `list_attendees_from_event` | List all the people linked to all the sessions of an event | `event_id` | +| `list_events` | List the events of your workspace | | +| `list_sessions` | List all your event sessions | | +| `register_someone_for_session` | Register a new participant for a session | `session_id` | +| `update_event` | Update an event with its full list of attributes | `event_id`, `owner_id`, `title`, `slug`, `status`, `description`, `recording_enabled`, `chat_enabled`, `everyone_can_speak`, `detailed_registration_page_enabled`, `light_registration_page_enabled`, `recording_public`, `show_in_company_page`, `polls_enabled`, `questions_enabled` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved OAuth credential. + +## Limits & Quotas + +- No publicly documented rate limits for the Livestorm API. +- Pagination is applied automatically for list endpoints (page-based). +- 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/livestorm/__init__.py b/src/modulex_integrations/tools/livestorm/__init__.py new file mode 100644 index 0000000..b979d46 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/__init__.py @@ -0,0 +1,33 @@ +"""Livestorm integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.livestorm.manifest import manifest +from modulex_integrations.tools.livestorm.tools import ( + create_event, + get_event, + list_attendees_from_event, + list_events, + list_sessions, + register_someone_for_session, + update_event, +) + +TOOLS = ( + create_event, + get_event, + list_attendees_from_event, + list_events, + list_sessions, + register_someone_for_session, + update_event, +) + +__all__ = [ + "TOOLS", + "create_event", + "get_event", + "list_attendees_from_event", + "list_events", + "list_sessions", + "manifest", + "register_someone_for_session", + "update_event", +] diff --git a/src/modulex_integrations/tools/livestorm/dependencies.toml b/src/modulex_integrations/tools/livestorm/dependencies.toml new file mode 100644 index 0000000..6fface2 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the livestorm integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/livestorm/manifest.py b/src/modulex_integrations/tools/livestorm/manifest.py new file mode 100644 index 0000000..ed04dc2 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/manifest.py @@ -0,0 +1,299 @@ +"""Livestorm integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="livestorm", + display_name="Livestorm", + description="Video engagement platform for webinars and virtual events", + version="1.0.0", + author="ModuleX", + logo="modulex:livestorm-themed", + app_url="https://livestorm.co", + categories=["Marketing", "Webinars & Events"], + actions=[ + ActionDefinition( + name="create_event", + description="Create a new event", + parameters={ + "owner_id": ParameterDef( + type="string", + description="The ID of the user who owns the event", + required=True, + ), + "title": ParameterDef( + type="string", + description="The title of the event", + required=True, + ), + "slug": ParameterDef( + type="string", + description="The slug of the event", + ), + "status": ParameterDef( + type="string", + description="The status of the event: draft, published", + ), + "description": ParameterDef( + type="string", + description="The HTML description of the event", + ), + "recording_enabled": ParameterDef( + type="boolean", + description="Whether the event is recorded", + ), + "chat_enabled": ParameterDef( + type="boolean", + description="Whether the chat is enabled", + ), + "everyone_can_speak": ParameterDef( + type="boolean", + description="Whether everyone can speak", + ), + "detailed_registration_page_enabled": ParameterDef( + type="boolean", + description="Whether the detailed registration page is enabled", + ), + "light_registration_page_enabled": ParameterDef( + type="boolean", + description="Whether the light registration page is enabled", + ), + "recording_public": ParameterDef( + type="boolean", + description="Whether the recording is public", + ), + "show_in_company_page": ParameterDef( + type="boolean", + description="Whether the event is shown in the company page", + ), + "polls_enabled": ParameterDef( + type="boolean", + description="Whether the polls are enabled", + ), + "questions_enabled": ParameterDef( + type="boolean", + description="Whether the questions are enabled", + ), + }, + ), + ActionDefinition( + name="get_event", + description="Retrieve a single event", + parameters={ + "event_id": ParameterDef( + type="string", + description="The ID of the event", + required=True, + ), + }, + ), + ActionDefinition( + name="list_attendees_from_event", + description="List all the people linked to all the sessions of an event", + parameters={ + "event_id": ParameterDef( + type="string", + description="The ID of the event", + required=True, + ), + "role_filter": ParameterDef( + type="string", + description="Filter by role: participant, team_member", + ), + }, + ), + ActionDefinition( + name="list_events", + description="List the events of your workspace", + parameters={ + "title_filter": ParameterDef( + type="string", + description="Filter events by title", + ), + }, + ), + ActionDefinition( + name="list_sessions", + description="List all your event sessions", + parameters={}, + ), + ActionDefinition( + name="register_someone_for_session", + description="Register a new participant for a session", + parameters={ + "session_id": ParameterDef( + type="string", + description="The ID of the session", + required=True, + ), + "referrer": ParameterDef( + type="string", + description="The referrer of the person registering", + ), + "utm_source": ParameterDef( + type="string", + description="The UTM source", + ), + "utm_medium": ParameterDef( + type="string", + description="The UTM medium", + ), + "utm_campaign": ParameterDef( + type="string", + description="The UTM campaign", + ), + "utm_term": ParameterDef( + type="string", + description="The UTM term", + ), + "utm_content": ParameterDef( + type="string", + description="The UTM content", + ), + "fields": ParameterDef( + type="object", + description="Registration fields as key-value pairs where key is the field ID and value is the field value", + ), + }, + ), + ActionDefinition( + name="update_event", + description="Update an event with its full list of attributes", + parameters={ + "event_id": ParameterDef( + type="string", + description="The ID of the event", + required=True, + ), + "owner_id": ParameterDef( + type="string", + description="The ID of the user who owns the event", + required=True, + ), + "title": ParameterDef( + type="string", + description="The title of the event", + required=True, + ), + "slug": ParameterDef( + type="string", + description="The slug of the event", + required=True, + ), + "status": ParameterDef( + type="string", + description="The status of the event: draft, published", + required=True, + ), + "description": ParameterDef( + type="string", + description="The HTML description of the event", + required=True, + ), + "recording_enabled": ParameterDef( + type="boolean", + description="Whether the event is recorded", + required=True, + ), + "chat_enabled": ParameterDef( + type="boolean", + description="Whether the chat is enabled", + required=True, + ), + "everyone_can_speak": ParameterDef( + type="boolean", + description="Whether everyone can speak", + required=True, + ), + "detailed_registration_page_enabled": ParameterDef( + type="boolean", + description="Whether the detailed registration page is enabled", + required=True, + ), + "light_registration_page_enabled": ParameterDef( + type="boolean", + description="Whether the light registration page is enabled", + required=True, + ), + "recording_public": ParameterDef( + type="boolean", + description="Whether the recording is public", + required=True, + ), + "show_in_company_page": ParameterDef( + type="boolean", + description="Whether the event is shown in the company page", + required=True, + ), + "polls_enabled": ParameterDef( + type="boolean", + description="Whether the polls are enabled", + required=True, + ), + "questions_enabled": ParameterDef( + type="boolean", + description="Whether the questions are enabled", + required=True, + ), + }, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Livestorm OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="LIVESTORM_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Livestorm OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + about_url="https://developers.livestorm.co", + ), + EnvVar( + name="LIVESTORM_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Livestorm OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + about_url="https://developers.livestorm.co", + ), + ], + oauth_config=OAuthConfig( + auth_url="https://app.livestorm.co/oauth/authorize", + token_url="https://app.livestorm.co/oauth/token", + scopes=[], + ), + test_endpoint=TestEndpoint( + url="https://api.livestorm.co/v1/events", + method="GET", + headers={ + "Authorization": "Bearer {access_token}", + "Accept": "application/vnd.api+json", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates OAuth token by listing events", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/livestorm/outputs.py b/src/modulex_integrations/tools/livestorm/outputs.py new file mode 100644 index 0000000..b94c8a7 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/outputs.py @@ -0,0 +1,67 @@ +"""Pydantic response models for the livestorm integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateEventOutput", + "GetEventOutput", + "ListAttendeesFromEventOutput", + "ListEventsOutput", + "ListSessionsOutput", + "RegisterSomeoneForSessionOutput", + "UpdateEventOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Per-action output models ------------------------------------------------ + + +class CreateEventOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetEventOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class ListAttendeesFromEventOutput(_Base): + success: bool + error: str | None = None + data: list[dict[str, Any]] = Field(default_factory=list) + + +class ListEventsOutput(_Base): + success: bool + error: str | None = None + data: list[dict[str, Any]] = Field(default_factory=list) + + +class ListSessionsOutput(_Base): + success: bool + error: str | None = None + data: list[dict[str, Any]] = Field(default_factory=list) + + +class RegisterSomeoneForSessionOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class UpdateEventOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None diff --git a/src/modulex_integrations/tools/livestorm/tests/__init__.py b/src/modulex_integrations/tools/livestorm/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py b/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py new file mode 100644 index 0000000..09f2a54 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/tests/test_livestorm.py @@ -0,0 +1,254 @@ +"""Happy-path tests for every livestorm @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.livestorm import ( + TOOLS, + create_event, + get_event, + list_attendees_from_event, + list_events, + list_sessions, + manifest, + register_someone_for_session, + update_event, +) +from modulex_integrations.tools.livestorm.outputs import ( + CreateEventOutput, + GetEventOutput, + ListAttendeesFromEventOutput, + ListEventsOutput, + ListSessionsOutput, + RegisterSomeoneForSessionOutput, + UpdateEventOutput, +) + +API = "https://api.livestorm.co/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_7_actions(self) -> None: + assert len(manifest.actions) == 7 + + 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_event(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/events", + status_code=201, + json={ + "data": { + "id": "evt_123", + "type": "events", + "attributes": {"title": "My Webinar"}, + } + }, + ) + + result_dict = await create_event.ainvoke( + _args(owner_id="user_1", title="My Webinar") + ) + + assert isinstance(result_dict, dict) + result = CreateEventOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + assert result.data["id"] == "evt_123" + + +@pytest.mark.asyncio +async def test_get_event(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/events/evt_123", + json={ + "data": { + "id": "evt_123", + "type": "events", + "attributes": {"title": "My Webinar"}, + } + }, + ) + + result_dict = await get_event.ainvoke(_args(event_id="evt_123")) + + assert isinstance(result_dict, dict) + result = GetEventOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + assert result.data["id"] == "evt_123" + + +@pytest.mark.asyncio +async def test_list_attendees_from_event(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/events/evt_123/people?page%5Bnumber%5D=1", + json={ + "data": [ + {"id": "person_1", "type": "people", "attributes": {"email": "a@b.com"}} + ], + "meta": {"page_count": 1}, + }, + ) + + result_dict = await list_attendees_from_event.ainvoke( + _args(event_id="evt_123") + ) + + assert isinstance(result_dict, dict) + result = ListAttendeesFromEventOutput.model_validate(result_dict) + assert result.success is True + assert len(result.data) == 1 + + +@pytest.mark.asyncio +async def test_list_events(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/events?page%5Bnumber%5D=1", + json={ + "data": [ + {"id": "evt_1", "type": "events", "attributes": {"title": "Event 1"}} + ], + "meta": {"page_count": 1}, + }, + ) + + 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.data) == 1 + + +@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%5Bnumber%5D=1", + json={ + "data": [ + {"id": "ses_1", "type": "sessions", "attributes": {"status": "upcoming"}} + ], + "meta": {"page_count": 1}, + }, + ) + + 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.data) == 1 + + +@pytest.mark.asyncio +async def test_register_someone_for_session(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/sessions/ses_1/people", + status_code=201, + json={ + "data": { + "id": "person_new", + "type": "people", + "attributes": {"email": "new@example.com"}, + } + }, + ) + + result_dict = await register_someone_for_session.ainvoke( + _args(session_id="ses_1") + ) + + assert isinstance(result_dict, dict) + result = RegisterSomeoneForSessionOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + assert result.data["id"] == "person_new" + + +@pytest.mark.asyncio +async def test_update_event(httpx_mock) -> None: # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/events/evt_123", + json={ + "data": { + "id": "evt_123", + "type": "events", + "attributes": {"title": "Updated Title"}, + } + }, + ) + + result_dict = await update_event.ainvoke( + _args( + event_id="evt_123", + owner_id="user_1", + title="Updated Title", + slug="updated-title", + status="published", + description="

Updated

", + recording_enabled=True, + chat_enabled=True, + everyone_can_speak=False, + detailed_registration_page_enabled=True, + light_registration_page_enabled=False, + recording_public=False, + show_in_company_page=True, + polls_enabled=True, + questions_enabled=True, + ) + ) + + assert isinstance(result_dict, dict) + result = UpdateEventOutput.model_validate(result_dict) + assert result.success is True + assert result.data is not None + assert result.data["id"] == "evt_123" + + +# --- Failure-path test -------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_event_missing_credentials() -> None: + """Empty credentials should return an error without hitting the API.""" + result_dict = await create_event.ainvoke( + _args(owner_id="user_1", title="Test", auth_data={}) + ) + + assert isinstance(result_dict, dict) + result = CreateEventOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "access_token" in result.error diff --git a/src/modulex_integrations/tools/livestorm/tools.py b/src/modulex_integrations/tools/livestorm/tools.py new file mode 100644 index 0000000..054d400 --- /dev/null +++ b/src/modulex_integrations/tools/livestorm/tools.py @@ -0,0 +1,507 @@ +"""Livestorm 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.livestorm.outputs import ( + CreateEventOutput, + GetEventOutput, + ListAttendeesFromEventOutput, + ListEventsOutput, + ListSessionsOutput, + RegisterSomeoneForSessionOutput, + UpdateEventOutput, +) + +__all__ = [ + "create_event", + "get_event", + "list_attendees_from_event", + "list_events", + "list_sessions", + "register_someone_for_session", + "update_event", +] + +_BASE_URL = "https://api.livestorm.co/v1" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Livestorm API based on auth_type/auth_data.""" + headers: dict[str, str] = { + "Accept": "application/vnd.api+json", + "Content-Type": "application/vnd.api+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 CreateEventInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + owner_id: str = Field(description="The ID of the user who owns the event") + title: str = Field(description="The title of the event") + slug: str | None = Field(default=None, description="The slug of the event") + status: str | None = Field(default=None, description="The status of the event: draft, published") + description: str | None = Field(default=None, description="The HTML description of the event") + recording_enabled: bool | None = Field(default=None, description="Whether the event is recorded") + chat_enabled: bool | None = Field(default=None, description="Whether the chat is enabled") + everyone_can_speak: bool | None = Field(default=None, description="Whether everyone can speak") + detailed_registration_page_enabled: bool | None = Field(default=None, description="Whether the detailed registration page is enabled") + light_registration_page_enabled: bool | None = Field(default=None, description="Whether the light registration page is enabled") + recording_public: bool | None = Field(default=None, description="Whether the recording is public") + show_in_company_page: bool | None = Field(default=None, description="Whether the event is shown in the company page") + polls_enabled: bool | None = Field(default=None, description="Whether the polls are enabled") + questions_enabled: bool | None = Field(default=None, description="Whether the questions are enabled") + + +class GetEventInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + event_id: str = Field(description="The ID of the event") + + +class ListAttendeesFromEventInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + event_id: str = Field(description="The ID of the event") + role_filter: str | None = Field(default=None, description="Filter by role: participant, team_member") + + +class ListEventsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + title_filter: str | None = Field(default=None, description="Filter events by title") + + +class ListSessionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +class RegisterSomeoneForSessionInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + session_id: str = Field(description="The ID of the session") + referrer: str | None = Field(default=None, description="The referrer of the person registering") + utm_source: str | None = Field(default=None, description="The UTM source") + utm_medium: str | None = Field(default=None, description="The UTM medium") + utm_campaign: str | None = Field(default=None, description="The UTM campaign") + utm_term: str | None = Field(default=None, description="The UTM term") + utm_content: str | None = Field(default=None, description="The UTM content") + fields: dict[str, Any] | None = Field(default=None, description="Registration fields as key-value pairs where key is the field ID and value is the field value") + + +class UpdateEventInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + event_id: str = Field(description="The ID of the event") + owner_id: str = Field(description="The ID of the user who owns the event") + title: str = Field(description="The title of the event") + slug: str = Field(description="The slug of the event") + status: str = Field(description="The status of the event: draft, published") + description: str = Field(description="The HTML description of the event") + recording_enabled: bool = Field(description="Whether the event is recorded") + chat_enabled: bool = Field(description="Whether the chat is enabled") + everyone_can_speak: bool = Field(description="Whether everyone can speak") + detailed_registration_page_enabled: bool = Field(description="Whether the detailed registration page is enabled") + light_registration_page_enabled: bool = Field(description="Whether the light registration page is enabled") + recording_public: bool = Field(description="Whether the recording is public") + show_in_company_page: bool = Field(description="Whether the event is shown in the company page") + polls_enabled: bool = Field(description="Whether the polls are enabled") + questions_enabled: bool = Field(description="Whether the questions are enabled") + + +# --- @tool functions ---------------------------------------------------------- + + +@tool(args_schema=CreateEventInput) +@serialize_pydantic_return +async def create_event( + auth_type: str, + auth_data: dict[str, Any], + owner_id: str, + title: str, + slug: str | None = None, + status: str | None = None, + description: str | None = None, + recording_enabled: bool | None = None, + chat_enabled: bool | None = None, + everyone_can_speak: bool | None = None, + detailed_registration_page_enabled: bool | None = None, + light_registration_page_enabled: bool | None = None, + recording_public: bool | None = None, + show_in_company_page: bool | None = None, + polls_enabled: bool | None = None, + questions_enabled: bool | None = None, +) -> CreateEventOutput: + """Create a new event.""" + if not auth_data.get("access_token"): + return CreateEventOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + attributes: dict[str, Any] = { + "owner_id": owner_id, + "title": title, + } + if slug is not None: + attributes["slug"] = slug + if status is not None: + attributes["status"] = status + if description is not None: + attributes["description"] = description + if recording_enabled is not None: + attributes["recording_enabled"] = recording_enabled + if chat_enabled is not None: + attributes["chat_enabled"] = chat_enabled + if everyone_can_speak is not None: + attributes["everyone_can_speak"] = everyone_can_speak + if detailed_registration_page_enabled is not None: + attributes["detailed_registration_page_enabled"] = detailed_registration_page_enabled + if light_registration_page_enabled is not None: + attributes["light_registration_page_enabled"] = light_registration_page_enabled + if recording_public is not None: + attributes["recording_public"] = recording_public + if show_in_company_page is not None: + attributes["show_in_company_page"] = show_in_company_page + if polls_enabled is not None: + attributes["polls_enabled"] = polls_enabled + if questions_enabled is not None: + attributes["questions_enabled"] = questions_enabled + + payload = {"data": {"type": "events", "attributes": attributes}} + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/events", + headers=headers, + json=payload, + ) + 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, data=data.get("data")) + + +@tool(args_schema=GetEventInput) +@serialize_pydantic_return +async def get_event( + auth_type: str, + auth_data: dict[str, Any], + event_id: str, +) -> GetEventOutput: + """Retrieve a single event.""" + if not auth_data.get("access_token"): + return GetEventOutput(success=False, error="Missing 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}/events/{event_id}", + headers=headers, + ) + 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, data=data.get("data")) + + +@tool(args_schema=ListAttendeesFromEventInput) +@serialize_pydantic_return +async def list_attendees_from_event( + auth_type: str, + auth_data: dict[str, Any], + event_id: str, + role_filter: str | None = None, +) -> ListAttendeesFromEventOutput: + """List all the people linked to all the sessions of an event.""" + if not auth_data.get("access_token"): + return ListAttendeesFromEventOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, str] = {} + if role_filter is not None: + params["filter[role]"] = role_filter + + all_items: list[dict[str, Any]] = [] + page_number = 1 + max_pages = 50 + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while page_number <= max_pages: + params["page[number]"] = str(page_number) + response = await client.get( + f"{_BASE_URL}/events/{event_id}/people", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListAttendeesFromEventOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + items = body.get("data", []) + if not items: + break + all_items.extend(items) + meta = body.get("meta", {}) + if page_number >= meta.get("page_count", 1): + break + page_number += 1 + except httpx.TimeoutException: + return ListAttendeesFromEventOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListAttendeesFromEventOutput(success=False, error=f"Call failed: {exc}") + + return ListAttendeesFromEventOutput(success=True, data=all_items) + + +@tool(args_schema=ListEventsInput) +@serialize_pydantic_return +async def list_events( + auth_type: str, + auth_data: dict[str, Any], + title_filter: str | None = None, +) -> ListEventsOutput: + """List the events of your workspace.""" + if not auth_data.get("access_token"): + return ListEventsOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + params: dict[str, str] = {} + if title_filter is not None: + params["filter[title]"] = title_filter + + all_items: list[dict[str, Any]] = [] + page_number = 1 + max_pages = 50 + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while page_number <= max_pages: + params["page[number]"] = str(page_number) + response = await client.get( + f"{_BASE_URL}/events", + headers=headers, + params=params, + ) + if response.status_code != 200: + return ListEventsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + items = body.get("data", []) + if not items: + break + all_items.extend(items) + meta = body.get("meta", {}) + if page_number >= meta.get("page_count", 1): + break + page_number += 1 + except httpx.TimeoutException: + return ListEventsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListEventsOutput(success=False, error=f"Call failed: {exc}") + + return ListEventsOutput(success=True, data=all_items) + + +@tool(args_schema=ListSessionsInput) +@serialize_pydantic_return +async def list_sessions( + auth_type: str, + auth_data: dict[str, Any], +) -> ListSessionsOutput: + """List all your event sessions.""" + if not auth_data.get("access_token"): + return ListSessionsOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + + all_items: list[dict[str, Any]] = [] + page_number = 1 + max_pages = 50 + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while page_number <= max_pages: + response = await client.get( + f"{_BASE_URL}/sessions", + headers=headers, + params={"page[number]": str(page_number)}, + ) + if response.status_code != 200: + return ListSessionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + items = body.get("data", []) + if not items: + break + all_items.extend(items) + meta = body.get("meta", {}) + if page_number >= meta.get("page_count", 1): + break + page_number += 1 + 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, data=all_items) + + +@tool(args_schema=RegisterSomeoneForSessionInput) +@serialize_pydantic_return +async def register_someone_for_session( + auth_type: str, + auth_data: dict[str, Any], + session_id: str, + referrer: str | None = None, + utm_source: str | None = None, + utm_medium: str | None = None, + utm_campaign: str | None = None, + utm_term: str | None = None, + utm_content: str | None = None, + fields: dict[str, Any] | None = None, +) -> RegisterSomeoneForSessionOutput: + """Register a new participant for a session.""" + if not auth_data.get("access_token"): + return RegisterSomeoneForSessionOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + + attributes: dict[str, Any] = {} + if referrer is not None: + attributes["referrer"] = referrer + if utm_source is not None: + attributes["utm_source"] = utm_source + if utm_medium is not None: + attributes["utm_medium"] = utm_medium + if utm_campaign is not None: + attributes["utm_campaign"] = utm_campaign + if utm_term is not None: + attributes["utm_term"] = utm_term + if utm_content is not None: + attributes["utm_content"] = utm_content + + fields_array: list[dict[str, Any]] = [] + if fields: + for field_id, value in fields.items(): + fields_array.append({"id": field_id, "value": value}) + if fields_array: + attributes["fields"] = fields_array + + payload: dict[str, Any] = { + "data": {"type": "people", "attributes": attributes}, + } + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/sessions/{session_id}/people", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return RegisterSomeoneForSessionOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return RegisterSomeoneForSessionOutput(success=False, error="Request timed out.") + except Exception as exc: + return RegisterSomeoneForSessionOutput(success=False, error=f"Call failed: {exc}") + + return RegisterSomeoneForSessionOutput(success=True, data=data.get("data")) + + +@tool(args_schema=UpdateEventInput) +@serialize_pydantic_return +async def update_event( + auth_type: str, + auth_data: dict[str, Any], + event_id: str, + owner_id: str, + title: str, + slug: str, + status: str, + description: str, + recording_enabled: bool, + chat_enabled: bool, + everyone_can_speak: bool, + detailed_registration_page_enabled: bool, + light_registration_page_enabled: bool, + recording_public: bool, + show_in_company_page: bool, + polls_enabled: bool, + questions_enabled: bool, +) -> UpdateEventOutput: + """Update an event with its full list of attributes.""" + if not auth_data.get("access_token"): + return UpdateEventOutput(success=False, error="Missing access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + attributes: dict[str, Any] = { + "owner_id": owner_id, + "title": title, + "slug": slug, + "status": status, + "description": description, + "recording_enabled": recording_enabled, + "chat_enabled": chat_enabled, + "everyone_can_speak": everyone_can_speak, + "detailed_registration_page_enabled": detailed_registration_page_enabled, + "light_registration_page_enabled": light_registration_page_enabled, + "recording_public": recording_public, + "show_in_company_page": show_in_company_page, + "polls_enabled": polls_enabled, + "questions_enabled": questions_enabled, + } + + payload = {"data": {"type": "events", "attributes": attributes}} + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put( + f"{_BASE_URL}/events/{event_id}", + headers=headers, + json=payload, + ) + if response.status_code != 200: + return UpdateEventOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return UpdateEventOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateEventOutput(success=False, error=f"Call failed: {exc}") + + return UpdateEventOutput(success=True, data=data.get("data")) From 3aa20569661e9b138c5e709e73a3b532b80663e3 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Fri, 29 May 2026 19:22:09 +0000 Subject: [PATCH 07/15] auto-integrate: motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the **motion** integration — 6 actions for AI-powered task and project management with automatic scheduling via the Motion REST API. **Actions:** `create_task`, `delete_task`, `get_schedules`, `get_task`, `move_workspace`, `update_task` **Auth:** API Key (`X-API-Key` header). Test endpoint validates against `GET /v1/users/me`. **Auditor patches applied (1):** 1. **manifest.py line 23 (check 8.9, mechanical)** — Replaced CDN logo URL with the standard `modulex:motion-themed` placeholder per logo convention enforcement. **Consumer-side additions:** - Registered `motion` entry-point in `pyproject.toml` - Added ruff per-file-ignores for E501 on long description literals - Updated `CHANGELOG.md` Unreleased/Added section **Gate results:** - ruff: PASS - mypy --strict: PASS - pytest: PASS (10 tests, all passed) Provider: primary Run: 26657032382 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 6 + .../tools/motion/README.md | 33 ++ .../tools/motion/__init__.py | 30 ++ .../tools/motion/dependencies.toml | 3 + .../tools/motion/manifest.py | 232 +++++++++++ .../tools/motion/outputs.py | 97 +++++ .../tools/motion/tests/__init__.py | 1 + .../tools/motion/tests/test_motion.py | 231 +++++++++++ .../tools/motion/tools.py | 367 ++++++++++++++++++ 10 files changed, 1006 insertions(+) create mode 100644 src/modulex_integrations/tools/motion/README.md create mode 100644 src/modulex_integrations/tools/motion/__init__.py create mode 100644 src/modulex_integrations/tools/motion/dependencies.toml create mode 100644 src/modulex_integrations/tools/motion/manifest.py create mode 100644 src/modulex_integrations/tools/motion/outputs.py create mode 100644 src/modulex_integrations/tools/motion/tests/__init__.py create mode 100644 src/modulex_integrations/tools/motion/tests/test_motion.py create mode 100644 src/modulex_integrations/tools/motion/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d323221..ba2c110 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `motion` integration — 6 actions, auth: api_key. AI-powered task and + project management platform for automatic scheduling via the Motion API + (create_task, delete_task, get_schedules, get_task, move_workspace, + update_task). Producer-staged by integration-drafts; consumer-side audit + applied 1 patch before merge. + - `livestorm` integration — 7 actions, auth: oauth2. Video engagement platform for webinars and virtual events via the Livestorm REST API (create_event, get_event, list_attendees_from_event, list_events, diff --git a/pyproject.toml b/pyproject.toml index d9ab751..305e637 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -181,6 +181,7 @@ microsoft_teams = "modulex_integrations.tools.microsoft_teams" mintlify = "modulex_integrations.tools.mintlify" mixpanel = "modulex_integrations.tools.mixpanel" monday = "modulex_integrations.tools.monday" +motion = "modulex_integrations.tools.motion" postgrid = "modulex_integrations.tools.postgrid" posthog = "modulex_integrations.tools.posthog" postman = "modulex_integrations.tools.postman" @@ -614,6 +615,11 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/yelp/manifest.py" = ["E501"] "src/modulex_integrations/tools/yelp/tools.py" = ["E501"] +# motion 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/motion/manifest.py" = ["E501"] +"src/modulex_integrations/tools/motion/tools.py" = ["E501"] +"src/modulex_integrations/tools/motion/tests/test_motion.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/motion/README.md b/src/modulex_integrations/tools/motion/README.md new file mode 100644 index 0000000..07c9cc4 --- /dev/null +++ b/src/modulex_integrations/tools/motion/README.md @@ -0,0 +1,33 @@ +# Motion + +AI-powered task and project management platform with automatic scheduling, accessed via the Motion REST API (`api.usemotion.com/v1`). + +## Authentication + +### API Key Authentication + +- Sign in at , navigate to **Settings > API**, and generate or copy your API key. +- Required env var: `MOTION_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxx`). + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_task` | Create a new task in a Motion workspace | `workspace_id`, `name` | +| `delete_task` | Delete a specific task by ID | `task_id` | +| `get_schedules` | Get a list of schedules for the authenticated user | — | +| `get_task` | Retrieve a specific task by ID | `task_id` | +| `move_workspace` | Move a task to another workspace. Resets the task's project, status, labels, and assignee | `task_id`, `workspace_id` | +| `update_task` | Update a specific task's properties | `task_id` | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Rate limit**: 12 requests per minute per API key (Motion API documentation). +- **Pricing**: API access requires a Motion Individual or Team plan. +- **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/motion/__init__.py b/src/modulex_integrations/tools/motion/__init__.py new file mode 100644 index 0000000..04c703f --- /dev/null +++ b/src/modulex_integrations/tools/motion/__init__.py @@ -0,0 +1,30 @@ +"""Motion integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.motion.manifest import manifest +from modulex_integrations.tools.motion.tools import ( + create_task, + delete_task, + get_schedules, + get_task, + move_workspace, + update_task, +) + +TOOLS = ( + create_task, + delete_task, + get_schedules, + get_task, + move_workspace, + update_task, +) + +__all__ = [ + "TOOLS", + "create_task", + "delete_task", + "get_schedules", + "get_task", + "manifest", + "move_workspace", + "update_task", +] diff --git a/src/modulex_integrations/tools/motion/dependencies.toml b/src/modulex_integrations/tools/motion/dependencies.toml new file mode 100644 index 0000000..c857a56 --- /dev/null +++ b/src/modulex_integrations/tools/motion/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the motion integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/motion/manifest.py b/src/modulex_integrations/tools/motion/manifest.py new file mode 100644 index 0000000..1503522 --- /dev/null +++ b/src/modulex_integrations/tools/motion/manifest.py @@ -0,0 +1,232 @@ +"""Motion integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="motion", + display_name="Motion", + description="AI-powered task and project management platform for automatic scheduling", + version="1.0.0", + author="ModuleX", + logo="modulex:motion-themed", + app_url="https://www.usemotion.com", + categories=["Productivity & Collaboration", "project-management"], + actions=[ + ActionDefinition( + name="create_task", + description="Create a new task in a Motion workspace", + parameters={ + "workspace_id": ParameterDef( + type="string", + description="The ID of the workspace", + required=True, + ), + "name": ParameterDef( + type="string", + description="Name / title of the task", + required=True, + ), + "project_id": ParameterDef( + type="string", + description="The ID of the project to assign the task to", + ), + "due_date": ParameterDef( + type="string", + description="ISO 8601 due date. Required for scheduled tasks. Example: 2023-06-28T10:11:14.320-06:00", + ), + "duration": ParameterDef( + type="string", + description="Duration: NONE, REMINDER, or an integer greater than 0", + ), + "description": ParameterDef( + type="string", + description="Task description in GitHub Flavored Markdown", + ), + "priority": ParameterDef( + type="string", + description="Priority level: ASAP, HIGH, MEDIUM, LOW", + default="MEDIUM", + ), + "assignee_id": ParameterDef( + type="string", + description="The user ID to assign the task to", + ), + "labels": ParameterDef( + type="array", + description="List of label names to add to the task", + ), + "status": ParameterDef( + type="string", + description="The name of the task status", + ), + "start_date": ParameterDef( + type="string", + description="ISO 8601 date for auto-scheduled tasks. Example: 2023-06-28", + ), + "deadline_type": ParameterDef( + type="string", + description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE", + ), + "schedule": ParameterDef( + type="string", + description="Schedule the task must adhere to. Must be 'Work Hours' if scheduling for another user", + default="Work Hours", + ), + }, + ), + ActionDefinition( + name="delete_task", + description="Delete a specific task by ID", + parameters={ + "task_id": ParameterDef( + type="string", + description="The ID of the task to delete", + required=True, + ), + }, + ), + ActionDefinition( + name="get_schedules", + description="Get a list of schedules for the authenticated user", + parameters={}, + ), + ActionDefinition( + name="get_task", + description="Retrieve a specific task by ID", + parameters={ + "task_id": ParameterDef( + type="string", + description="The ID of the task to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="move_workspace", + description="Move a task to another workspace. Resets the task's project, status, labels, and assignee", + parameters={ + "task_id": ParameterDef( + type="string", + description="The ID of the task to move", + required=True, + ), + "workspace_id": ParameterDef( + type="string", + description="The ID of the target workspace", + required=True, + ), + "assignee_id": ParameterDef( + type="string", + description="The user ID to assign the task to in the target workspace", + ), + }, + ), + ActionDefinition( + name="update_task", + description="Update a specific task's properties", + parameters={ + "task_id": ParameterDef( + type="string", + description="The ID of the task to update", + required=True, + ), + "name": ParameterDef( + type="string", + description="New name / title for the task", + ), + "due_date": ParameterDef( + type="string", + description="ISO 8601 due date. Example: 2023-06-28T10:11:14.320-06:00", + ), + "duration": ParameterDef( + type="string", + description="Duration: NONE, REMINDER, or an integer greater than 0", + ), + "project_id": ParameterDef( + type="string", + description="The ID of the project to assign the task to", + ), + "description": ParameterDef( + type="string", + description="Task description in GitHub Flavored Markdown", + ), + "priority": ParameterDef( + type="string", + description="Priority level: ASAP, HIGH, MEDIUM, LOW", + default="MEDIUM", + ), + "assignee_id": ParameterDef( + type="string", + description="The user ID to assign the task to", + ), + "labels": ParameterDef( + type="array", + description="List of label names to add to the task", + ), + "status": ParameterDef( + type="string", + description="The name of the task status", + ), + "start_date": ParameterDef( + type="string", + description="ISO 8601 date for auto-scheduled tasks. Example: 2023-06-28", + ), + "deadline_type": ParameterDef( + type="string", + description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE", + ), + "schedule": ParameterDef( + type="string", + description="Schedule the task must adhere to. Must be 'Work Hours' if scheduling for another user", + default="Work Hours", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Motion API key", + setup_instructions=[ + "Go to https://app.usemotion.com and sign in", + "Navigate to Settings > API", + "Generate a new API key or copy your existing one", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="MOTION_API_KEY", + display_name="Motion API Key", + description="Your Motion API key from the Settings > API page", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxx", + about_url="https://app.usemotion.com/settings", + ), + ], + test_endpoint=TestEndpoint( + url="https://api.usemotion.com/v1/users/me", + method="GET", + headers={"X-API-Key": "{api_key}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["id"], + ), + cost_level="free", + description="Validates the API key by fetching the current user", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/motion/outputs.py b/src/modulex_integrations/tools/motion/outputs.py new file mode 100644 index 0000000..3517921 --- /dev/null +++ b/src/modulex_integrations/tools/motion/outputs.py @@ -0,0 +1,97 @@ +"""Pydantic response models for the motion integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateTaskOutput", + "DeleteTaskOutput", + "GetSchedulesOutput", + "GetTaskOutput", + "MoveWorkspaceOutput", + "ScheduleItem", + "TaskObject", + "TaskStatus", + "UpdateTaskOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class TaskStatus(_Base): + """Status information for a task.""" + + name: str | None = None + is_default_status: bool | None = None + is_resolved_status: bool | None = None + + +class TaskObject(_Base): + """A Motion task object returned by task-related actions.""" + + id: str | None = None + name: str | None = None + description: str | None = None + status: TaskStatus | None = None + workspace_id: str | None = None + project_id: str | None = None + priority: str | None = None + assignee_id: str | None = None + labels: list[str] = Field(default_factory=list) + due_date: str | None = None + duration: str | None = None + created_at: str | None = None + + +class ScheduleItem(_Base): + """A schedule object returned by the get_schedules action.""" + + id: str | None = None + name: str | None = None + timezone: str | None = None + is_default: bool | None = None + + +# --- Per-action output models ---------------------------------------------- + + +class CreateTaskOutput(_Base): + success: bool + error: str | None = None + task: TaskObject | None = None + + +class DeleteTaskOutput(_Base): + success: bool + error: str | None = None + + +class GetSchedulesOutput(_Base): + success: bool + error: str | None = None + schedules: list[ScheduleItem] = Field(default_factory=list) + + +class GetTaskOutput(_Base): + success: bool + error: str | None = None + task: TaskObject | None = None + + +class MoveWorkspaceOutput(_Base): + success: bool + error: str | None = None + task: TaskObject | None = None + + +class UpdateTaskOutput(_Base): + success: bool + error: str | None = None + task: TaskObject | None = None diff --git a/src/modulex_integrations/tools/motion/tests/__init__.py b/src/modulex_integrations/tools/motion/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/motion/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/motion/tests/test_motion.py b/src/modulex_integrations/tools/motion/tests/test_motion.py new file mode 100644 index 0000000..a541b1e --- /dev/null +++ b/src/modulex_integrations/tools/motion/tests/test_motion.py @@ -0,0 +1,231 @@ +"""Happy-path tests for every motion @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.motion import ( + TOOLS, + create_task, + delete_task, + get_schedules, + get_task, + manifest, + move_workspace, + update_task, +) +from modulex_integrations.tools.motion.outputs import ( + CreateTaskOutput, + DeleteTaskOutput, + GetSchedulesOutput, + GetTaskOutput, + MoveWorkspaceOutput, + UpdateTaskOutput, +) + +API = "https://api.usemotion.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_6_actions(self) -> None: + assert len(manifest.actions) == 6 + + 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_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 Motion API docs + "id": "task_123", + "name": "Test Task", + "description": None, + "status": {"name": "To Do", "isDefaultStatus": True, "isResolvedStatus": False}, + "workspaceId": "ws_1", + "projectId": None, + "priority": "MEDIUM", + "assigneeId": None, + "labels": [], + "dueDate": None, + "duration": None, + "createdAt": "2023-06-28T10:00:00Z", + }, + ) + + result_dict = await create_task.ainvoke( + _args(workspace_id="ws_1", name="Test Task") + ) + + assert isinstance(result_dict, dict) + result = CreateTaskOutput.model_validate(result_dict) + assert result.success is True + assert result.task is not None + assert result.task.id == "task_123" + + sent = httpx_mock.get_requests()[0] + assert sent.headers["X-API-Key"] == _API_KEY + + +@pytest.mark.asyncio +async def test_delete_task(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/tasks/task_456", + status_code=204, + ) + + result_dict = await delete_task.ainvoke(_args(task_id="task_456")) + + assert isinstance(result_dict, dict) + result = DeleteTaskOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_schedules(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/schedules", + json=[ + # TODO: fill in a representative response shape from the Motion API docs + {"id": "sched_1", "name": "Work Hours", "timezone": "America/New_York", "isDefault": True}, + ], + ) + + result_dict = await get_schedules.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = GetSchedulesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.schedules) == 1 + assert result.schedules[0].name == "Work Hours" + + +@pytest.mark.asyncio +async def test_get_task(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/tasks/task_789", + json={ + # TODO: fill in a representative response shape from the Motion API docs + "id": "task_789", + "name": "My Task", + "description": "A description", + "status": {"name": "In Progress", "isDefaultStatus": False, "isResolvedStatus": False}, + "workspaceId": "ws_1", + "projectId": "proj_1", + "priority": "HIGH", + "assigneeId": "user_1", + "labels": ["urgent"], + "dueDate": "2023-07-01T00:00:00Z", + "duration": "60", + "createdAt": "2023-06-20T08:00:00Z", + }, + ) + + result_dict = await get_task.ainvoke(_args(task_id="task_789")) + + assert isinstance(result_dict, dict) + result = GetTaskOutput.model_validate(result_dict) + assert result.success is True + assert result.task is not None + assert result.task.name == "My Task" + + +@pytest.mark.asyncio +async def test_move_workspace(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/tasks/task_100/move", + json={ + # TODO: fill in a representative response shape from the Motion API docs + "id": "task_100", + "name": "Moved Task", + "description": None, + "status": {"name": "To Do", "isDefaultStatus": True, "isResolvedStatus": False}, + "workspaceId": "ws_2", + "projectId": None, + "priority": "LOW", + "assigneeId": None, + "labels": [], + "dueDate": None, + "duration": None, + "createdAt": "2023-06-15T12:00:00Z", + }, + ) + + result_dict = await move_workspace.ainvoke( + _args(task_id="task_100", workspace_id="ws_2") + ) + + assert isinstance(result_dict, dict) + result = MoveWorkspaceOutput.model_validate(result_dict) + assert result.success is True + assert result.task is not None + assert result.task.workspace_id == "ws_2" + + +@pytest.mark.asyncio +async def test_update_task(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PATCH", + url=f"{API}/tasks/task_200", + json={ + # TODO: fill in a representative response shape from the Motion API docs + "id": "task_200", + "name": "Updated Task", + "description": "New description", + "status": {"name": "In Progress", "isDefaultStatus": False, "isResolvedStatus": False}, + "workspaceId": "ws_1", + "projectId": "proj_2", + "priority": "HIGH", + "assigneeId": "user_2", + "labels": ["review"], + "dueDate": "2023-08-01T00:00:00Z", + "duration": "30", + "createdAt": "2023-06-10T09:00:00Z", + }, + ) + + result_dict = await update_task.ainvoke( + _args(task_id="task_200", name="Updated Task", priority="HIGH") + ) + + assert isinstance(result_dict, dict) + result = UpdateTaskOutput.model_validate(result_dict) + assert result.success is True + assert result.task is not None + assert result.task.name == "Updated Task" + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_task_validates_empty_api_key() -> None: + result_dict = await create_task.ainvoke( + {"workspace_id": "ws_1", "name": "Test", "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/motion/tools.py b/src/modulex_integrations/tools/motion/tools.py new file mode 100644 index 0000000..221de5d --- /dev/null +++ b/src/modulex_integrations/tools/motion/tools.py @@ -0,0 +1,367 @@ +"""Motion 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.motion.outputs import ( + CreateTaskOutput, + DeleteTaskOutput, + GetSchedulesOutput, + GetTaskOutput, + MoveWorkspaceOutput, + ScheduleItem, + TaskObject, + TaskStatus, + UpdateTaskOutput, +) + +__all__ = [ + "create_task", + "delete_task", + "get_schedules", + "get_task", + "move_workspace", + "update_task", +] + +_BASE_URL = "https://api.usemotion.com/v1" +_TIMEOUT = 30.0 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "X-API-Key": api_key, + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def _parse_task(data: dict[str, Any]) -> TaskObject: + status_raw = data.get("status") + status = None + if isinstance(status_raw, dict): + status = TaskStatus( + name=status_raw.get("name"), + is_default_status=status_raw.get("isDefaultStatus"), + is_resolved_status=status_raw.get("isResolvedStatus"), + ) + return TaskObject( + id=data.get("id"), + name=data.get("name"), + description=data.get("description"), + status=status, + workspace_id=data.get("workspaceId"), + project_id=data.get("projectId"), + priority=data.get("priority"), + assignee_id=data.get("assigneeId"), + labels=data.get("labels") or [], + due_date=data.get("dueDate"), + duration=str(data["duration"]) if data.get("duration") is not None else None, + created_at=data.get("createdAt"), + ) + + +# --- Input schemas -------------------------------------------------------- + + +class CreateTaskInput(BaseModel): + workspace_id: str = Field(description="The ID of the workspace") + name: str = Field(description="Name / title of the task") + api_key: str = Field(description="Motion API key") + project_id: str | None = Field(default=None, description="The ID of the project to assign the task to") + due_date: str | None = Field(default=None, description="ISO 8601 due date. Required for scheduled tasks") + duration: str | None = Field(default=None, description="Duration: NONE, REMINDER, or an integer greater than 0") + description: str | None = Field(default=None, description="Task description in GitHub Flavored Markdown") + priority: str = Field(default="MEDIUM", description="Priority level: ASAP, HIGH, MEDIUM, LOW") + assignee_id: str | None = Field(default=None, description="The user ID to assign the task to") + labels: list[str] | None = Field(default=None, description="List of label names to add to the task") + status: str | None = Field(default=None, description="The name of the task status") + start_date: str | None = Field(default=None, description="ISO 8601 date for auto-scheduled tasks") + deadline_type: str | None = Field(default=None, description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE") + schedule: str | None = Field(default=None, description="Schedule the task must adhere to") + + +class DeleteTaskInput(BaseModel): + task_id: str = Field(description="The ID of the task to delete") + api_key: str = Field(description="Motion API key") + + +class GetSchedulesInput(BaseModel): + api_key: str = Field(description="Motion API key") + + +class GetTaskInput(BaseModel): + task_id: str = Field(description="The ID of the task to retrieve") + api_key: str = Field(description="Motion API key") + + +class MoveWorkspaceInput(BaseModel): + task_id: str = Field(description="The ID of the task to move") + workspace_id: str = Field(description="The ID of the target workspace") + api_key: str = Field(description="Motion API key") + assignee_id: str | None = Field(default=None, description="The user ID to assign the task to in the target workspace") + + +class UpdateTaskInput(BaseModel): + task_id: str = Field(description="The ID of the task to update") + api_key: str = Field(description="Motion API key") + name: str | None = Field(default=None, description="New name / title for the task") + due_date: str | None = Field(default=None, description="ISO 8601 due date") + duration: str | None = Field(default=None, description="Duration: NONE, REMINDER, or an integer greater than 0") + project_id: str | None = Field(default=None, description="The ID of the project to assign the task to") + description: str | None = Field(default=None, description="Task description in GitHub Flavored Markdown") + priority: str | None = Field(default=None, description="Priority level: ASAP, HIGH, MEDIUM, LOW") + assignee_id: str | None = Field(default=None, description="The user ID to assign the task to") + labels: list[str] | None = Field(default=None, description="List of label names to add to the task") + status: str | None = Field(default=None, description="The name of the task status") + start_date: str | None = Field(default=None, description="ISO 8601 date for auto-scheduled tasks") + deadline_type: str | None = Field(default=None, description="Deadline type for auto-scheduled tasks: HARD, SOFT, NONE") + schedule: str | None = Field(default=None, description="Schedule the task must adhere to") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateTaskInput) +@serialize_pydantic_return +async def create_task( + workspace_id: str, + name: str, + api_key: str, + project_id: str | None = None, + due_date: str | None = None, + duration: str | None = None, + description: str | None = None, + priority: str = "MEDIUM", + assignee_id: str | None = None, + labels: list[str] | None = None, + status: str | None = None, + start_date: str | None = None, + deadline_type: str | None = None, + schedule: str | None = None, +) -> CreateTaskOutput: + """Create a new task in a Motion workspace.""" + if not api_key or not api_key.strip(): + return CreateTaskOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = { + "workspaceId": workspace_id, + "name": name, + "priority": priority, + } + if project_id is not None: + body["projectId"] = project_id + if due_date is not None: + body["dueDate"] = due_date + if duration is not None: + body["duration"] = duration + if description is not None: + body["description"] = description + if assignee_id is not None: + body["assigneeId"] = assignee_id + if labels is not None: + body["labels"] = labels + if status is not None: + body["status"] = status + if start_date is not None: + body["startDate"] = start_date + if deadline_type is not None: + body["deadlineType"] = deadline_type + if schedule is not None: + body["schedule"] = schedule + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/tasks", + headers=_headers(api_key), + 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=_parse_task(data)) + + +@tool(args_schema=DeleteTaskInput) +@serialize_pydantic_return +async def delete_task( + task_id: str, + api_key: str, +) -> DeleteTaskOutput: + """Delete a specific task by ID.""" + if not api_key or not api_key.strip(): + return DeleteTaskOutput(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}/tasks/{task_id}", + headers=_headers(api_key), + ) + if response.status_code not in (200, 204): + return DeleteTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + except httpx.TimeoutException: + return DeleteTaskOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteTaskOutput(success=False, error=f"Call failed: {exc}") + return DeleteTaskOutput(success=True) + + +@tool(args_schema=GetSchedulesInput) +@serialize_pydantic_return +async def get_schedules( + api_key: str, +) -> GetSchedulesOutput: + """Get a list of schedules for the authenticated user.""" + if not api_key or not api_key.strip(): + return GetSchedulesOutput(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}/schedules", + headers=_headers(api_key), + ) + if response.status_code != 200: + return GetSchedulesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetSchedulesOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetSchedulesOutput(success=False, error=f"Call failed: {exc}") + items = data if isinstance(data, list) else data.get("schedules", []) + schedules = [ + ScheduleItem( + id=s.get("id"), + name=s.get("name"), + timezone=s.get("timezone"), + is_default=s.get("isDefault"), + ) + for s in items + ] + return GetSchedulesOutput(success=True, schedules=schedules) + + +@tool(args_schema=GetTaskInput) +@serialize_pydantic_return +async def get_task( + task_id: str, + api_key: str, +) -> GetTaskOutput: + """Retrieve a specific task by ID.""" + if not api_key or not api_key.strip(): + return GetTaskOutput(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}/tasks/{task_id}", + headers=_headers(api_key), + ) + if response.status_code != 200: + return GetTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetTaskOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetTaskOutput(success=False, error=f"Call failed: {exc}") + return GetTaskOutput(success=True, task=_parse_task(data)) + + +@tool(args_schema=MoveWorkspaceInput) +@serialize_pydantic_return +async def move_workspace( + task_id: str, + workspace_id: str, + api_key: str, + assignee_id: str | None = None, +) -> MoveWorkspaceOutput: + """Move a task to another workspace. Resets the task's project, status, labels, and assignee.""" + if not api_key or not api_key.strip(): + return MoveWorkspaceOutput(success=False, error="API key is empty. Please configure a valid credential.") + body: dict[str, Any] = {"workspaceId": workspace_id} + if assignee_id is not None: + body["assigneeId"] = assignee_id + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/tasks/{task_id}/move", + headers=_headers(api_key), + json=body, + ) + if response.status_code != 200: + return MoveWorkspaceOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return MoveWorkspaceOutput(success=False, error="Request timed out.") + except Exception as exc: + return MoveWorkspaceOutput(success=False, error=f"Call failed: {exc}") + return MoveWorkspaceOutput(success=True, task=_parse_task(data)) + + +@tool(args_schema=UpdateTaskInput) +@serialize_pydantic_return +async def update_task( + task_id: str, + api_key: str, + name: str | None = None, + due_date: str | None = None, + duration: str | None = None, + project_id: str | None = None, + description: str | None = None, + priority: str | None = None, + assignee_id: str | None = None, + labels: list[str] | None = None, + status: str | None = None, + start_date: str | None = None, + deadline_type: str | None = None, + schedule: str | None = None, +) -> UpdateTaskOutput: + """Update a specific task's properties.""" + if not api_key or not api_key.strip(): + return UpdateTaskOutput(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 due_date is not None: + body["dueDate"] = due_date + if duration is not None: + body["duration"] = duration + if project_id is not None: + body["projectId"] = project_id + if description is not None: + body["description"] = description + if priority is not None: + body["priority"] = priority + if assignee_id is not None: + body["assigneeId"] = assignee_id + if labels is not None: + body["labels"] = labels + if status is not None: + body["status"] = status + if start_date is not None: + body["startDate"] = start_date + if deadline_type is not None: + body["deadlineType"] = deadline_type + if schedule is not None: + body["schedule"] = schedule + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.patch( + f"{_BASE_URL}/tasks/{task_id}", + headers=_headers(api_key), + json=body, + ) + if response.status_code != 200: + return UpdateTaskOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateTaskOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateTaskOutput(success=False, error=f"Call failed: {exc}") + return UpdateTaskOutput(success=True, task=_parse_task(data)) From feff752ef6c09b34406b75d65b32b5830438d48c Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 00:09:02 +0000 Subject: [PATCH 08/15] auto-integrate: canvas ## Summary Adds the **canvas** (Canvas LMS) integration to `modulex-integrations`. - **5 actions:** `list_accounts`, `list_courses`, `list_assignments`, `search_course_content`, `update_assignment` - **Auth:** Custom (Canvas domain + access token) - **Producer:** integration-drafts (pdream-to-modulex pipeline) - **Auditor verdict:** NEEDS_REWORK (2 patches applied by merger) ## Patches applied | # | File | Check | Description | Strategy | |---|------|-------|-------------|----------| | 1 | `manifest.py` | 8.9 | Logo convention: changed `simple-icons:canvas` to `modulex:canvas-themed` | mechanical | | 2 | `tests/test_canvas.py` | 6.5 | Added failure-path test (`test_list_accounts_empty_credentials`) verifying empty credentials return `success=False` | mechanical | Additionally, the merger fixed: - Removed unused `typing.Any` import in `outputs.py` (ruff auto-fix) - Added `-> None` return annotation to the failure-path test function (mypy --strict compliance) - Added `E501` per-file-ignores for `canvas/manifest.py` and `canvas/tools.py` in `pyproject.toml` (consistent with all other integrations in this repo) ## Gate results | Gate | Result | Details | |------|--------|---------| | pip install -e ".[dev]" | PASS | Clean install | | Import check | PASS | `manifest.name=canvas`, 5 actions, 5 tools | | ruff | PASS | After auto-fix (F401) + per-file E501 ignores | | mypy --strict | PASS | 6 source files, 0 errors | | pytest | PASS | 9 tests passed (3 manifest, 5 happy-path, 1 failure-path) | ## Dependencies No additional runtime dependencies required (empty `dependencies.toml`). ## Files changed - `src/modulex_integrations/tools/canvas/__init__.py` (new) - `src/modulex_integrations/tools/canvas/manifest.py` (new, 1 patch applied) - `src/modulex_integrations/tools/canvas/tools.py` (new) - `src/modulex_integrations/tools/canvas/outputs.py` (new, unused import removed) - `src/modulex_integrations/tools/canvas/dependencies.toml` (new) - `src/modulex_integrations/tools/canvas/README.md` (new) - `src/modulex_integrations/tools/canvas/tests/__init__.py` (new) - `src/modulex_integrations/tools/canvas/tests/test_canvas.py` (new, 1 patch applied + annotation fix) - `pyproject.toml` (entry-point added; E501 per-file-ignores added) - `CHANGELOG.md` (Unreleased / Added: canvas) ## Manual TODOs for human reviewer - Verify the mock response shapes in `tests/test_canvas.py` match the real Canvas API (TODO comments in test file) - Register Canvas integration credentials in the modulex UI for smoke testing - Confirm `search_course_content` endpoint path (`/courses/{id}/smartsearch`) is correct for your Canvas instance version Provider: primary Run: 26668308016 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 5 + .../tools/canvas/README.md | 35 ++ .../tools/canvas/__init__.py | 27 ++ .../tools/canvas/dependencies.toml | 3 + .../tools/canvas/manifest.py | 159 ++++++++ .../tools/canvas/outputs.py | 97 +++++ .../tools/canvas/tests/__init__.py | 1 + .../tools/canvas/tests/test_canvas.py | 211 ++++++++++ .../tools/canvas/tools.py | 366 ++++++++++++++++++ 10 files changed, 910 insertions(+) create mode 100644 src/modulex_integrations/tools/canvas/README.md create mode 100644 src/modulex_integrations/tools/canvas/__init__.py create mode 100644 src/modulex_integrations/tools/canvas/dependencies.toml create mode 100644 src/modulex_integrations/tools/canvas/manifest.py create mode 100644 src/modulex_integrations/tools/canvas/outputs.py create mode 100644 src/modulex_integrations/tools/canvas/tests/__init__.py create mode 100644 src/modulex_integrations/tools/canvas/tests/test_canvas.py create mode 100644 src/modulex_integrations/tools/canvas/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ba2c110..a1ec990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `canvas` integration — 5 actions, auth: custom. Canvas LMS integration + for course, assignment, and user management via the Canvas REST API + (list_accounts, list_courses, list_assignments, search_course_content, + update_assignment). Producer-staged by integration-drafts; consumer-side + audit applied 2 patches before merge. + - `motion` integration — 6 actions, auth: api_key. AI-powered task and project management platform for automatic scheduling via the Motion API (create_task, delete_task, get_schedules, get_task, move_workspace, diff --git a/pyproject.toml b/pyproject.toml index 305e637..a179914 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -105,6 +105,7 @@ 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" instructure_canvas = "modulex_integrations.tools.instructure_canvas" linear = "modulex_integrations.tools.linear" linkedin = "modulex_integrations.tools.linkedin" @@ -620,6 +621,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] "src/modulex_integrations/tools/motion/manifest.py" = ["E501"] "src/modulex_integrations/tools/motion/tools.py" = ["E501"] "src/modulex_integrations/tools/motion/tests/test_motion.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/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..b35353d --- /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"), + ), + ) From b8420139dbe7b534dfb406b062141de670c89c42 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 07:01:34 +0000 Subject: [PATCH 09/15] auto-integrate: fellow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `fellow` integration — meeting productivity platform for notes, action items, and meeting management via the Fellow API. **Actions (3):** archive_action_item, complete_action_item, get_note_by_id **Auth:** API Key (workspace subdomain + API key from Fellow Settings > Integrations > API) **Patches applied during consumer-side merge (1):** 1. `manifest.py` — Check 8.9: replaced CDN logo URL with `modulex:fellow-themed` convention value (mechanical). **Ruff:** auto-fixed 2 issues (unused `typing.Any` import and import sorting in `tools.py`). **Gates:** All passing — ruff clean, mypy --strict clean, pytest 7/7 passed. **Dependencies:** None (the integration uses only httpx which is already a core dependency). Provider: primary Run: 26677376436 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 5 + pyproject.toml | 1 + .../tools/fellow/README.md | 34 ++++ .../tools/fellow/__init__.py | 21 +++ .../tools/fellow/dependencies.toml | 3 + .../tools/fellow/manifest.py | 104 +++++++++++ .../tools/fellow/outputs.py | 36 ++++ .../tools/fellow/tests/__init__.py | 1 + .../tools/fellow/tests/test_fellow.py | 109 +++++++++++ .../tools/fellow/tools.py | 171 ++++++++++++++++++ 10 files changed, 485 insertions(+) create mode 100644 src/modulex_integrations/tools/fellow/README.md create mode 100644 src/modulex_integrations/tools/fellow/__init__.py create mode 100644 src/modulex_integrations/tools/fellow/dependencies.toml create mode 100644 src/modulex_integrations/tools/fellow/manifest.py create mode 100644 src/modulex_integrations/tools/fellow/outputs.py create mode 100644 src/modulex_integrations/tools/fellow/tests/__init__.py create mode 100644 src/modulex_integrations/tools/fellow/tests/test_fellow.py create mode 100644 src/modulex_integrations/tools/fellow/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ec990..af6cf6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `fellow` integration — 3 actions, auth: api_key. Meeting productivity + platform for notes, action items, and meeting management via the Fellow API + (archive_action_item, complete_action_item, get_note_by_id). Producer-staged + by integration-drafts; consumer-side audit applied 1 patch before merge. + - `canvas` integration — 5 actions, auth: custom. Canvas LMS integration for course, assignment, and user management via the Canvas REST API (list_accounts, list_courses, list_assignments, search_course_content, diff --git a/pyproject.toml b/pyproject.toml index a179914..db2c142 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,7 @@ slack = "modulex_integrations.tools.slack" etsy = "modulex_integrations.tools.etsy" exa = "modulex_integrations.tools.exa" fal_ai = "modulex_integrations.tools.fal_ai" +fellow = "modulex_integrations.tools.fellow" figma = "modulex_integrations.tools.figma" tavily = "modulex_integrations.tools.tavily" insightly = "modulex_integrations.tools.insightly" diff --git a/src/modulex_integrations/tools/fellow/README.md b/src/modulex_integrations/tools/fellow/README.md new file mode 100644 index 0000000..eb6eb7d --- /dev/null +++ b/src/modulex_integrations/tools/fellow/README.md @@ -0,0 +1,34 @@ +# Fellow + +Meeting productivity platform for notes, action items, and meeting management via the Fellow REST API (`https://.fellow.app/api/v1`). + +## Authentication + +### API Key Authentication + +- Sign in to your Fellow workspace at `https://.fellow.app` +- Navigate to **Settings > Integrations > API** and generate or copy your API key +- Note your workspace subdomain (the part before `.fellow.app` in your URL) +- Required env vars: + - `FELLOW_SUBDOMAIN` (format: `mycompany`) — your workspace subdomain + - `FELLOW_API_KEY` (format: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) — your API key +- Docs: + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `archive_action_item` | Archive an action item | `action_item_id` | +| `complete_action_item` | Complete an action item | `action_item_id` | +| `get_note_by_id` | Get a note by its ID | `note_id` | + +Every tool takes additional `subdomain` and `api_key` parameters that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- No documented public rate limits from Fellow's API documentation. +- 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/fellow/__init__.py b/src/modulex_integrations/tools/fellow/__init__.py new file mode 100644 index 0000000..d6be28e --- /dev/null +++ b/src/modulex_integrations/tools/fellow/__init__.py @@ -0,0 +1,21 @@ +"""Fellow integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.fellow.manifest import manifest +from modulex_integrations.tools.fellow.tools import ( + archive_action_item, + complete_action_item, + get_note_by_id, +) + +TOOLS = ( + archive_action_item, + complete_action_item, + get_note_by_id, +) + +__all__ = [ + "TOOLS", + "archive_action_item", + "complete_action_item", + "get_note_by_id", + "manifest", +] diff --git a/src/modulex_integrations/tools/fellow/dependencies.toml b/src/modulex_integrations/tools/fellow/dependencies.toml new file mode 100644 index 0000000..7b49573 --- /dev/null +++ b/src/modulex_integrations/tools/fellow/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the fellow integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/fellow/manifest.py b/src/modulex_integrations/tools/fellow/manifest.py new file mode 100644 index 0000000..5aff5ef --- /dev/null +++ b/src/modulex_integrations/tools/fellow/manifest.py @@ -0,0 +1,104 @@ +"""Fellow integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="fellow", + display_name="Fellow", + description="Meeting productivity platform for notes, action items, and meeting management", + version="1.0.0", + author="ModuleX", + logo="modulex:fellow-themed", + app_url="https://fellow.app", + categories=["Productivity & Collaboration", "meetings"], + actions=[ + ActionDefinition( + name="archive_action_item", + description="Archive an action item", + parameters={ + "action_item_id": ParameterDef( + type="string", + description="The ID of the action item to archive", + required=True, + ), + }, + ), + ActionDefinition( + name="complete_action_item", + description="Complete an action item", + parameters={ + "action_item_id": ParameterDef( + type="string", + description="The ID of the action item to mark as complete", + required=True, + ), + }, + ), + ActionDefinition( + name="get_note_by_id", + description="Get a note by its ID", + parameters={ + "note_id": ParameterDef( + type="string", + description="The ID of the note to retrieve", + required=True, + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your Fellow API key and workspace subdomain", + setup_instructions=[ + "Sign in to your Fellow workspace at https://.fellow.app", + "Navigate to Settings > Integrations > API", + "Generate a new API key or copy your existing one", + "Note your workspace subdomain (the part before .fellow.app in your URL)", + ], + setup_environment_variables=[ + EnvVar( + name="FELLOW_SUBDOMAIN", + display_name="Workspace Subdomain", + description="Your Fellow workspace subdomain (the part before .fellow.app)", + required=True, + sensitive=False, + sample_format="mycompany", + about_url="https://fellow.app", + ), + EnvVar( + name="FELLOW_API_KEY", + display_name="API Key", + description="Your Fellow API key from Settings > Integrations > API", + required=True, + sensitive=True, + sample_format="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://developers.fellow.ai", + ), + ], + test_endpoint=TestEndpoint( + url="https://{subdomain}.fellow.app/api/v1/note", + method="GET", + headers={"x-api-key": "{api_key}"}, + params={"limit": "1"}, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Validates credentials by listing notes with limit=1", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/fellow/outputs.py b/src/modulex_integrations/tools/fellow/outputs.py new file mode 100644 index 0000000..19885eb --- /dev/null +++ b/src/modulex_integrations/tools/fellow/outputs.py @@ -0,0 +1,36 @@ +"""Pydantic response models for the fellow integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +__all__ = [ + "ArchiveActionItemOutput", + "CompleteActionItemOutput", + "GetNoteByIdOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +class ArchiveActionItemOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class CompleteActionItemOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None + + +class GetNoteByIdOutput(_Base): + success: bool + error: str | None = None + data: dict[str, Any] | None = None diff --git a/src/modulex_integrations/tools/fellow/tests/__init__.py b/src/modulex_integrations/tools/fellow/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/fellow/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/fellow/tests/test_fellow.py b/src/modulex_integrations/tools/fellow/tests/test_fellow.py new file mode 100644 index 0000000..9499234 --- /dev/null +++ b/src/modulex_integrations/tools/fellow/tests/test_fellow.py @@ -0,0 +1,109 @@ +"""Happy-path tests for every fellow @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.fellow import ( + TOOLS, + archive_action_item, + complete_action_item, + get_note_by_id, + manifest, +) +from modulex_integrations.tools.fellow.outputs import ( + ArchiveActionItemOutput, + CompleteActionItemOutput, + GetNoteByIdOutput, +) + +_SUBDOMAIN = "testworkspace" +_API_KEY = "fake-api-key" + +API = f"https://{_SUBDOMAIN}.fellow.app/api/v1" + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(subdomain=_SUBDOMAIN, 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_archive_action_item(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/action_item/123/archive", + json={ + # TODO: fill in a representative response shape from the upstream API docs + }, + ) + + result_dict = await archive_action_item.ainvoke(_args(action_item_id="123")) + + assert isinstance(result_dict, dict) + result = ArchiveActionItemOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_complete_action_item(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/action_item/456/complete", + json={ + # TODO: fill in a representative response shape from the upstream API docs + }, + ) + + result_dict = await complete_action_item.ainvoke(_args(action_item_id="456")) + + assert isinstance(result_dict, dict) + result = CompleteActionItemOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_get_note_by_id(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/note/789", + json={ + # TODO: fill in a representative response shape from the upstream API docs + }, + ) + + result_dict = await get_note_by_id.ainvoke(_args(note_id="789")) + + assert isinstance(result_dict, dict) + result = GetNoteByIdOutput.model_validate(result_dict) + assert result.success is True + + +# --- Failure-path tests --------------------------------------------------- + + +@pytest.mark.asyncio +async def test_archive_action_item_validates_empty_api_key() -> None: + result_dict = await archive_action_item.ainvoke( + {"action_item_id": "123", "subdomain": "test", "api_key": ""} + ) + result = ArchiveActionItemOutput.model_validate(result_dict) + assert result.success is False + assert "API key" in (result.error or "") diff --git a/src/modulex_integrations/tools/fellow/tools.py b/src/modulex_integrations/tools/fellow/tools.py new file mode 100644 index 0000000..d06045d --- /dev/null +++ b/src/modulex_integrations/tools/fellow/tools.py @@ -0,0 +1,171 @@ +"""Fellow LangChain @tool functions.""" +from __future__ import annotations + +import httpx +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +from modulex_integrations import serialize_pydantic_return +from modulex_integrations.tools.fellow.outputs import ( + ArchiveActionItemOutput, + CompleteActionItemOutput, + GetNoteByIdOutput, +) + +__all__ = [ + "archive_action_item", + "complete_action_item", + "get_note_by_id", +] + +_TIMEOUT = 30.0 + + +def _base_url(subdomain: str) -> str: + return f"https://{subdomain}.fellow.app/api/v1" + + +def _headers(api_key: str) -> dict[str, str]: + return { + "x-api-key": api_key, + "Content-Type": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class ArchiveActionItemInput(BaseModel): + action_item_id: str = Field(description="The ID of the action item to archive") + subdomain: str = Field(description="Fellow workspace subdomain") + api_key: str = Field(description="Fellow API key") + + +class CompleteActionItemInput(BaseModel): + action_item_id: str = Field(description="The ID of the action item to mark as complete") + subdomain: str = Field(description="Fellow workspace subdomain") + api_key: str = Field(description="Fellow API key") + + +class GetNoteByIdInput(BaseModel): + note_id: str = Field(description="The ID of the note to retrieve") + subdomain: str = Field(description="Fellow workspace subdomain") + api_key: str = Field(description="Fellow API key") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=ArchiveActionItemInput) +@serialize_pydantic_return +async def archive_action_item( + action_item_id: str, + subdomain: str, + api_key: str, +) -> ArchiveActionItemOutput: + """Archive an action item.""" + if not api_key or not api_key.strip(): + return ArchiveActionItemOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not subdomain or not subdomain.strip(): + return ArchiveActionItemOutput( + success=False, + error="Subdomain is empty. Please configure your Fellow workspace subdomain.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_base_url(subdomain)}/action_item/{action_item_id}/archive", + headers=_headers(api_key), + ) + if response.status_code != 200: + return ArchiveActionItemOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ArchiveActionItemOutput(success=False, error="Request timed out.") + except Exception as exc: + return ArchiveActionItemOutput(success=False, error=f"Call failed: {exc}") + + return ArchiveActionItemOutput(success=True, data=data) + + +@tool(args_schema=CompleteActionItemInput) +@serialize_pydantic_return +async def complete_action_item( + action_item_id: str, + subdomain: str, + api_key: str, +) -> CompleteActionItemOutput: + """Complete an action item.""" + if not api_key or not api_key.strip(): + return CompleteActionItemOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not subdomain or not subdomain.strip(): + return CompleteActionItemOutput( + success=False, + error="Subdomain is empty. Please configure your Fellow workspace subdomain.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_base_url(subdomain)}/action_item/{action_item_id}/complete", + headers=_headers(api_key), + json={"completed": True}, + ) + if response.status_code != 200: + return CompleteActionItemOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CompleteActionItemOutput(success=False, error="Request timed out.") + except Exception as exc: + return CompleteActionItemOutput(success=False, error=f"Call failed: {exc}") + + return CompleteActionItemOutput(success=True, data=data) + + +@tool(args_schema=GetNoteByIdInput) +@serialize_pydantic_return +async def get_note_by_id( + note_id: str, + subdomain: str, + api_key: str, +) -> GetNoteByIdOutput: + """Get a note by its ID.""" + if not api_key or not api_key.strip(): + return GetNoteByIdOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + if not subdomain or not subdomain.strip(): + return GetNoteByIdOutput( + success=False, + error="Subdomain is empty. Please configure your Fellow workspace subdomain.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_base_url(subdomain)}/note/{note_id}", + headers=_headers(api_key), + ) + if response.status_code != 200: + return GetNoteByIdOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return GetNoteByIdOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetNoteByIdOutput(success=False, error=f"Call failed: {exc}") + + return GetNoteByIdOutput(success=True, data=data) From eab16f5dcb4a59db7b5de410ba7a0036833cb61f Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 14:09:42 +0000 Subject: [PATCH 10/15] auto-integrate: cogmento Add **cogmento** integration -- a CRM platform for managing contacts, deals, and tasks via the Cogmento REST API. **Actions (4):** `create_contact`, `create_deal`, `create_task`, `list_user_ids_options` **Auth:** OAuth2 (token-based, `Token {access_token}` header pattern) **Patches applied during merge (3):** 1. `manifest.py` -- added `logo="modulex:cogmento-themed"` (auditor check 8.9, mechanical). 2. `tools.py` -- inserted credential-validity short-circuit (`access_token` emptiness guard) at the top of each of the 4 tool function bodies (auditor check 8.4, mechanical). 3. `tests/test_cogmento.py` -- appended `test_create_contact_empty_credential` failure-path test asserting `success=False` when `access_token` is empty (auditor check 6.5, mechanical). **No risky-semantic patches skipped.** All 3 patches were mechanical. **Test suite:** 8 tests total (3 manifest sanity, 4 happy-path with httpx_mock, 1 failure-path credential guard). All passing. **Dependencies:** None (pure httpx, already in root deps). Provider: primary Run: 26685698245 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 4 + .../tools/cogmento/README.md | 32 ++ .../tools/cogmento/__init__.py | 24 ++ .../tools/cogmento/dependencies.toml | 3 + .../tools/cogmento/manifest.py | 185 +++++++++++ .../tools/cogmento/outputs.py | 57 ++++ .../tools/cogmento/tests/__init__.py | 1 + .../tools/cogmento/tests/test_cogmento.py | 149 +++++++++ .../tools/cogmento/tools.py | 301 ++++++++++++++++++ 10 files changed, 762 insertions(+) create mode 100644 src/modulex_integrations/tools/cogmento/README.md create mode 100644 src/modulex_integrations/tools/cogmento/__init__.py create mode 100644 src/modulex_integrations/tools/cogmento/dependencies.toml create mode 100644 src/modulex_integrations/tools/cogmento/manifest.py create mode 100644 src/modulex_integrations/tools/cogmento/outputs.py create mode 100644 src/modulex_integrations/tools/cogmento/tests/__init__.py create mode 100644 src/modulex_integrations/tools/cogmento/tests/test_cogmento.py create mode 100644 src/modulex_integrations/tools/cogmento/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index af6cf6a..58d51aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `cogmento` integration — 4 actions, auth: oauth2. CRM platform for + managing contacts, deals, and tasks via the Cogmento API + (create_contact, create_deal, create_task, list_user_ids_options). + Producer-staged by integration-drafts; consumer-side audit applied + 3 patches before merge. + - `fellow` integration — 3 actions, auth: api_key. Meeting productivity platform for notes, action items, and meeting management via the Fellow API (archive_action_item, complete_action_item, get_note_by_id). Producer-staged diff --git a/pyproject.toml b/pyproject.toml index db2c142..3a44dec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,6 +126,7 @@ intercom = "modulex_integrations.tools.intercom" scrape_do = "modulex_integrations.tools.scrape_do" apollo_io = "modulex_integrations.tools.apollo_io" cloudflare = "modulex_integrations.tools.cloudflare" +cogmento = "modulex_integrations.tools.cogmento" segment = "modulex_integrations.tools.segment" semrush = "modulex_integrations.tools.semrush" gmail = "modulex_integrations.tools.gmail" @@ -626,6 +627,9 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/canvas/manifest.py" = ["E501"] "src/modulex_integrations/tools/canvas/tools.py" = ["E501"] +# cogmento tools have long description string literals in Field kwargs +# and credential guard lines that cannot be wrapped. +"src/modulex_integrations/tools/cogmento/tools.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/cogmento/README.md b/src/modulex_integrations/tools/cogmento/README.md new file mode 100644 index 0000000..e4f3677 --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/README.md @@ -0,0 +1,32 @@ +# Cogmento + +CRM platform for managing contacts, deals, and tasks via the Cogmento REST API (`api.cogmento.com/api/1`). + +## Authentication + +### OAuth2 Authentication + +- Connect via Cogmento's OAuth 2.0 flow (recommended). +- Register an OAuth app at Cogmento's developer portal; redirect URI must be `https://api.modulex.dev/credentials/oauth2/callback`. +- Required env vars (custom OAuth app only): `COGMENTO_OAUTH2_CLIENT_ID`, `COGMENTO_OAUTH2_CLIENT_SECRET`. +- Note: Cogmento uses a `Token` prefix (not `Bearer`) for the Authorization header. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_contact` | Create a new contact in Cogmento CRM | `first_name`, `last_name` | +| `create_deal` | Create a new deal in Cogmento CRM | `title` | +| `create_task` | Create a new task in Cogmento CRM | `title` | +| `list_user_ids_options` | Retrieve available user options for assignment fields | (none) | + +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 Cogmento's public API documentation. +- 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/cogmento/__init__.py b/src/modulex_integrations/tools/cogmento/__init__.py new file mode 100644 index 0000000..4784eff --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/__init__.py @@ -0,0 +1,24 @@ +"""Cogmento integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.cogmento.manifest import manifest +from modulex_integrations.tools.cogmento.tools import ( + create_contact, + create_deal, + create_task, + list_user_ids_options, +) + +TOOLS = ( + create_contact, + create_deal, + create_task, + list_user_ids_options, +) + +__all__ = [ + "TOOLS", + "create_contact", + "create_deal", + "create_task", + "list_user_ids_options", + "manifest", +] diff --git a/src/modulex_integrations/tools/cogmento/dependencies.toml b/src/modulex_integrations/tools/cogmento/dependencies.toml new file mode 100644 index 0000000..a0999bc --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the cogmento integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/cogmento/manifest.py b/src/modulex_integrations/tools/cogmento/manifest.py new file mode 100644 index 0000000..1ab42b9 --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/manifest.py @@ -0,0 +1,185 @@ +"""Cogmento integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + EnvVar, + IntegrationManifest, + OAuth2AuthSchema, + OAuthConfig, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="cogmento", + display_name="Cogmento", + description="CRM platform for managing contacts, deals, and tasks", + version="1.0.0", + author="ModuleX", + logo="modulex:cogmento-themed", + app_url="https://www.cogmento.com", + categories=["CRM", "Sales", "Productivity"], + actions=[ + ActionDefinition( + name="create_contact", + description="Create a new contact in Cogmento CRM", + parameters={ + "first_name": ParameterDef( + type="string", + description="First name of the contact", + required=True, + ), + "last_name": ParameterDef( + type="string", + description="Last name of the contact", + required=True, + ), + "email": ParameterDef( + type="string", + description="Email address of the contact", + ), + "phone": ParameterDef( + type="string", + description="Phone number of the contact", + ), + "description": ParameterDef( + type="string", + description="Description of the contact", + ), + "tags": ParameterDef( + type="array", + description="List of tags (strings) associated with the contact", + ), + "do_not_call": ParameterDef( + type="boolean", + description="Set to true to mark the contact as Do Not Call", + ), + "do_not_text": ParameterDef( + type="boolean", + description="Set to true to mark the contact as Do Not Text", + ), + "do_not_email": ParameterDef( + type="boolean", + description="Set to true to mark the contact as Do Not Email", + ), + }, + ), + ActionDefinition( + name="create_deal", + description="Create a new deal in Cogmento CRM", + parameters={ + "title": ParameterDef( + type="string", + description="The title of the deal", + required=True, + ), + "description": ParameterDef( + type="string", + description="A description of the deal", + ), + "assignee_ids": ParameterDef( + type="array", + description="List of user IDs (strings) to assign to the deal", + ), + "tags": ParameterDef( + type="array", + description="List of tags (strings) associated with the deal", + ), + "close_date": ParameterDef( + type="string", + description="The date the deal was completed (format: YYYY-MM-DD)", + ), + "product_ids": ParameterDef( + type="array", + description="List of product IDs (strings) to include in the deal", + ), + "amount": ParameterDef( + type="string", + description="The final deal value (numeric string)", + ), + }, + ), + ActionDefinition( + name="create_task", + description="Create a new task in Cogmento CRM", + parameters={ + "title": ParameterDef( + type="string", + description="The title of the task", + required=True, + ), + "description": ParameterDef( + type="string", + description="A description of the task", + ), + "due_date": ParameterDef( + type="string", + description="The task's deadline (format: YYYY-MM-DD)", + ), + "assignee_ids": ParameterDef( + type="array", + description="List of user IDs (strings) to assign to the task", + ), + "deal_id": ParameterDef( + type="string", + description="Identifier of a deal to associate with the task", + ), + "contact_id": ParameterDef( + type="string", + description="Identifier of a contact to associate with the task", + ), + }, + ), + ActionDefinition( + name="list_user_ids_options", + description="Retrieve available user options for assignment fields", + parameters={}, + ), + ], + auth_schemas=[ + OAuth2AuthSchema( + display_name="OAuth2 Authentication", + description="Connect using Cogmento OAuth (recommended)", + setup_environment_variables=[ + EnvVar( + name="COGMENTO_OAUTH2_CLIENT_ID", + display_name="Client ID", + description="Cogmento OAuth App Client ID", + required=True, + sensitive=False, + only_for_custom=True, + ), + EnvVar( + name="COGMENTO_OAUTH2_CLIENT_SECRET", + display_name="Client Secret", + description="Cogmento OAuth App Client Secret", + required=True, + sensitive=True, + only_for_custom=True, + ), + ], + oauth_config=OAuthConfig( + auth_url="https://www.cogmento.com/oauth/authorize", + token_url="https://www.cogmento.com/oauth/token", + scopes=[], + ), + test_endpoint=TestEndpoint( + url="https://api.cogmento.com/api/1/auth/user", + method="GET", + headers={ + "Authorization": "Token {access_token}", + }, + success_indicators=SuccessIndicators( + status_codes=[200], + ), + cost_level="free", + description="Validates OAuth token by fetching authenticated user info", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/cogmento/outputs.py b/src/modulex_integrations/tools/cogmento/outputs.py new file mode 100644 index 0000000..9ef5361 --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/outputs.py @@ -0,0 +1,57 @@ +"""Pydantic response models for the cogmento integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CreateContactOutput", + "CreateDealOutput", + "CreateTaskOutput", + "ListUserIdsOptionsOutput", + "UserOption", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class UserOption(_Base): + """A user option with label and value.""" + + label: str | None = None + value: str | None = None + + +# --- Per-action output models --------------------------------------------- + + +class CreateContactOutput(_Base): + success: bool + error: str | None = None + contact: dict[str, Any] | None = None + + +class CreateDealOutput(_Base): + success: bool + error: str | None = None + deal: dict[str, Any] | None = None + + +class CreateTaskOutput(_Base): + success: bool + error: str | None = None + task: dict[str, Any] | None = None + + +class ListUserIdsOptionsOutput(_Base): + success: bool + error: str | None = None + users: list[UserOption] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/cogmento/tests/__init__.py b/src/modulex_integrations/tools/cogmento/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py b/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py new file mode 100644 index 0000000..fe93f23 --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/tests/test_cogmento.py @@ -0,0 +1,149 @@ +"""Happy-path tests for every cogmento @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.cogmento import ( + TOOLS, + create_contact, + create_deal, + create_task, + list_user_ids_options, + manifest, +) +from modulex_integrations.tools.cogmento.outputs import ( + CreateContactOutput, + CreateDealOutput, + CreateTaskOutput, + ListUserIdsOptionsOutput, +) + +API = "https://api.cogmento.com/api/1" + +_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_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 Cogmento API docs + "id": "abc123", + "first_name": "John", + "last_name": "Doe", + }, + ) + + result_dict = await create_contact.ainvoke( + _args(first_name="John", last_name="Doe") + ) + + assert isinstance(result_dict, dict) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is True + assert result.contact is not None + + +@pytest.mark.asyncio +async def test_create_deal(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/deals/", + json={ + # TODO: fill in a representative response shape from Cogmento API docs + "id": "deal456", + "title": "New Deal", + }, + ) + + result_dict = await create_deal.ainvoke(_args(title="New Deal")) + + assert isinstance(result_dict, dict) + result = CreateDealOutput.model_validate(result_dict) + assert result.success is True + assert result.deal is not None + + +@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 Cogmento API docs + "id": "task789", + "title": "Follow up", + }, + ) + + result_dict = await create_task.ainvoke(_args(title="Follow up")) + + assert isinstance(result_dict, dict) + result = CreateTaskOutput.model_validate(result_dict) + assert result.success is True + assert result.task is not None + + +@pytest.mark.asyncio +async def test_list_user_ids_options(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/auth/user", + json=[ + # TODO: fill in a representative response shape from Cogmento API docs + {"id": "u1", "name": "Alice", "email": "alice@example.com"}, + {"id": "u2", "name": "Bob", "email": "bob@example.com"}, + ], + ) + + result_dict = await list_user_ids_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListUserIdsOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.users) == 2 + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_contact_empty_credential(): # type: ignore[no-untyped-def] + """Empty access_token should return success=False without hitting the network.""" + result_dict = await create_contact.ainvoke( + _args(auth_data={"access_token": ""}, first_name="Jane", last_name="Doe") + ) + + assert isinstance(result_dict, dict) + result = CreateContactOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None diff --git a/src/modulex_integrations/tools/cogmento/tools.py b/src/modulex_integrations/tools/cogmento/tools.py new file mode 100644 index 0000000..9a935ab --- /dev/null +++ b/src/modulex_integrations/tools/cogmento/tools.py @@ -0,0 +1,301 @@ +"""Cogmento 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.cogmento.outputs import ( + CreateContactOutput, + CreateDealOutput, + CreateTaskOutput, + ListUserIdsOptionsOutput, + UserOption, +) + +__all__ = [ + "create_contact", + "create_deal", + "create_task", + "list_user_ids_options", +] + +_BASE_URL = "https://api.cogmento.com/api/1" + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Cogmento 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"Token {access_token}" + return headers + + +# --- Input schemas -------------------------------------------------------- + + +class CreateContactInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + first_name: str = Field(description="First name of the contact") + last_name: str = Field(description="Last name of the contact") + email: str | None = Field(default=None, description="Email address of the contact") + phone: str | None = Field(default=None, description="Phone number of the contact") + description: str | None = Field(default=None, description="Description of the contact") + tags: list[str] | None = Field(default=None, description="List of tags associated with the contact") + do_not_call: bool | None = Field(default=None, description="Set to true to mark as Do Not Call") + do_not_text: bool | None = Field(default=None, description="Set to true to mark as Do Not Text") + do_not_email: bool | None = Field(default=None, description="Set to true to mark as Do Not Email") + + +class CreateDealInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + title: str = Field(description="The title of the deal") + description: str | None = Field(default=None, description="A description of the deal") + assignee_ids: list[str] | None = Field(default=None, description="List of user IDs to assign to the deal") + tags: list[str] | None = Field(default=None, description="List of tags associated with the deal") + close_date: str | None = Field(default=None, description="The date the deal was completed (YYYY-MM-DD)") + product_ids: list[str] | None = Field(default=None, description="List of product IDs to include in the deal") + amount: str | None = Field(default=None, description="The final deal value (numeric string)") + + +class CreateTaskInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + title: str = Field(description="The title of the task") + description: str | None = Field(default=None, description="A description of the task") + due_date: str | None = Field(default=None, description="The task's deadline (YYYY-MM-DD)") + assignee_ids: list[str] | None = Field(default=None, description="List of user IDs to assign to the task") + deal_id: str | None = Field(default=None, description="Identifier of a deal to associate with the task") + contact_id: str | None = Field(default=None, description="Identifier of a contact to associate with the task") + + +class ListUserIdsOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateContactInput) +@serialize_pydantic_return +async def create_contact( + auth_type: str, + auth_data: dict[str, Any], + first_name: str, + last_name: str, + email: str | None = None, + phone: str | None = None, + description: str | None = None, + tags: list[str] | None = None, + do_not_call: bool | None = None, + do_not_text: bool | None = None, + do_not_email: bool | None = None, +) -> CreateContactOutput: + """Create a new contact in Cogmento CRM""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateContactOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + payload: dict[str, Any] = { + "first_name": first_name, + "last_name": last_name, + } + + channels: list[dict[str, str]] = [] + if email: + channels.append({"channel_type": "email", "value": email}) + if phone: + channels.append({"channel_type": "phone", "value": phone}) + if channels: + payload["channels"] = channels + + if description is not None: + payload["description"] = description + if tags is not None: + payload["tags"] = tags + if do_not_call is not None: + payload["do_not_call"] = do_not_call + if do_not_text is not None: + payload["do_not_text"] = do_not_text + if do_not_email is not None: + payload["do_not_email"] = do_not_email + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/contacts/", + headers=headers, + json=payload, + ) + 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=data) + + +@tool(args_schema=CreateDealInput) +@serialize_pydantic_return +async def create_deal( + auth_type: str, + auth_data: dict[str, Any], + title: str, + description: str | None = None, + assignee_ids: list[str] | None = None, + tags: list[str] | None = None, + close_date: str | None = None, + product_ids: list[str] | None = None, + amount: str | None = None, +) -> CreateDealOutput: + """Create a new deal in Cogmento CRM""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateDealOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + payload: dict[str, Any] = {"title": title} + + if description is not None: + payload["description"] = description + if assignee_ids is not None: + payload["assigned_to"] = [{"id": uid} for uid in assignee_ids] + if tags is not None: + payload["tags"] = tags + if close_date is not None: + payload["close_date"] = close_date + if product_ids is not None: + payload["products"] = [{"id": pid} for pid in product_ids] + if amount is not None: + payload["amount"] = float(amount) + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/deals/", + headers=headers, + json=payload, + ) + if response.status_code not in (200, 201): + return CreateDealOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return CreateDealOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateDealOutput(success=False, error=f"Call failed: {exc}") + + return CreateDealOutput(success=True, deal=data) + + +@tool(args_schema=CreateTaskInput) +@serialize_pydantic_return +async def create_task( + auth_type: str, + auth_data: dict[str, Any], + title: str, + description: str | None = None, + due_date: str | None = None, + assignee_ids: list[str] | None = None, + deal_id: str | None = None, + contact_id: str | None = None, +) -> CreateTaskOutput: + """Create a new task in Cogmento CRM""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return CreateTaskOutput(success=False, error="Missing or empty access_token in auth_data.") + headers = _get_auth_headers(auth_type, auth_data) + headers["Content-Type"] = "application/json" + + payload: dict[str, Any] = {"title": title} + + if description is not None: + payload["description"] = description + if due_date is not None: + payload["due_date"] = due_date + if assignee_ids is not None: + payload["assigned_to"] = [{"id": uid} for uid in assignee_ids] + if deal_id is not None: + payload["deal"] = {"id": deal_id} + if contact_id is not None: + payload["contact"] = {"id": contact_id} + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{_BASE_URL}/tasks/", + headers=headers, + json=payload, + ) + 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=data) + + +@tool(args_schema=ListUserIdsOptionsInput) +@serialize_pydantic_return +async def list_user_ids_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListUserIdsOptionsOutput: + """Retrieve available user options for assignment fields""" + access_token = auth_data.get("access_token") + if not access_token or not access_token.strip(): + return ListUserIdsOptionsOutput(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=30.0) as client: + response = await client.get( + f"{_BASE_URL}/auth/user", + headers=headers, + ) + if response.status_code != 200: + return ListUserIdsOptionsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return ListUserIdsOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListUserIdsOptionsOutput(success=False, error=f"Call failed: {exc}") + + users_list = data if isinstance(data, list) else [data] + users = [ + UserOption( + label=u.get("name") or u.get("email", ""), + value=str(u.get("id", "")), + ) + for u in users_list + ] + + return ListUserIdsOptionsOutput(success=True, users=users) From 50079bcd1837c91a367cf56135afa44ddba2a38b Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 15:26:49 +0000 Subject: [PATCH 11/15] auto-integrate: coinmarketcap Add `coinmarketcap` integration (4 actions, auth: api_key) Adds the CoinMarketCap integration providing cryptocurrency market data, quotes, and metadata via the CoinMarketCap Pro API. Actions: get_cryptocurrency_metadata, id_map, latest_listings, latest_quotes. Patches applied during consumer-side merge (2 total): 1. PATCH #1 (manifest.py, check 8.9) -- Logo convention enforcement. Changed logo="cryptocurrency:cmc" to logo="modulex:coinmarketcap-themed". 2. PATCH #2 (tests/test_coinmarketcap.py, check 6.5) -- Missing failure-path test. Appended test_empty_credential_returns_error to validate Pattern B (empty API key returns success=False without hitting the wire). Gate results: ruff PASS, mypy --strict PASS, pytest 8/8 PASS. Auth type: API Key (X-CMC_PRO_API_KEY header). Provider: primary Run: 26687399608 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 6 + pyproject.toml | 5 + .../tools/coinmarketcap/README.md | 34 ++ .../tools/coinmarketcap/__init__.py | 24 ++ .../tools/coinmarketcap/dependencies.toml | 3 + .../tools/coinmarketcap/manifest.py | 182 +++++++++ .../tools/coinmarketcap/outputs.py | 114 ++++++ .../tools/coinmarketcap/tests/__init__.py | 1 + .../coinmarketcap/tests/test_coinmarketcap.py | 205 ++++++++++ .../tools/coinmarketcap/tools.py | 355 ++++++++++++++++++ 10 files changed, 929 insertions(+) create mode 100644 src/modulex_integrations/tools/coinmarketcap/README.md create mode 100644 src/modulex_integrations/tools/coinmarketcap/__init__.py create mode 100644 src/modulex_integrations/tools/coinmarketcap/dependencies.toml create mode 100644 src/modulex_integrations/tools/coinmarketcap/manifest.py create mode 100644 src/modulex_integrations/tools/coinmarketcap/outputs.py create mode 100644 src/modulex_integrations/tools/coinmarketcap/tests/__init__.py create mode 100644 src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py create mode 100644 src/modulex_integrations/tools/coinmarketcap/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d51aa..d55f93e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `coinmarketcap` integration — 4 actions, auth: api_key. Cryptocurrency + market data, quotes, and metadata from the CoinMarketCap API + (get_cryptocurrency_metadata, id_map, latest_listings, latest_quotes). + Producer-staged by integration-drafts; consumer-side audit applied + 2 patches before merge. + - `cogmento` integration — 4 actions, auth: oauth2. CRM platform for managing contacts, deals, and tasks via the Cogmento API (create_contact, create_deal, create_task, list_user_ids_options). diff --git a/pyproject.toml b/pyproject.toml index 3a44dec..171766f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ google_docs = "modulex_integrations.tools.google_docs" sendgrid = "modulex_integrations.tools.sendgrid" sentry = "modulex_integrations.tools.sentry" coinbase = "modulex_integrations.tools.coinbase" +coinmarketcap = "modulex_integrations.tools.coinmarketcap" databricks = "modulex_integrations.tools.databricks" datadog = "modulex_integrations.tools.datadog" postgresql = "modulex_integrations.tools.postgresql" @@ -630,6 +631,10 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # cogmento tools have long description string literals in Field kwargs # and credential guard lines that cannot be wrapped. "src/modulex_integrations/tools/cogmento/tools.py" = ["E501"] +# coinmarketcap manifest and tools have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/coinmarketcap/manifest.py" = ["E501"] +"src/modulex_integrations/tools/coinmarketcap/tools.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/coinmarketcap/README.md b/src/modulex_integrations/tools/coinmarketcap/README.md new file mode 100644 index 0000000..2853e5a --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/README.md @@ -0,0 +1,34 @@ +# CoinMarketCap + +Cryptocurrency market data, quotes, and metadata from the CoinMarketCap REST API (`pro-api.coinmarketcap.com`). + +## Authentication + +### API Key Authentication + +- Sign up at to get your API key. +- Required env var: `COINMARKETCAP_API_KEY` (format: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`). +- The API key is passed via the `X-CMC_PRO_API_KEY` header on every request. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `get_cryptocurrency_metadata` | Returns all static metadata available for one or more cryptocurrencies | `ids` | +| `id_map` | Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs | _(none)_ | +| `latest_listings` | Returns a paginated list of all active cryptocurrencies with latest market data | _(none)_ | +| `latest_quotes` | Returns the latest market quote for one or more cryptocurrencies | _(none — but at least one of id, slug, or symbol must be provided)_ | + +Every tool takes an additional `api_key` parameter that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- **Basic (free) plan**: 333 calls/day, 10,000 calls/month. +- **Hobbyist plan**: 10,000 calls/month. +- **Standard plan and above**: higher limits per the pricing page. +- Rate limiting is applied per API key. Exceeding limits returns HTTP 429. +- **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/coinmarketcap/__init__.py b/src/modulex_integrations/tools/coinmarketcap/__init__.py new file mode 100644 index 0000000..50700b3 --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/__init__.py @@ -0,0 +1,24 @@ +"""CoinMarketCap integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.coinmarketcap.manifest import manifest +from modulex_integrations.tools.coinmarketcap.tools import ( + get_cryptocurrency_metadata, + id_map, + latest_listings, + latest_quotes, +) + +TOOLS = ( + get_cryptocurrency_metadata, + id_map, + latest_listings, + latest_quotes, +) + +__all__ = [ + "TOOLS", + "get_cryptocurrency_metadata", + "id_map", + "latest_listings", + "latest_quotes", + "manifest", +] diff --git a/src/modulex_integrations/tools/coinmarketcap/dependencies.toml b/src/modulex_integrations/tools/coinmarketcap/dependencies.toml new file mode 100644 index 0000000..a2b951f --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the coinmarketcap integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/coinmarketcap/manifest.py b/src/modulex_integrations/tools/coinmarketcap/manifest.py new file mode 100644 index 0000000..7492524 --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/manifest.py @@ -0,0 +1,182 @@ +"""CoinMarketCap integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + ApiKeyAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="coinmarketcap", + display_name="CoinMarketCap", + description="Cryptocurrency market data, quotes, and metadata from the CoinMarketCap API", + version="1.0.0", + author="ModuleX", + logo="modulex:coinmarketcap-themed", + app_url="https://coinmarketcap.com", + categories=["Finance", "Cryptocurrency", "Market Data"], + actions=[ + ActionDefinition( + name="get_cryptocurrency_metadata", + description="Returns all static metadata available for one or more cryptocurrencies including name, symbol, logo, description, and URLs", + parameters={ + "ids": ParameterDef( + type="string", + description="One or more comma-separated CoinMarketCap cryptocurrency IDs. Example: 1,2,1027", + required=True, + ), + "skip_invalid": ParameterDef( + type="boolean", + description="When true, invalid lookups will be skipped allowing valid cryptocurrencies to still be returned", + default=False, + ), + "aux": ParameterDef( + type="string", + description="Comma-separated supplemental data fields to return. Valid values: urls, logo, description, tags, platform, date_added, notice, status", + ), + }, + ), + ActionDefinition( + name="id_map", + description="Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs", + parameters={ + "listing_status": ParameterDef( + type="string", + description="Filter by status. Valid values: active, inactive, untracked. Comma-separated for multiple.", + ), + "start": ParameterDef( + type="integer", + description="Offset the start (1-based index) of the paginated list of items to return", + ), + "limit": ParameterDef( + type="integer", + description="Number of results to return. Default 100", + default=100, + ), + "sort": ParameterDef( + type="string", + description="Sort field. Valid values: cmc_rank, id", + ), + "symbol": ParameterDef( + type="string", + description="Comma-separated list of cryptocurrency symbols to return IDs for. If passed, other options are ignored.", + ), + "aux": ParameterDef( + type="string", + description="Comma-separated supplemental data fields. Valid values: platform, first_historical_data, last_historical_data, is_active, status", + ), + }, + ), + ActionDefinition( + name="latest_listings", + description="Returns a paginated list of all active cryptocurrencies with latest market data", + parameters={ + "start": ParameterDef( + type="integer", + description="Offset the start (1-based index) of the paginated list of items to return", + ), + "limit": ParameterDef( + type="integer", + description="Number of results to return", + ), + "volume_24h_min": ParameterDef( + type="number", + description="Minimum 24 hour USD volume to filter results by", + ), + "convert": ParameterDef( + type="string", + description="Comma-separated list of cryptocurrency or fiat currency symbols to calculate market quotes in", + ), + "convert_id": ParameterDef( + type="string", + description="Comma-separated CoinMarketCap IDs to calculate market quotes in. Cannot be used with convert.", + ), + "sort": ParameterDef( + type="string", + description="Sort field. Valid values: market_cap, name, symbol, date_added, price, circulating_supply, total_supply, max_supply, num_market_pairs, volume_24h, percent_change_1h, percent_change_24h, percent_change_7d", + ), + "sort_dir": ParameterDef( + type="string", + description="Sort direction. Valid values: asc, desc", + ), + "cryptocurrency_type": ParameterDef( + type="string", + description="Type of cryptocurrency to include. Valid values: all, coins, tokens", + ), + "aux": ParameterDef( + type="string", + description="Comma-separated supplemental data fields. Valid values: num_market_pairs, cmc_rank, date_added, tags, platform, max_supply, circulating_supply, total_supply", + ), + }, + ), + ActionDefinition( + name="latest_quotes", + description="Returns the latest market quote for one or more cryptocurrencies. At least one of id, slug, or symbol is required.", + parameters={ + "id": ParameterDef( + type="string", + description="One or more comma-separated CoinMarketCap cryptocurrency IDs. Example: 1,2", + ), + "slug": ParameterDef( + type="string", + description="Comma-separated list of cryptocurrency slugs. Example: bitcoin,ethereum", + ), + "symbol": ParameterDef( + type="string", + description="Comma-separated cryptocurrency symbols. Example: BTC,ETH", + ), + "convert": ParameterDef( + type="string", + description="Comma-separated list of currency symbols to calculate quotes in", + ), + "convert_id": ParameterDef( + type="string", + description="Comma-separated CoinMarketCap IDs to calculate quotes in. Cannot be used with convert.", + ), + }, + ), + ], + auth_schemas=[ + ApiKeyAuthSchema( + display_name="API Key Authentication", + description="Authenticate using your CoinMarketCap API key", + setup_instructions=[ + "Go to https://coinmarketcap.com/api/ and sign up for an account", + "Navigate to your API dashboard", + "Copy your API key", + "Paste the API key below", + ], + setup_environment_variables=[ + EnvVar( + name="COINMARKETCAP_API_KEY", + display_name="CoinMarketCap API Key", + description="Your CoinMarketCap API key from the developer dashboard", + required=True, + sensitive=True, + sample_format="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + about_url="https://coinmarketcap.com/api/", + ), + ], + test_endpoint=TestEndpoint( + url="https://pro-api.coinmarketcap.com/v1/cryptocurrency/map", + method="GET", + headers={"X-CMC_PRO_API_KEY": "{api_key}"}, + params={"limit": "1"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["data"], + ), + cost_level="free", + description="Validates the API key by fetching one cryptocurrency mapping entry", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/coinmarketcap/outputs.py b/src/modulex_integrations/tools/coinmarketcap/outputs.py new file mode 100644 index 0000000..d9dc248 --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/outputs.py @@ -0,0 +1,114 @@ +"""Pydantic response models for the coinmarketcap integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "CryptocurrencyMapItem", + "CryptocurrencyMetadata", + "CryptocurrencyQuote", + "GetCryptocurrencyMetadataOutput", + "IdMapOutput", + "LatestListingsOutput", + "LatestQuotesOutput", + "ListingItem", + "QuoteData", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class CryptocurrencyMetadata(_Base): + """Metadata for a single cryptocurrency.""" + + id: int | None = None + name: str | None = None + symbol: str | None = None + slug: str | None = None + description: str | None = None + logo: str | None = None + date_added: str | None = None + category: str | None = None + + +class CryptocurrencyMapItem(_Base): + """A cryptocurrency mapping entry.""" + + id: int | None = None + name: str | None = None + symbol: str | None = None + slug: str | None = None + is_active: int | None = None + first_historical_data: str | None = None + last_historical_data: str | None = None + + +class QuoteData(_Base): + """Quote data for a single currency conversion.""" + + price: float | None = None + volume_24h: float | None = None + market_cap: float | None = None + percent_change_1h: float | None = None + percent_change_24h: float | None = None + percent_change_7d: float | None = None + last_updated: str | None = None + + +class ListingItem(_Base): + """A cryptocurrency listing entry with market data.""" + + id: int | None = None + name: str | None = None + symbol: str | None = None + slug: str | None = None + cmc_rank: int | None = None + circulating_supply: float | None = None + total_supply: float | None = None + max_supply: float | None = None + quote: dict[str, QuoteData] = Field(default_factory=dict) + + +class CryptocurrencyQuote(_Base): + """Quote data for a specific cryptocurrency.""" + + id: int | None = None + name: str | None = None + symbol: str | None = None + slug: str | None = None + cmc_rank: int | None = None + quote: dict[str, QuoteData] = Field(default_factory=dict) + + +# --- Per-action output models ---------------------------------------------- + + +class GetCryptocurrencyMetadataOutput(_Base): + success: bool + error: str | None = None + data: dict[str, CryptocurrencyMetadata] = Field(default_factory=dict) + + +class IdMapOutput(_Base): + success: bool + error: str | None = None + data: list[CryptocurrencyMapItem] = Field(default_factory=list) + + +class LatestListingsOutput(_Base): + success: bool + error: str | None = None + data: list[ListingItem] = Field(default_factory=list) + + +class LatestQuotesOutput(_Base): + success: bool + error: str | None = None + data: dict[str, CryptocurrencyQuote] = Field(default_factory=dict) diff --git a/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py b/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py b/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py new file mode 100644 index 0000000..054ed5a --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/tests/test_coinmarketcap.py @@ -0,0 +1,205 @@ +"""Happy-path tests for every coinmarketcap @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.coinmarketcap import ( + TOOLS, + get_cryptocurrency_metadata, + id_map, + latest_listings, + latest_quotes, + manifest, +) +from modulex_integrations.tools.coinmarketcap.outputs import ( + GetCryptocurrencyMetadataOutput, + IdMapOutput, + LatestListingsOutput, + LatestQuotesOutput, +) + +API = "https://pro-api.coinmarketcap.com" + +_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_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_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_cryptocurrency_metadata(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v2/cryptocurrency/info?id=1&skip_invalid=false", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": { + "1": { + "id": 1, + "name": "Bitcoin", + "symbol": "BTC", + "slug": "bitcoin", + "description": "Bitcoin is a cryptocurrency.", + "logo": "https://s2.coinmarketcap.com/static/img/coins/64x64/1.png", + "date_added": "2013-04-28T00:00:00.000Z", + "category": "coin", + } + } + }, + ) + + result_dict = await get_cryptocurrency_metadata.ainvoke(_args(ids="1")) + + assert isinstance(result_dict, dict) + result = GetCryptocurrencyMetadataOutput.model_validate(result_dict) + assert result.success is True + assert "1" in result.data + assert result.data["1"].name == "Bitcoin" + + +@pytest.mark.asyncio +async def test_id_map(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/cryptocurrency/map?limit=100", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": [ + { + "id": 1, + "name": "Bitcoin", + "symbol": "BTC", + "slug": "bitcoin", + "is_active": 1, + "first_historical_data": "2013-04-28T18:47:21.000Z", + "last_historical_data": "2024-01-01T00:00:00.000Z", + } + ] + }, + ) + + result_dict = await id_map.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = IdMapOutput.model_validate(result_dict) + assert result.success is True + assert len(result.data) == 1 + assert result.data[0].symbol == "BTC" + + +@pytest.mark.asyncio +async def test_latest_listings(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/cryptocurrency/listings/latest", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": [ + { + "id": 1, + "name": "Bitcoin", + "symbol": "BTC", + "slug": "bitcoin", + "cmc_rank": 1, + "circulating_supply": 19500000.0, + "total_supply": 19500000.0, + "max_supply": 21000000.0, + "quote": { + "USD": { + "price": 50000.0, + "volume_24h": 30000000000.0, + "market_cap": 975000000000.0, + "percent_change_1h": 0.5, + "percent_change_24h": 2.1, + "percent_change_7d": -1.3, + "last_updated": "2024-01-01T00:00:00.000Z", + } + }, + } + ] + }, + ) + + result_dict = await latest_listings.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = LatestListingsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.data) == 1 + assert result.data[0].symbol == "BTC" + assert result.data[0].quote["USD"].price == 50000.0 + + +@pytest.mark.asyncio +async def test_latest_quotes(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/v1/cryptocurrency/quotes/latest?symbol=BTC", + json={ + # TODO: fill in a representative response shape from the upstream API docs + "data": { + "BTC": { + "id": 1, + "name": "Bitcoin", + "symbol": "BTC", + "slug": "bitcoin", + "cmc_rank": 1, + "quote": { + "USD": { + "price": 50000.0, + "volume_24h": 30000000000.0, + "market_cap": 975000000000.0, + "percent_change_1h": 0.5, + "percent_change_24h": 2.1, + "percent_change_7d": -1.3, + "last_updated": "2024-01-01T00:00:00.000Z", + } + }, + } + } + }, + ) + + result_dict = await latest_quotes.ainvoke(_args(symbol="BTC")) + + assert isinstance(result_dict, dict) + result = LatestQuotesOutput.model_validate(result_dict) + assert result.success is True + assert "BTC" in result.data + assert result.data["BTC"].quote["USD"].price == 50000.0 + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_empty_credential_returns_error(): # type: ignore[no-untyped-def] + """Pattern B: empty API key must return success=False without hitting the wire.""" + result_dict = await get_cryptocurrency_metadata.ainvoke( + {"ids": "1", "api_key": ""} + ) + + assert isinstance(result_dict, dict) + result = GetCryptocurrencyMetadataOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None diff --git a/src/modulex_integrations/tools/coinmarketcap/tools.py b/src/modulex_integrations/tools/coinmarketcap/tools.py new file mode 100644 index 0000000..b0eb0b6 --- /dev/null +++ b/src/modulex_integrations/tools/coinmarketcap/tools.py @@ -0,0 +1,355 @@ +"""CoinMarketCap 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.coinmarketcap.outputs import ( + CryptocurrencyMapItem, + CryptocurrencyMetadata, + CryptocurrencyQuote, + GetCryptocurrencyMetadataOutput, + IdMapOutput, + LatestListingsOutput, + LatestQuotesOutput, + ListingItem, + QuoteData, +) + +__all__ = [ + "get_cryptocurrency_metadata", + "id_map", + "latest_listings", + "latest_quotes", +] + +_BASE_URL = "https://pro-api.coinmarketcap.com" +_TIMEOUT = 30.0 + + +def _headers(api_key: str) -> dict[str, str]: + return { + "X-CMC_PRO_API_KEY": api_key, + "Accept": "application/json", + } + + +def _parse_quote(raw: dict[str, Any]) -> dict[str, QuoteData]: + result: dict[str, QuoteData] = {} + for currency, qdata in (raw or {}).items(): + if isinstance(qdata, dict): + result[currency] = QuoteData( + price=qdata.get("price"), + volume_24h=qdata.get("volume_24h"), + market_cap=qdata.get("market_cap"), + percent_change_1h=qdata.get("percent_change_1h"), + percent_change_24h=qdata.get("percent_change_24h"), + percent_change_7d=qdata.get("percent_change_7d"), + last_updated=qdata.get("last_updated"), + ) + return result + + +# --- Input schemas -------------------------------------------------------- + + +class GetCryptocurrencyMetadataInput(BaseModel): + ids: str = Field(description="One or more comma-separated CoinMarketCap cryptocurrency IDs") + api_key: str = Field(description="CoinMarketCap API key") + skip_invalid: bool = Field(default=False, description="When true, invalid lookups will be skipped") + aux: str | None = Field(default=None, description="Comma-separated supplemental data fields to return") + + +class IdMapInput(BaseModel): + api_key: str = Field(description="CoinMarketCap API key") + listing_status: str | None = Field(default=None, description="Filter by status: active, inactive, untracked") + start: int | None = Field(default=None, description="Offset the start (1-based index)") + limit: int = Field(default=100, description="Number of results to return") + sort: str | None = Field(default=None, description="Sort field: cmc_rank or id") + symbol: str | None = Field(default=None, description="Comma-separated cryptocurrency symbols") + aux: str | None = Field(default=None, description="Comma-separated supplemental data fields") + + +class LatestListingsInput(BaseModel): + api_key: str = Field(description="CoinMarketCap API key") + start: int | None = Field(default=None, description="Offset the start (1-based index)") + limit: int | None = Field(default=None, description="Number of results to return") + volume_24h_min: float | None = Field(default=None, description="Minimum 24 hour USD volume filter") + convert: str | None = Field(default=None, description="Comma-separated currency symbols for quotes") + convert_id: str | None = Field(default=None, description="Comma-separated CoinMarketCap IDs for quotes") + sort: str | None = Field(default=None, description="Sort field") + sort_dir: str | None = Field(default=None, description="Sort direction: asc or desc") + cryptocurrency_type: str | None = Field(default=None, description="Type filter: all, coins, tokens") + aux: str | None = Field(default=None, description="Comma-separated supplemental data fields") + + +class LatestQuotesInput(BaseModel): + api_key: str = Field(description="CoinMarketCap API key") + id: str | None = Field(default=None, description="Comma-separated CoinMarketCap cryptocurrency IDs") + slug: str | None = Field(default=None, description="Comma-separated cryptocurrency slugs") + symbol: str | None = Field(default=None, description="Comma-separated cryptocurrency symbols") + convert: str | None = Field(default=None, description="Comma-separated currency symbols for quotes") + convert_id: str | None = Field(default=None, description="Comma-separated CoinMarketCap IDs for quotes") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=GetCryptocurrencyMetadataInput) +@serialize_pydantic_return +async def get_cryptocurrency_metadata( + ids: str, + api_key: str, + skip_invalid: bool = False, + aux: str | None = None, +) -> GetCryptocurrencyMetadataOutput: + """Returns all static metadata available for one or more cryptocurrencies including name, symbol, logo, description, and URLs""" + if not api_key or not api_key.strip(): + return GetCryptocurrencyMetadataOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {"id": ids, "skip_invalid": str(skip_invalid).lower()} + if aux: + params["aux"] = aux + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/v2/cryptocurrency/info", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return GetCryptocurrencyMetadataOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + except httpx.TimeoutException: + return GetCryptocurrencyMetadataOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetCryptocurrencyMetadataOutput(success=False, error=f"Call failed: {exc}") + + raw_data = body.get("data", {}) + data: dict[str, CryptocurrencyMetadata] = {} + for key, item in raw_data.items(): + if isinstance(item, dict): + data[key] = CryptocurrencyMetadata( + id=item.get("id"), + name=item.get("name"), + symbol=item.get("symbol"), + slug=item.get("slug"), + description=item.get("description"), + logo=item.get("logo"), + date_added=item.get("date_added"), + category=item.get("category"), + ) + return GetCryptocurrencyMetadataOutput(success=True, data=data) + + +@tool(args_schema=IdMapInput) +@serialize_pydantic_return +async def id_map( + api_key: str, + listing_status: str | None = None, + start: int | None = None, + limit: int = 100, + sort: str | None = None, + symbol: str | None = None, + aux: str | None = None, +) -> IdMapOutput: + """Returns a mapping of all cryptocurrencies to unique CoinMarketCap IDs""" + if not api_key or not api_key.strip(): + return IdMapOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {"limit": limit} + if listing_status: + params["listing_status"] = listing_status + if start is not None: + params["start"] = start + if sort: + params["sort"] = sort + if symbol: + params["symbol"] = symbol + if aux: + params["aux"] = aux + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/v1/cryptocurrency/map", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return IdMapOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + except httpx.TimeoutException: + return IdMapOutput(success=False, error="Request timed out.") + except Exception as exc: + return IdMapOutput(success=False, error=f"Call failed: {exc}") + + raw_data = body.get("data", []) + data = [ + CryptocurrencyMapItem( + id=item.get("id"), + name=item.get("name"), + symbol=item.get("symbol"), + slug=item.get("slug"), + is_active=item.get("is_active"), + first_historical_data=item.get("first_historical_data"), + last_historical_data=item.get("last_historical_data"), + ) + for item in raw_data + if isinstance(item, dict) + ] + return IdMapOutput(success=True, data=data) + + +@tool(args_schema=LatestListingsInput) +@serialize_pydantic_return +async def latest_listings( + api_key: str, + start: int | None = None, + limit: int | None = None, + volume_24h_min: float | None = None, + convert: str | None = None, + convert_id: str | None = None, + sort: str | None = None, + sort_dir: str | None = None, + cryptocurrency_type: str | None = None, + aux: str | None = None, +) -> LatestListingsOutput: + """Returns a paginated list of all active cryptocurrencies with latest market data""" + if not api_key or not api_key.strip(): + return LatestListingsOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if start is not None: + params["start"] = start + if limit is not None: + params["limit"] = limit + if volume_24h_min is not None: + params["volume_24h_min"] = volume_24h_min + if convert: + params["convert"] = convert + if convert_id: + params["convert_id"] = convert_id + if sort: + params["sort"] = sort + if sort_dir: + params["sort_dir"] = sort_dir + if cryptocurrency_type: + params["cryptocurrency_type"] = cryptocurrency_type + if aux: + params["aux"] = aux + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/v1/cryptocurrency/listings/latest", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return LatestListingsOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + except httpx.TimeoutException: + return LatestListingsOutput(success=False, error="Request timed out.") + except Exception as exc: + return LatestListingsOutput(success=False, error=f"Call failed: {exc}") + + raw_data = body.get("data", []) + data = [ + ListingItem( + id=item.get("id"), + name=item.get("name"), + symbol=item.get("symbol"), + slug=item.get("slug"), + cmc_rank=item.get("cmc_rank"), + circulating_supply=item.get("circulating_supply"), + total_supply=item.get("total_supply"), + max_supply=item.get("max_supply"), + quote=_parse_quote(item.get("quote", {})), + ) + for item in raw_data + if isinstance(item, dict) + ] + return LatestListingsOutput(success=True, data=data) + + +@tool(args_schema=LatestQuotesInput) +@serialize_pydantic_return +async def latest_quotes( + api_key: str, + id: str | None = None, + slug: str | None = None, + symbol: str | None = None, + convert: str | None = None, + convert_id: str | None = None, +) -> LatestQuotesOutput: + """Returns the latest market quote for one or more cryptocurrencies. At least one of id, slug, or symbol is required.""" + if not api_key or not api_key.strip(): + return LatestQuotesOutput( + success=False, + error="API key is empty. Please configure a valid credential.", + ) + params: dict[str, Any] = {} + if id: + params["id"] = id + if slug: + params["slug"] = slug + if symbol: + params["symbol"] = symbol + if convert: + params["convert"] = convert + if convert_id: + params["convert_id"] = convert_id + if not params: + return LatestQuotesOutput( + success=False, + error="At least one of id, slug, or symbol is required.", + ) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{_BASE_URL}/v1/cryptocurrency/quotes/latest", + headers=_headers(api_key), + params=params, + ) + if response.status_code != 200: + return LatestQuotesOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + body = response.json() + except httpx.TimeoutException: + return LatestQuotesOutput(success=False, error="Request timed out.") + except Exception as exc: + return LatestQuotesOutput(success=False, error=f"Call failed: {exc}") + + raw_data = body.get("data", {}) + data: dict[str, CryptocurrencyQuote] = {} + for key, item in raw_data.items(): + if isinstance(item, dict): + data[key] = CryptocurrencyQuote( + id=item.get("id"), + name=item.get("name"), + symbol=item.get("symbol"), + slug=item.get("slug"), + cmc_rank=item.get("cmc_rank"), + quote=_parse_quote(item.get("quote", {})), + ) + return LatestQuotesOutput(success=True, data=data) From b5aa487b4e5a3ef1291b0e6e72466de7b26a17bc Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 16:40:37 +0000 Subject: [PATCH 12/15] auto-integrate: woocommerce Add `woocommerce` integration (17 actions, custom auth via REST API credentials). This integration enables managing WooCommerce stores via the REST API v3, covering orders (create, get, list, delete, update status), products (create, update, get, list), customers (search, get, create), order notes (add, get, list), refunds (create), and payment gateway listing. **Authentication:** Custom auth using store URL + consumer key + consumer secret (HTTP Basic Auth over HTTPS). Credential validation rejects empty fields before any API call is attempted. **Patches applied during consumer-side audit (2):** 1. `manifest.py` (check 8.9, mechanical) -- Normalized `logo` field from `logos:woocommerce-icon` to `modulex:woocommerce-themed` per project logo-naming convention. 2. `tests/test_woocommerce.py` (check 6.5, mechanical) -- Added `test_create_order_empty_credentials` failure-path test verifying the credential-validation guard returns `success=False` with a non-null error message when all credential fields are empty. **Test results:** 21 tests pass (17 happy-path action tests + 3 manifest sanity tests + 1 failure-path credential test). All tests use httpx_mock for HTTP assertions; no real API calls are made. Co-Authored-By: auto-integrate bot Provider: primary Run: 26689038146 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 9 + pyproject.toml | 6 + .../tools/woocommerce/README.md | 51 + .../tools/woocommerce/__init__.py | 63 ++ .../tools/woocommerce/dependencies.toml | 3 + .../tools/woocommerce/manifest.py | 432 ++++++++ .../tools/woocommerce/outputs.py | 216 ++++ .../tools/woocommerce/tests/__init__.py | 1 + .../woocommerce/tests/test_woocommerce.py | 466 +++++++++ .../tools/woocommerce/tools.py | 961 ++++++++++++++++++ 10 files changed, 2208 insertions(+) create mode 100644 src/modulex_integrations/tools/woocommerce/README.md create mode 100644 src/modulex_integrations/tools/woocommerce/__init__.py create mode 100644 src/modulex_integrations/tools/woocommerce/dependencies.toml create mode 100644 src/modulex_integrations/tools/woocommerce/manifest.py create mode 100644 src/modulex_integrations/tools/woocommerce/outputs.py create mode 100644 src/modulex_integrations/tools/woocommerce/tests/__init__.py create mode 100644 src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py create mode 100644 src/modulex_integrations/tools/woocommerce/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d55f93e..57ce487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `woocommerce` integration — 17 actions, auth: custom. WooCommerce REST API + integration for managing orders, products, customers, and refunds on + self-hosted WooCommerce stores (create_order, get_order, list_orders, + delete_order, update_order_status, create_product, update_product, + get_product, list_products, search_customers, get_customer, + create_customer, add_order_note, get_order_note, list_order_notes, + create_refund, list_payment_method_options). Producer-staged by + integration-drafts; consumer-side audit applied 2 patches before merge. + - `coinmarketcap` integration — 4 actions, auth: api_key. Cryptocurrency market data, quotes, and metadata from the CoinMarketCap API (get_cryptocurrency_metadata, id_map, latest_listings, latest_quotes). diff --git a/pyproject.toml b/pyproject.toml index 171766f..7ba767e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -194,6 +194,7 @@ okta = "modulex_integrations.tools.okta" pagerduty = "modulex_integrations.tools.pagerduty" shopify = "modulex_integrations.tools.shopify" shopify_partner = "modulex_integrations.tools.shopify_partner" +woocommerce = "modulex_integrations.tools.woocommerce" yelp = "modulex_integrations.tools.yelp" zoom = "modulex_integrations.tools.zoom" @@ -635,6 +636,11 @@ select = ["E", "F", "I", "N", "W", "B", "C4", "UP", "RUF"] # ParameterDef / Field kwargs that cannot be wrapped. "src/modulex_integrations/tools/coinmarketcap/manifest.py" = ["E501"] "src/modulex_integrations/tools/coinmarketcap/tools.py" = ["E501"] +# woocommerce manifest, tools, and tests have long description string literals in +# ParameterDef / Field kwargs that cannot be wrapped. +"src/modulex_integrations/tools/woocommerce/manifest.py" = ["E501"] +"src/modulex_integrations/tools/woocommerce/tools.py" = ["E501"] +"src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py" = ["E501"] [tool.mypy] python_version = "3.12" diff --git a/src/modulex_integrations/tools/woocommerce/README.md b/src/modulex_integrations/tools/woocommerce/README.md new file mode 100644 index 0000000..1ab1981 --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/README.md @@ -0,0 +1,51 @@ +# WooCommerce + +Manage orders, products, customers, refunds, and payment methods on self-hosted WooCommerce stores via the WooCommerce REST API (`{store_url}/wp-json/wc/v3`). + +## Authentication + +### WooCommerce REST API Credentials + +- Go to your WordPress admin panel > WooCommerce > Settings > Advanced > REST API. +- Click "Add key", give it a description, choose Read/Write permissions, and generate. +- Required env vars: + - `WOOCOMMERCE_STORE_URL` — your store's base URL (e.g. `https://mystore.com`) + - `WOOCOMMERCE_CONSUMER_KEY` — format: `ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` + - `WOOCOMMERCE_CONSUMER_SECRET` — format: `cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` +- Note: The store must be served over HTTPS for this integration to work (uses HTTP Basic Auth). HTTP-only stores requiring OAuth 1.0a signing are not supported. +- Docs: + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_order` | Create a new order in the WooCommerce store. | — | +| `get_order` | Retrieve a specific order by ID. | `order_id` | +| `list_orders` | Retrieve a list of orders with optional filters. | — | +| `delete_order` | Delete an existing order. | `order_id` | +| `update_order_status` | Update the status of a specific order. | `order_id`, `status` | +| `create_product` | Create a new product in the WooCommerce store. | `name` | +| `update_product` | Update an existing product. | `product_id` | +| `get_product` | Retrieve a specific product by ID. | `product_id` | +| `list_products` | Retrieve a list of products with optional filters. | — | +| `search_customers` | Search for customers by email, name, or other criteria. | — | +| `get_customer` | Retrieve a specific customer by ID. | `customer_id` | +| `create_customer` | Create a new customer. | `email` | +| `add_order_note` | Create a new note for an order. | `order_id`, `note` | +| `get_order_note` | Retrieve a specific order note. | `order_id`, `note_id` | +| `list_order_notes` | Retrieve all notes for a specific order. | `order_id` | +| `create_refund` | Create a new refund for an order. | `order_id` | +| `list_payment_method_options` | Retrieve available payment gateway options. | — | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential (custom auth with store_url, consumer_key, and consumer_secret). + +## Limits & Quotas + +- WooCommerce REST API rate limits depend on the hosting provider and server configuration. Most managed hosts enforce 60-120 requests/minute. +- Pagination is server-controlled; responses include `X-WP-Total` and `X-WP-TotalPages` headers. +- Error model: non-2xx responses are caught and returned as `success=False` + `error` rather than raising. Plan for retries on the agent side based on the error string. +- No per-request pricing — WooCommerce is self-hosted. + +## Maintainer + +ModuleX core team. diff --git a/src/modulex_integrations/tools/woocommerce/__init__.py b/src/modulex_integrations/tools/woocommerce/__init__.py new file mode 100644 index 0000000..42b7072 --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/__init__.py @@ -0,0 +1,63 @@ +"""WooCommerce integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.woocommerce.manifest import manifest +from modulex_integrations.tools.woocommerce.tools import ( + add_order_note, + create_customer, + create_order, + create_product, + create_refund, + delete_order, + get_customer, + get_order, + get_order_note, + get_product, + list_order_notes, + list_orders, + list_payment_method_options, + list_products, + search_customers, + update_order_status, + update_product, +) + +TOOLS = ( + create_order, + get_order, + list_orders, + delete_order, + update_order_status, + create_product, + update_product, + get_product, + list_products, + search_customers, + get_customer, + create_customer, + add_order_note, + get_order_note, + list_order_notes, + create_refund, + list_payment_method_options, +) + +__all__ = [ + "TOOLS", + "add_order_note", + "create_customer", + "create_order", + "create_product", + "create_refund", + "delete_order", + "get_customer", + "get_order", + "get_order_note", + "get_product", + "list_order_notes", + "list_orders", + "list_payment_method_options", + "list_products", + "manifest", + "search_customers", + "update_order_status", + "update_product", +] diff --git a/src/modulex_integrations/tools/woocommerce/dependencies.toml b/src/modulex_integrations/tools/woocommerce/dependencies.toml new file mode 100644 index 0000000..2c1cd9b --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the woocommerce integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/woocommerce/manifest.py b/src/modulex_integrations/tools/woocommerce/manifest.py new file mode 100644 index 0000000..e2f0fcc --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/manifest.py @@ -0,0 +1,432 @@ +"""WooCommerce integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + CustomAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="woocommerce", + display_name="WooCommerce", + description="WooCommerce REST API integration for managing orders, products, customers, and refunds on self-hosted WooCommerce stores.", + version="1.0.0", + author="ModuleX", + logo="modulex:woocommerce-themed", + app_url="https://woocommerce.com", + categories=["E-Commerce", "Retail", "Payments"], + actions=[ + ActionDefinition( + name="create_order", + description="Create a new order in the WooCommerce store.", + parameters={ + "status": ParameterDef( + type="string", + description="Order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash", + default="pending", + ), + "customer_id": ParameterDef( + type="integer", + description="User ID who owns the order. 0 for guests", + ), + "payment_method": ParameterDef( + type="string", + description="Payment method ID (e.g. bacs, cheque, cod, paypal)", + ), + "line_items": ParameterDef( + type="array", + description="Array of line item objects, each with product_id (integer) and quantity (integer)", + ), + }, + ), + ActionDefinition( + name="get_order", + description="Retrieve a specific order by ID.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="list_orders", + description="Retrieve a list of orders with optional filters.", + parameters={ + "search": ParameterDef( + type="string", + description="Limit results to those matching a string", + ), + "status": ParameterDef( + type="string", + description="Order status filter. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash", + default="pending", + ), + "customer": ParameterDef( + type="integer", + description="Filter by customer user ID. 0 for guests", + ), + "after": ParameterDef( + type="string", + description="Limit to orders created after this ISO8601 date (e.g. 2023-01-01T00:00:00)", + ), + "before": ParameterDef( + type="string", + description="Limit to orders created before this ISO8601 date (e.g. 2023-12-31T23:59:59)", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=20, + ), + }, + ), + ActionDefinition( + name="delete_order", + description="Delete an existing order.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order to delete", + required=True, + ), + "force": ParameterDef( + type="boolean", + description="Whether to bypass trash and permanently delete the order", + ), + }, + ), + ActionDefinition( + name="update_order_status", + description="Update the status of a specific order.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order to update", + required=True, + ), + "status": ParameterDef( + type="string", + description="New order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash", + required=True, + ), + }, + ), + ActionDefinition( + name="create_product", + description="Create a new product in the WooCommerce store.", + parameters={ + "name": ParameterDef( + type="string", + description="Name of the product", + required=True, + ), + "type": ParameterDef( + type="string", + description="Product type. Options: simple, grouped, external, variable", + default="simple", + ), + "status": ParameterDef( + type="string", + description="Product status. Options: draft, pending, private, publish", + default="publish", + ), + "regular_price": ParameterDef( + type="string", + description="Product regular price", + ), + "sale_price": ParameterDef( + type="string", + description="Product sale price", + ), + "description": ParameterDef( + type="string", + description="Product description (HTML allowed)", + ), + "categories": ParameterDef( + type="array", + description="Array of category IDs (integers) to assign the product to", + ), + "image_url": ParameterDef( + type="string", + description="URL of an image to add to the product", + ), + }, + ), + ActionDefinition( + name="update_product", + description="Update an existing product.", + parameters={ + "product_id": ParameterDef( + type="integer", + description="ID of the product to update", + required=True, + ), + "name": ParameterDef( + type="string", + description="New name for the product", + ), + "type": ParameterDef( + type="string", + description="Product type. Options: simple, grouped, external, variable", + ), + "status": ParameterDef( + type="string", + description="Product status. Options: draft, pending, private, publish", + ), + "regular_price": ParameterDef( + type="string", + description="Product regular price", + ), + "sale_price": ParameterDef( + type="string", + description="Product sale price", + ), + "description": ParameterDef( + type="string", + description="Product description (HTML allowed)", + ), + "categories": ParameterDef( + type="array", + description="Array of category IDs (integers) to assign the product to", + ), + "image_url": ParameterDef( + type="string", + description="URL of an image to add to the product", + ), + }, + ), + ActionDefinition( + name="get_product", + description="Retrieve a specific product by ID.", + parameters={ + "product_id": ParameterDef( + type="integer", + description="ID of the product to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="list_products", + description="Retrieve a list of products with optional filters.", + parameters={ + "search": ParameterDef( + type="string", + description="Limit results to those matching a string", + ), + "status": ParameterDef( + type="string", + description="Product status filter. Options: draft, pending, private, publish", + default="publish", + ), + "type": ParameterDef( + type="string", + description="Product type filter. Options: simple, grouped, external, variable", + default="simple", + ), + "after": ParameterDef( + type="string", + description="Limit to products created after this ISO8601 date", + ), + "before": ParameterDef( + type="string", + description="Limit to products created before this ISO8601 date", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=20, + ), + }, + ), + ActionDefinition( + name="search_customers", + description="Search for customers by email, name, or other criteria.", + parameters={ + "search": ParameterDef( + type="string", + description="Limit results to those matching a string", + ), + "email": ParameterDef( + type="string", + description="Filter by exact customer email address", + ), + "role": ParameterDef( + type="string", + description="Filter by role. Options: all, administrator, editor, author, contributor, subscriber, customer", + default="customer", + ), + "max_results": ParameterDef( + type="integer", + description="Maximum number of results to return", + default=20, + ), + }, + ), + ActionDefinition( + name="get_customer", + description="Retrieve a specific customer by ID.", + parameters={ + "customer_id": ParameterDef( + type="integer", + description="ID of the customer to retrieve", + required=True, + ), + }, + ), + ActionDefinition( + name="create_customer", + description="Create a new customer.", + parameters={ + "email": ParameterDef( + type="string", + description="Customer email address", + required=True, + ), + "first_name": ParameterDef( + type="string", + description="Customer first name", + ), + "last_name": ParameterDef( + type="string", + description="Customer last name", + ), + "username": ParameterDef( + type="string", + description="Customer login username", + ), + "password": ParameterDef( + type="string", + description="Customer password", + ), + "is_paying_customer": ParameterDef( + type="boolean", + description="Whether the customer is a paying customer", + ), + }, + ), + ActionDefinition( + name="add_order_note", + description="Create a new note for an order.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order to add a note to", + required=True, + ), + "note": ParameterDef( + type="string", + description="Content of the order note", + required=True, + ), + }, + ), + ActionDefinition( + name="get_order_note", + description="Retrieve a specific order note.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order", + required=True, + ), + "note_id": ParameterDef( + type="integer", + description="ID of the order note", + required=True, + ), + }, + ), + ActionDefinition( + name="list_order_notes", + description="Retrieve all notes for a specific order.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order", + required=True, + ), + "type": ParameterDef( + type="string", + description="Filter by note type. Options: any, customer, internal", + default="any", + ), + }, + ), + ActionDefinition( + name="create_refund", + description="Create a new refund for an order.", + parameters={ + "order_id": ParameterDef( + type="integer", + description="ID of the order to refund", + required=True, + ), + "amount": ParameterDef( + type="string", + description="Refund amount. If not specified, calculated from line items", + ), + "reason": ParameterDef( + type="string", + description="Reason for the refund", + ), + "api_refund": ParameterDef( + type="boolean", + description="When true, the payment gateway API generates the refund. When false, the refund is manual", + ), + "line_items": ParameterDef( + type="array", + description="Array of line item refund objects. Each with id (integer), refund_total (string), and optionally refund_tax (array)", + ), + }, + ), + ActionDefinition( + name="list_payment_method_options", + description="Retrieve available payment gateway options.", + parameters={}, + ), + ], + auth_schemas=[ + CustomAuthSchema( + display_name="WooCommerce REST API Credentials", + description="Authenticate using your WooCommerce store URL, consumer key, and consumer secret via HTTP Basic Auth (HTTPS required).", + setup_environment_variables=[ + EnvVar( + name="WOOCOMMERCE_STORE_URL", + display_name="Store URL", + description="Your WooCommerce store URL (e.g. https://mystore.com)", + required=True, + sensitive=False, + sample_format="https://mystore.example.com", + about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication", + ), + EnvVar( + name="WOOCOMMERCE_CONSUMER_KEY", + display_name="Consumer Key", + description="WooCommerce REST API consumer key from WooCommerce > Settings > Advanced > REST API", + required=True, + sensitive=True, + sample_format="ck_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication", + ), + EnvVar( + name="WOOCOMMERCE_CONSUMER_SECRET", + display_name="Consumer Secret", + description="WooCommerce REST API consumer secret from WooCommerce > Settings > Advanced > REST API", + required=True, + sensitive=True, + sample_format="cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + about_url="https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication", + ), + ], + ), + ], +) diff --git a/src/modulex_integrations/tools/woocommerce/outputs.py b/src/modulex_integrations/tools/woocommerce/outputs.py new file mode 100644 index 0000000..da8b4f6 --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/outputs.py @@ -0,0 +1,216 @@ +"""Pydantic response models for the woocommerce integration's @tool functions.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +__all__ = [ + "AddOrderNoteOutput", + "CreateCustomerOutput", + "CreateOrderOutput", + "CreateProductOutput", + "CreateRefundOutput", + "CustomerSummary", + "DeleteOrderOutput", + "GetCustomerOutput", + "GetOrderNoteOutput", + "GetOrderOutput", + "GetProductOutput", + "ListOrderNotesOutput", + "ListOrdersOutput", + "ListPaymentMethodOptionsOutput", + "ListProductsOutput", + "OrderNoteSummary", + "OrderSummary", + "PaymentMethodSummary", + "ProductSummary", + "RefundSummary", + "SearchCustomersOutput", + "UpdateOrderStatusOutput", + "UpdateProductOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Nested resource models ----------------------------------------------- + + +class OrderSummary(_Base): + id: int | None = None + number: str | None = None + status: str | None = None + total: str | None = None + currency: str | None = None + customer_id: int | None = None + payment_method: str | None = None + date_created: str | None = None + date_modified: str | None = None + line_items: list[dict[str, Any]] = Field(default_factory=list) + billing: dict[str, Any] | None = None + shipping: dict[str, Any] | None = None + + +class ProductSummary(_Base): + id: int | None = None + name: str | None = None + slug: str | None = None + type: str | None = None + status: str | None = None + regular_price: str | None = None + sale_price: str | None = None + price: str | None = None + description: str | None = None + categories: list[dict[str, Any]] = Field(default_factory=list) + images: list[dict[str, Any]] = Field(default_factory=list) + date_created: str | None = None + + +class CustomerSummary(_Base): + id: int | None = None + email: str | None = None + first_name: str | None = None + last_name: str | None = None + username: str | None = None + role: str | None = None + date_created: str | None = None + billing: dict[str, Any] | None = None + shipping: dict[str, Any] | None = None + is_paying_customer: bool | None = None + + +class OrderNoteSummary(_Base): + id: int | None = None + author: str | None = None + date_created: str | None = None + note: str | None = None + customer_note: bool | None = None + + +class RefundSummary(_Base): + id: int | None = None + amount: str | None = None + reason: str | None = None + refunded_by: int | None = None + date_created: str | None = None + line_items: list[dict[str, Any]] = Field(default_factory=list) + + +class PaymentMethodSummary(_Base): + id: str | None = None + title: str | None = None + description: str | None = None + enabled: bool | None = None + + +# --- Per-action output models --------------------------------------------- + + +class CreateOrderOutput(_Base): + success: bool + error: str | None = None + order: OrderSummary | None = None + + +class GetOrderOutput(_Base): + success: bool + error: str | None = None + order: OrderSummary | None = None + + +class ListOrdersOutput(_Base): + success: bool + error: str | None = None + orders: list[OrderSummary] = Field(default_factory=list) + total: int = 0 + + +class DeleteOrderOutput(_Base): + success: bool + error: str | None = None + order: OrderSummary | None = None + + +class UpdateOrderStatusOutput(_Base): + success: bool + error: str | None = None + order: OrderSummary | None = None + + +class CreateProductOutput(_Base): + success: bool + error: str | None = None + product: ProductSummary | None = None + + +class UpdateProductOutput(_Base): + success: bool + error: str | None = None + product: ProductSummary | None = None + + +class GetProductOutput(_Base): + success: bool + error: str | None = None + product: ProductSummary | None = None + + +class ListProductsOutput(_Base): + success: bool + error: str | None = None + products: list[ProductSummary] = Field(default_factory=list) + total: int = 0 + + +class SearchCustomersOutput(_Base): + success: bool + error: str | None = None + customers: list[CustomerSummary] = Field(default_factory=list) + total: int = 0 + + +class GetCustomerOutput(_Base): + success: bool + error: str | None = None + customer: CustomerSummary | None = None + + +class CreateCustomerOutput(_Base): + success: bool + error: str | None = None + customer: CustomerSummary | None = None + + +class AddOrderNoteOutput(_Base): + success: bool + error: str | None = None + note: OrderNoteSummary | None = None + + +class GetOrderNoteOutput(_Base): + success: bool + error: str | None = None + note: OrderNoteSummary | None = None + + +class ListOrderNotesOutput(_Base): + success: bool + error: str | None = None + notes: list[OrderNoteSummary] = Field(default_factory=list) + + +class CreateRefundOutput(_Base): + success: bool + error: str | None = None + refund: RefundSummary | None = None + + +class ListPaymentMethodOptionsOutput(_Base): + success: bool + error: str | None = None + payment_methods: list[PaymentMethodSummary] = Field(default_factory=list) diff --git a/src/modulex_integrations/tools/woocommerce/tests/__init__.py b/src/modulex_integrations/tools/woocommerce/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py b/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py new file mode 100644 index 0000000..03b9948 --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/tests/test_woocommerce.py @@ -0,0 +1,466 @@ +"""Happy-path tests for every woocommerce @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.woocommerce import ( + TOOLS, + add_order_note, + create_customer, + create_order, + create_product, + create_refund, + delete_order, + get_customer, + get_order, + get_order_note, + get_product, + list_order_notes, + list_orders, + list_payment_method_options, + list_products, + manifest, + search_customers, + update_order_status, + update_product, +) +from modulex_integrations.tools.woocommerce.outputs import ( + AddOrderNoteOutput, + CreateCustomerOutput, + CreateOrderOutput, + CreateProductOutput, + CreateRefundOutput, + DeleteOrderOutput, + GetCustomerOutput, + GetOrderNoteOutput, + GetOrderOutput, + GetProductOutput, + ListOrderNotesOutput, + ListOrdersOutput, + ListPaymentMethodOptionsOutput, + ListProductsOutput, + SearchCustomersOutput, + UpdateOrderStatusOutput, + UpdateProductOutput, +) + +API = "https://mystore.example.com/wp-json/wc/v3" + +_AUTH: dict[str, Any] = { + "auth_type": "custom", + "auth_data": { + "store_url": "https://mystore.example.com", + "consumer_key": "ck_fake_key", + "consumer_secret": "cs_fake_secret", + }, +} + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(_AUTH, **extra) + + +# --- Manifest sanity -------------------------------------------------------- + + +class TestManifest: + def test_manifest_exposes_17_actions(self) -> None: + assert len(manifest.actions) == 17 + + 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_create_order(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/orders", + json={ + # TODO: fill in a representative response shape from the WooCommerce API docs + "id": 123, + "number": "123", + "status": "pending", + "total": "50.00", + "currency": "USD", + "customer_id": 1, + "payment_method": "bacs", + "date_created": "2024-01-01T00:00:00", + "date_modified": "2024-01-01T00:00:00", + "line_items": [], + "billing": {}, + "shipping": {}, + }, + status_code=201, + ) + + result_dict = await create_order.ainvoke(_args(line_items=[{"product_id": 1, "quantity": 2}])) + + assert isinstance(result_dict, dict) + result = CreateOrderOutput.model_validate(result_dict) + assert result.success is True + assert result.order is not None + assert result.order.id == 123 + + +@pytest.mark.asyncio +async def test_get_order(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/orders/123", + json={ + "id": 123, + "number": "123", + "status": "processing", + "total": "99.00", + "currency": "USD", + "customer_id": 2, + "payment_method": "paypal", + "date_created": "2024-01-01T00:00:00", + "date_modified": "2024-01-02T00:00:00", + "line_items": [], + "billing": {}, + "shipping": {}, + }, + ) + + result_dict = await get_order.ainvoke(_args(order_id=123)) + + assert isinstance(result_dict, dict) + result = GetOrderOutput.model_validate(result_dict) + assert result.success is True + assert result.order is not None + assert result.order.status == "processing" + + +@pytest.mark.asyncio +async def test_list_orders(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/orders?per_page=20&status=pending", + json=[ + {"id": 1, "number": "1", "status": "pending", "total": "10.00", "currency": "USD", "customer_id": 0, "payment_method": "", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-01T00:00:00", "line_items": [], "billing": {}, "shipping": {}}, + ], + ) + + result_dict = await list_orders.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListOrdersOutput.model_validate(result_dict) + assert result.success is True + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_delete_order(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="DELETE", + url=f"{API}/orders/123", + json={"id": 123, "number": "123", "status": "trash", "total": "0.00", "currency": "USD", "customer_id": 0, "payment_method": "", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-01T00:00:00", "line_items": [], "billing": {}, "shipping": {}}, + ) + + result_dict = await delete_order.ainvoke(_args(order_id=123)) + + assert isinstance(result_dict, dict) + result = DeleteOrderOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_update_order_status(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/orders/123", + json={"id": 123, "number": "123", "status": "completed", "total": "50.00", "currency": "USD", "customer_id": 1, "payment_method": "bacs", "date_created": "2024-01-01T00:00:00", "date_modified": "2024-01-02T00:00:00", "line_items": [], "billing": {}, "shipping": {}}, + ) + + result_dict = await update_order_status.ainvoke(_args(order_id=123, status="completed")) + + assert isinstance(result_dict, dict) + result = UpdateOrderStatusOutput.model_validate(result_dict) + assert result.success is True + assert result.order is not None + assert result.order.status == "completed" + + +@pytest.mark.asyncio +async def test_create_product(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/products", + json={ + "id": 456, + "name": "Test Product", + "slug": "test-product", + "type": "simple", + "status": "publish", + "regular_price": "29.99", + "sale_price": "", + "price": "29.99", + "description": "A test product", + "categories": [], + "images": [], + "date_created": "2024-01-01T00:00:00", + }, + status_code=201, + ) + + result_dict = await create_product.ainvoke(_args(name="Test Product", regular_price="29.99")) + + assert isinstance(result_dict, dict) + result = CreateProductOutput.model_validate(result_dict) + assert result.success is True + assert result.product is not None + assert result.product.name == "Test Product" + + +@pytest.mark.asyncio +async def test_update_product(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/products/456", + json={ + "id": 456, + "name": "Updated Product", + "slug": "updated-product", + "type": "simple", + "status": "publish", + "regular_price": "39.99", + "sale_price": "", + "price": "39.99", + "description": "", + "categories": [], + "images": [], + "date_created": "2024-01-01T00:00:00", + }, + ) + + result_dict = await update_product.ainvoke(_args(product_id=456, name="Updated Product", regular_price="39.99")) + + assert isinstance(result_dict, dict) + result = UpdateProductOutput.model_validate(result_dict) + assert result.success is True + assert result.product is not None + assert result.product.regular_price == "39.99" + + +@pytest.mark.asyncio +async def test_get_product(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/products/456", + json={ + "id": 456, + "name": "Test Product", + "slug": "test-product", + "type": "simple", + "status": "publish", + "regular_price": "29.99", + "sale_price": "", + "price": "29.99", + "description": "", + "categories": [], + "images": [], + "date_created": "2024-01-01T00:00:00", + }, + ) + + result_dict = await get_product.ainvoke(_args(product_id=456)) + + assert isinstance(result_dict, dict) + result = GetProductOutput.model_validate(result_dict) + assert result.success is True + assert result.product is not None + assert result.product.id == 456 + + +@pytest.mark.asyncio +async def test_list_products(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/products?per_page=20&status=publish&type=simple", + json=[ + {"id": 1, "name": "P1", "slug": "p1", "type": "simple", "status": "publish", "regular_price": "10.00", "sale_price": "", "price": "10.00", "description": "", "categories": [], "images": [], "date_created": "2024-01-01T00:00:00"}, + ], + ) + + result_dict = await list_products.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListProductsOutput.model_validate(result_dict) + assert result.success is True + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_search_customers(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/customers?per_page=20&role=customer", + json=[ + {"id": 10, "email": "test@example.com", "first_name": "Test", "last_name": "User", "username": "testuser", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": True}, + ], + ) + + result_dict = await search_customers.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = SearchCustomersOutput.model_validate(result_dict) + assert result.success is True + assert result.total == 1 + + +@pytest.mark.asyncio +async def test_get_customer(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/customers/10", + json={"id": 10, "email": "test@example.com", "first_name": "Test", "last_name": "User", "username": "testuser", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": True}, + ) + + result_dict = await get_customer.ainvoke(_args(customer_id=10)) + + assert isinstance(result_dict, dict) + result = GetCustomerOutput.model_validate(result_dict) + assert result.success is True + assert result.customer is not None + assert result.customer.email == "test@example.com" + + +@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", + json={"id": 11, "email": "new@example.com", "first_name": "New", "last_name": "Customer", "username": "newcustomer", "role": "customer", "date_created": "2024-01-01T00:00:00", "billing": {}, "shipping": {}, "is_paying_customer": False}, + status_code=201, + ) + + result_dict = await create_customer.ainvoke(_args(email="new@example.com", first_name="New", last_name="Customer")) + + assert isinstance(result_dict, dict) + result = CreateCustomerOutput.model_validate(result_dict) + assert result.success is True + assert result.customer is not None + assert result.customer.id == 11 + + +@pytest.mark.asyncio +async def test_add_order_note(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/orders/123/notes", + json={"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "Test note", "customer_note": False}, + status_code=201, + ) + + result_dict = await add_order_note.ainvoke(_args(order_id=123, note="Test note")) + + assert isinstance(result_dict, dict) + result = AddOrderNoteOutput.model_validate(result_dict) + assert result.success is True + assert result.note is not None + assert result.note.note == "Test note" + + +@pytest.mark.asyncio +async def test_get_order_note(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/orders/123/notes/1", + json={"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "A note", "customer_note": False}, + ) + + result_dict = await get_order_note.ainvoke(_args(order_id=123, note_id=1)) + + assert isinstance(result_dict, dict) + result = GetOrderNoteOutput.model_validate(result_dict) + assert result.success is True + assert result.note is not None + + +@pytest.mark.asyncio +async def test_list_order_notes(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/orders/123/notes", + json=[ + {"id": 1, "author": "system", "date_created": "2024-01-01T00:00:00", "note": "Note 1", "customer_note": False}, + {"id": 2, "author": "admin", "date_created": "2024-01-02T00:00:00", "note": "Note 2", "customer_note": True}, + ], + ) + + result_dict = await list_order_notes.ainvoke(_args(order_id=123)) + + assert isinstance(result_dict, dict) + result = ListOrderNotesOutput.model_validate(result_dict) + assert result.success is True + assert len(result.notes) == 2 + + +@pytest.mark.asyncio +async def test_create_refund(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/orders/123/refunds", + json={"id": 5, "amount": "25.00", "reason": "Damaged item", "refunded_by": 1, "date_created": "2024-01-01T00:00:00", "line_items": []}, + status_code=201, + ) + + result_dict = await create_refund.ainvoke(_args(order_id=123, amount="25.00", reason="Damaged item")) + + assert isinstance(result_dict, dict) + result = CreateRefundOutput.model_validate(result_dict) + assert result.success is True + assert result.refund is not None + assert result.refund.amount == "25.00" + + +@pytest.mark.asyncio +async def test_list_payment_method_options(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="GET", + url=f"{API}/payment_gateways", + json=[ + {"id": "bacs", "title": "Direct bank transfer", "description": "Make your payment directly.", "enabled": True}, + {"id": "cod", "title": "Cash on delivery", "description": "Pay with cash.", "enabled": True}, + ], + ) + + result_dict = await list_payment_method_options.ainvoke(_args()) + + assert isinstance(result_dict, dict) + result = ListPaymentMethodOptionsOutput.model_validate(result_dict) + assert result.success is True + assert len(result.payment_methods) == 2 + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_order_empty_credentials() -> None: + """Credential validation rejects empty store_url / consumer_key / consumer_secret.""" + result_dict = await create_order.ainvoke( + { + "auth_type": "custom", + "auth_data": { + "store_url": "", + "consumer_key": "", + "consumer_secret": "", + }, + } + ) + + assert isinstance(result_dict, dict) + result = CreateOrderOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None diff --git a/src/modulex_integrations/tools/woocommerce/tools.py b/src/modulex_integrations/tools/woocommerce/tools.py new file mode 100644 index 0000000..5bd6dbe --- /dev/null +++ b/src/modulex_integrations/tools/woocommerce/tools.py @@ -0,0 +1,961 @@ +"""WooCommerce 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.woocommerce.outputs import ( + AddOrderNoteOutput, + CreateCustomerOutput, + CreateOrderOutput, + CreateProductOutput, + CreateRefundOutput, + CustomerSummary, + DeleteOrderOutput, + GetCustomerOutput, + GetOrderNoteOutput, + GetOrderOutput, + GetProductOutput, + ListOrderNotesOutput, + ListOrdersOutput, + ListPaymentMethodOptionsOutput, + ListProductsOutput, + OrderNoteSummary, + OrderSummary, + PaymentMethodSummary, + ProductSummary, + RefundSummary, + SearchCustomersOutput, + UpdateOrderStatusOutput, + UpdateProductOutput, +) + +__all__ = [ + "add_order_note", + "create_customer", + "create_order", + "create_product", + "create_refund", + "delete_order", + "get_customer", + "get_order", + "get_order_note", + "get_product", + "list_order_notes", + "list_orders", + "list_payment_method_options", + "list_products", + "search_customers", + "update_order_status", + "update_product", +] + +_TIMEOUT = 30.0 + + +def _build_base_url(auth_data: dict[str, Any]) -> str: + store_url = auth_data.get("store_url", "").rstrip("/") + return f"{store_url}/wp-json/wc/v3" + + +def _get_auth(auth_data: dict[str, Any]) -> tuple[str, str]: + return ( + auth_data.get("consumer_key", ""), + auth_data.get("consumer_secret", ""), + ) + + +def _validate_credentials(auth_data: dict[str, Any]) -> str | None: + store_url = auth_data.get("store_url", "") + consumer_key = auth_data.get("consumer_key", "") + consumer_secret = auth_data.get("consumer_secret", "") + if not store_url or not store_url.strip(): + return "Store URL is empty. Please configure a valid WooCommerce store URL." + if not consumer_key or not consumer_key.strip(): + return "Consumer key is empty. Please configure valid WooCommerce REST API credentials." + if not consumer_secret or not consumer_secret.strip(): + return "Consumer secret is empty. Please configure valid WooCommerce REST API credentials." + return None + + +def _parse_order(data: dict[str, Any]) -> OrderSummary: + return OrderSummary( + id=data.get("id"), + number=str(data.get("number", "")), + status=data.get("status"), + total=data.get("total"), + currency=data.get("currency"), + customer_id=data.get("customer_id"), + payment_method=data.get("payment_method"), + date_created=data.get("date_created"), + date_modified=data.get("date_modified"), + line_items=data.get("line_items") or [], + billing=data.get("billing"), + shipping=data.get("shipping"), + ) + + +def _parse_product(data: dict[str, Any]) -> ProductSummary: + return ProductSummary( + id=data.get("id"), + name=data.get("name"), + slug=data.get("slug"), + type=data.get("type"), + status=data.get("status"), + regular_price=data.get("regular_price"), + sale_price=data.get("sale_price"), + price=data.get("price"), + description=data.get("description"), + categories=data.get("categories") or [], + images=data.get("images") or [], + date_created=data.get("date_created"), + ) + + +def _parse_customer(data: dict[str, Any]) -> CustomerSummary: + return CustomerSummary( + id=data.get("id"), + email=data.get("email"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + username=data.get("username"), + role=data.get("role"), + date_created=data.get("date_created"), + billing=data.get("billing"), + shipping=data.get("shipping"), + is_paying_customer=data.get("is_paying_customer"), + ) + + +def _parse_order_note(data: dict[str, Any]) -> OrderNoteSummary: + return OrderNoteSummary( + id=data.get("id"), + author=data.get("author"), + date_created=data.get("date_created"), + note=data.get("note"), + customer_note=data.get("customer_note"), + ) + + +# --- Input schemas -------------------------------------------------------- + + +class CreateOrderInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + status: str | None = Field(default="pending", description="Order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash") + customer_id: int | None = Field(default=None, description="User ID who owns the order. 0 for guests") + payment_method: str | None = Field(default=None, description="Payment method ID (e.g. bacs, cheque, cod, paypal)") + line_items: list[dict[str, Any]] | None = Field(default=None, description="Array of line item objects, each with product_id (integer) and quantity (integer)") + + +class GetOrderInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order to retrieve") + + +class ListOrdersInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + search: str | None = Field(default=None, description="Limit results to those matching a string") + status: str | None = Field(default="pending", description="Order status filter. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash") + customer: int | None = Field(default=None, description="Filter by customer user ID. 0 for guests") + after: str | None = Field(default=None, description="Limit to orders created after this ISO8601 date") + before: str | None = Field(default=None, description="Limit to orders created before this ISO8601 date") + max_results: int = Field(default=20, description="Maximum number of results to return") + + +class DeleteOrderInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order to delete") + force: bool | None = Field(default=None, description="Whether to bypass trash and permanently delete the order") + + +class UpdateOrderStatusInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order to update") + status: str = Field(description="New order status. Options: pending, processing, on-hold, completed, cancelled, refunded, failed, trash") + + +class CreateProductInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + name: str = Field(description="Name of the product") + type: str | None = Field(default="simple", description="Product type. Options: simple, grouped, external, variable") + status: str | None = Field(default="publish", description="Product status. Options: draft, pending, private, publish") + regular_price: str | None = Field(default=None, description="Product regular price") + sale_price: str | None = Field(default=None, description="Product sale price") + description: str | None = Field(default=None, description="Product description (HTML allowed)") + categories: list[int] | None = Field(default=None, description="Array of category IDs to assign the product to") + image_url: str | None = Field(default=None, description="URL of an image to add to the product") + + +class UpdateProductInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + product_id: int = Field(description="ID of the product to update") + name: str | None = Field(default=None, description="New name for the product") + type: str | None = Field(default=None, description="Product type. Options: simple, grouped, external, variable") + status: str | None = Field(default=None, description="Product status. Options: draft, pending, private, publish") + regular_price: str | None = Field(default=None, description="Product regular price") + sale_price: str | None = Field(default=None, description="Product sale price") + description: str | None = Field(default=None, description="Product description (HTML allowed)") + categories: list[int] | None = Field(default=None, description="Array of category IDs to assign the product to") + image_url: str | None = Field(default=None, description="URL of an image to add to the product") + + +class GetProductInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + product_id: int = Field(description="ID of the product to retrieve") + + +class ListProductsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + search: str | None = Field(default=None, description="Limit results to those matching a string") + status: str | None = Field(default="publish", description="Product status filter. Options: draft, pending, private, publish") + type: str | None = Field(default="simple", description="Product type filter. Options: simple, grouped, external, variable") + after: str | None = Field(default=None, description="Limit to products created after this ISO8601 date") + before: str | None = Field(default=None, description="Limit to products created before this ISO8601 date") + max_results: int = Field(default=20, description="Maximum number of results to return") + + +class SearchCustomersInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + search: str | None = Field(default=None, description="Limit results to those matching a string") + email: str | None = Field(default=None, description="Filter by exact customer email address") + role: str | None = Field(default="customer", description="Filter by role. Options: all, administrator, editor, author, contributor, subscriber, customer") + max_results: int = Field(default=20, description="Maximum number of results to return") + + +class GetCustomerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + customer_id: int = Field(description="ID of the customer to retrieve") + + +class CreateCustomerInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + email: str = Field(description="Customer email address") + first_name: str | None = Field(default=None, description="Customer first name") + last_name: str | None = Field(default=None, description="Customer last name") + username: str | None = Field(default=None, description="Customer login username") + password: str | None = Field(default=None, description="Customer password") + is_paying_customer: bool | None = Field(default=None, description="Whether the customer is a paying customer") + + +class AddOrderNoteInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order to add a note to") + note: str = Field(description="Content of the order note") + + +class GetOrderNoteInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order") + note_id: int = Field(description="ID of the order note") + + +class ListOrderNotesInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order") + type: str | None = Field(default="any", description="Filter by note type. Options: any, customer, internal") + + +class CreateRefundInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + order_id: int = Field(description="ID of the order to refund") + amount: str | None = Field(default=None, description="Refund amount. If not specified, calculated from line items") + reason: str | None = Field(default=None, description="Reason for the refund") + api_refund: bool | None = Field(default=None, description="When true, the payment gateway API generates the refund") + line_items: list[dict[str, Any]] | None = Field(default=None, description="Array of line item refund objects with id, refund_total, and optionally refund_tax") + + +class ListPaymentMethodOptionsInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateOrderInput) +@serialize_pydantic_return +async def create_order( + auth_type: str, + auth_data: dict[str, Any], + status: str | None = "pending", + customer_id: int | None = None, + payment_method: str | None = None, + line_items: list[dict[str, Any]] | None = None, +) -> CreateOrderOutput: + """Create a new order in the WooCommerce store.""" + err = _validate_credentials(auth_data) + if err: + return CreateOrderOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + body: dict[str, Any] = {} + if status: + body["status"] = status + if customer_id is not None: + body["customer_id"] = customer_id + if payment_method: + body["payment_method"] = payment_method + if line_items: + body["line_items"] = line_items + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{base_url}/orders", + auth=auth, + json=body, + ) + if response.status_code not in (200, 201): + return CreateOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateOrderOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateOrderOutput(success=False, error=f"Call failed: {exc}") + return CreateOrderOutput(success=True, order=_parse_order(data)) + + +@tool(args_schema=GetOrderInput) +@serialize_pydantic_return +async def get_order( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, +) -> GetOrderOutput: + """Retrieve a specific order by ID.""" + err = _validate_credentials(auth_data) + if err: + return GetOrderOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/orders/{order_id}", + auth=auth, + ) + if response.status_code != 200: + return GetOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetOrderOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetOrderOutput(success=False, error=f"Call failed: {exc}") + return GetOrderOutput(success=True, order=_parse_order(data)) + + +@tool(args_schema=ListOrdersInput) +@serialize_pydantic_return +async def list_orders( + auth_type: str, + auth_data: dict[str, Any], + search: str | None = None, + status: str | None = "pending", + customer: int | None = None, + after: str | None = None, + before: str | None = None, + max_results: int = 20, +) -> ListOrdersOutput: + """Retrieve a list of orders with optional filters.""" + err = _validate_credentials(auth_data) + if err: + return ListOrdersOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + params: dict[str, Any] = {"per_page": min(max_results, 100)} + if search: + params["search"] = search + if status: + params["status"] = status + if customer is not None: + params["customer"] = customer + 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}/orders", + auth=auth, + params=params, + ) + if response.status_code != 200: + return ListOrdersOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListOrdersOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListOrdersOutput(success=False, error=f"Call failed: {exc}") + orders = [_parse_order(o) for o in data] + return ListOrdersOutput(success=True, orders=orders, total=len(orders)) + + +@tool(args_schema=DeleteOrderInput) +@serialize_pydantic_return +async def delete_order( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + force: bool | None = None, +) -> DeleteOrderOutput: + """Delete an existing order.""" + err = _validate_credentials(auth_data) + if err: + return DeleteOrderOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + params: dict[str, Any] = {} + if force is not None: + params["force"] = str(force).lower() + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.delete( + f"{base_url}/orders/{order_id}", + auth=auth, + params=params, + ) + if response.status_code != 200: + return DeleteOrderOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return DeleteOrderOutput(success=False, error="Request timed out.") + except Exception as exc: + return DeleteOrderOutput(success=False, error=f"Call failed: {exc}") + return DeleteOrderOutput(success=True, order=_parse_order(data)) + + +@tool(args_schema=UpdateOrderStatusInput) +@serialize_pydantic_return +async def update_order_status( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + status: str, +) -> UpdateOrderStatusOutput: + """Update the status of a specific order.""" + err = _validate_credentials(auth_data) + if err: + return UpdateOrderStatusOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put( + f"{base_url}/orders/{order_id}", + auth=auth, + json={"status": status}, + ) + if response.status_code != 200: + return UpdateOrderStatusOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateOrderStatusOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateOrderStatusOutput(success=False, error=f"Call failed: {exc}") + return UpdateOrderStatusOutput(success=True, order=_parse_order(data)) + + +@tool(args_schema=CreateProductInput) +@serialize_pydantic_return +async def create_product( + auth_type: str, + auth_data: dict[str, Any], + name: str, + type: str | None = "simple", + status: str | None = "publish", + regular_price: str | None = None, + sale_price: str | None = None, + description: str | None = None, + categories: list[int] | None = None, + image_url: str | None = None, +) -> CreateProductOutput: + """Create a new product in the WooCommerce store.""" + err = _validate_credentials(auth_data) + if err: + return CreateProductOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + body: dict[str, Any] = {"name": name} + if type: + body["type"] = type + if status: + body["status"] = status + if regular_price: + body["regular_price"] = regular_price + if sale_price: + body["sale_price"] = sale_price + if description: + body["description"] = description + if categories: + body["categories"] = [{"id": c} for c in categories] + if image_url: + body["images"] = [{"src": image_url}] + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{base_url}/products", + auth=auth, + json=body, + ) + if response.status_code not in (200, 201): + return CreateProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateProductOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateProductOutput(success=False, error=f"Call failed: {exc}") + return CreateProductOutput(success=True, product=_parse_product(data)) + + +@tool(args_schema=UpdateProductInput) +@serialize_pydantic_return +async def update_product( + auth_type: str, + auth_data: dict[str, Any], + product_id: int, + name: str | None = None, + type: str | None = None, + status: str | None = None, + regular_price: str | None = None, + sale_price: str | None = None, + description: str | None = None, + categories: list[int] | None = None, + image_url: str | None = None, +) -> UpdateProductOutput: + """Update an existing product.""" + err = _validate_credentials(auth_data) + if err: + return UpdateProductOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + body: dict[str, Any] = {} + if name: + body["name"] = name + if type: + body["type"] = type + if status: + body["status"] = status + if regular_price: + body["regular_price"] = regular_price + if sale_price: + body["sale_price"] = sale_price + if description: + body["description"] = description + if categories: + body["categories"] = [{"id": c} for c in categories] + if image_url: + body["images"] = [{"src": image_url}] + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put( + f"{base_url}/products/{product_id}", + auth=auth, + json=body, + ) + if response.status_code != 200: + return UpdateProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return UpdateProductOutput(success=False, error="Request timed out.") + except Exception as exc: + return UpdateProductOutput(success=False, error=f"Call failed: {exc}") + return UpdateProductOutput(success=True, product=_parse_product(data)) + + +@tool(args_schema=GetProductInput) +@serialize_pydantic_return +async def get_product( + auth_type: str, + auth_data: dict[str, Any], + product_id: int, +) -> GetProductOutput: + """Retrieve a specific product by ID.""" + err = _validate_credentials(auth_data) + if err: + return GetProductOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/products/{product_id}", + auth=auth, + ) + if response.status_code != 200: + return GetProductOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetProductOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetProductOutput(success=False, error=f"Call failed: {exc}") + return GetProductOutput(success=True, product=_parse_product(data)) + + +@tool(args_schema=ListProductsInput) +@serialize_pydantic_return +async def list_products( + auth_type: str, + auth_data: dict[str, Any], + search: str | None = None, + status: str | None = "publish", + type: str | None = "simple", + after: str | None = None, + before: str | None = None, + max_results: int = 20, +) -> ListProductsOutput: + """Retrieve a list of products with optional filters.""" + err = _validate_credentials(auth_data) + if err: + return ListProductsOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + params: dict[str, Any] = {"per_page": min(max_results, 100)} + if search: + params["search"] = search + if status: + params["status"] = status + if type: + params["type"] = type + 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}/products", + auth=auth, + params=params, + ) + if response.status_code != 200: + return ListProductsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListProductsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListProductsOutput(success=False, error=f"Call failed: {exc}") + products = [_parse_product(p) for p in data] + return ListProductsOutput(success=True, products=products, total=len(products)) + + +@tool(args_schema=SearchCustomersInput) +@serialize_pydantic_return +async def search_customers( + auth_type: str, + auth_data: dict[str, Any], + search: str | None = None, + email: str | None = None, + role: str | None = "customer", + max_results: int = 20, +) -> SearchCustomersOutput: + """Search for customers by email, name, or other criteria.""" + err = _validate_credentials(auth_data) + if err: + return SearchCustomersOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + params: dict[str, Any] = {"per_page": min(max_results, 100)} + if search: + params["search"] = search + if email: + params["email"] = email + if role: + params["role"] = role + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/customers", + auth=auth, + params=params, + ) + if response.status_code != 200: + return SearchCustomersOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return SearchCustomersOutput(success=False, error="Request timed out.") + except Exception as exc: + return SearchCustomersOutput(success=False, error=f"Call failed: {exc}") + customers = [_parse_customer(c) for c in data] + return SearchCustomersOutput(success=True, customers=customers, total=len(customers)) + + +@tool(args_schema=GetCustomerInput) +@serialize_pydantic_return +async def get_customer( + auth_type: str, + auth_data: dict[str, Any], + customer_id: int, +) -> GetCustomerOutput: + """Retrieve a specific customer by ID.""" + err = _validate_credentials(auth_data) + if err: + return GetCustomerOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/customers/{customer_id}", + auth=auth, + ) + if response.status_code != 200: + return GetCustomerOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetCustomerOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetCustomerOutput(success=False, error=f"Call failed: {exc}") + return GetCustomerOutput(success=True, customer=_parse_customer(data)) + + +@tool(args_schema=CreateCustomerInput) +@serialize_pydantic_return +async def create_customer( + auth_type: str, + auth_data: dict[str, Any], + email: str, + first_name: str | None = None, + last_name: str | None = None, + username: str | None = None, + password: str | None = None, + is_paying_customer: bool | None = None, +) -> CreateCustomerOutput: + """Create a new customer.""" + err = _validate_credentials(auth_data) + if err: + return CreateCustomerOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + body: dict[str, Any] = {"email": email} + if first_name: + body["first_name"] = first_name + if last_name: + body["last_name"] = last_name + if username: + body["username"] = username + if password: + body["password"] = password + if is_paying_customer is not None: + body["is_paying_customer"] = is_paying_customer + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{base_url}/customers", + auth=auth, + json=body, + ) + if response.status_code not in (200, 201): + return CreateCustomerOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateCustomerOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateCustomerOutput(success=False, error=f"Call failed: {exc}") + return CreateCustomerOutput(success=True, customer=_parse_customer(data)) + + +@tool(args_schema=AddOrderNoteInput) +@serialize_pydantic_return +async def add_order_note( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + note: str, +) -> AddOrderNoteOutput: + """Create a new note for an order.""" + err = _validate_credentials(auth_data) + if err: + return AddOrderNoteOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{base_url}/orders/{order_id}/notes", + auth=auth, + json={"note": note}, + ) + if response.status_code not in (200, 201): + return AddOrderNoteOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return AddOrderNoteOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddOrderNoteOutput(success=False, error=f"Call failed: {exc}") + return AddOrderNoteOutput(success=True, note=_parse_order_note(data)) + + +@tool(args_schema=GetOrderNoteInput) +@serialize_pydantic_return +async def get_order_note( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + note_id: int, +) -> GetOrderNoteOutput: + """Retrieve a specific order note.""" + err = _validate_credentials(auth_data) + if err: + return GetOrderNoteOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/orders/{order_id}/notes/{note_id}", + auth=auth, + ) + if response.status_code != 200: + return GetOrderNoteOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return GetOrderNoteOutput(success=False, error="Request timed out.") + except Exception as exc: + return GetOrderNoteOutput(success=False, error=f"Call failed: {exc}") + return GetOrderNoteOutput(success=True, note=_parse_order_note(data)) + + +@tool(args_schema=ListOrderNotesInput) +@serialize_pydantic_return +async def list_order_notes( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + type: str | None = "any", +) -> ListOrderNotesOutput: + """Retrieve all notes for a specific order.""" + err = _validate_credentials(auth_data) + if err: + return ListOrderNotesOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + params: dict[str, Any] = {} + if type and type != "any": + params["type"] = type + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/orders/{order_id}/notes", + auth=auth, + params=params, + ) + if response.status_code != 200: + return ListOrderNotesOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListOrderNotesOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListOrderNotesOutput(success=False, error=f"Call failed: {exc}") + notes = [_parse_order_note(n) for n in data] + return ListOrderNotesOutput(success=True, notes=notes) + + +@tool(args_schema=CreateRefundInput) +@serialize_pydantic_return +async def create_refund( + auth_type: str, + auth_data: dict[str, Any], + order_id: int, + amount: str | None = None, + reason: str | None = None, + api_refund: bool | None = None, + line_items: list[dict[str, Any]] | None = None, +) -> CreateRefundOutput: + """Create a new refund for an order.""" + err = _validate_credentials(auth_data) + if err: + return CreateRefundOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + body: dict[str, Any] = {} + if amount: + body["amount"] = amount + if reason: + body["reason"] = reason + if api_refund is not None: + body["api_refund"] = api_refund + if line_items: + body["line_items"] = line_items + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{base_url}/orders/{order_id}/refunds", + auth=auth, + json=body, + ) + if response.status_code not in (200, 201): + return CreateRefundOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return CreateRefundOutput(success=False, error="Request timed out.") + except Exception as exc: + return CreateRefundOutput(success=False, error=f"Call failed: {exc}") + return CreateRefundOutput( + success=True, + refund=RefundSummary( + id=data.get("id"), + amount=data.get("amount"), + reason=data.get("reason"), + refunded_by=data.get("refunded_by"), + date_created=data.get("date_created"), + line_items=data.get("line_items") or [], + ), + ) + + +@tool(args_schema=ListPaymentMethodOptionsInput) +@serialize_pydantic_return +async def list_payment_method_options( + auth_type: str, + auth_data: dict[str, Any], +) -> ListPaymentMethodOptionsOutput: + """Retrieve available payment gateway options.""" + err = _validate_credentials(auth_data) + if err: + return ListPaymentMethodOptionsOutput(success=False, error=err) + base_url = _build_base_url(auth_data) + auth = _get_auth(auth_data) + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.get( + f"{base_url}/payment_gateways", + auth=auth, + ) + if response.status_code != 200: + return ListPaymentMethodOptionsOutput(success=False, error=f"API error ({response.status_code}): {response.text}") + data = response.json() + except httpx.TimeoutException: + return ListPaymentMethodOptionsOutput(success=False, error="Request timed out.") + except Exception as exc: + return ListPaymentMethodOptionsOutput(success=False, error=f"Call failed: {exc}") + methods = [ + PaymentMethodSummary( + id=m.get("id"), + title=m.get("title"), + description=m.get("description"), + enabled=m.get("enabled"), + ) + for m in data + ] + return ListPaymentMethodOptionsOutput(success=True, payment_methods=methods) From a6b57d341657ca22ce660de182e6e26255696749 Mon Sep 17 00:00:00 2001 From: auto-integrate bot Date: Sat, 30 May 2026 23:26:22 +0000 Subject: [PATCH 13/15] auto-integrate: revolt **Add `revolt` integration -- 3 actions, bearer_token auth** Integrates the Revolt open-source chat platform with ModuleX. Actions: - `create_group` -- Create a new group channel - `add_group_member` -- Add another user to a group channel - `send_friend_request` -- Send a friend request to another user Authentication: Session token passed as `x-session-token` header (bearer_token auth schema). **Consumer-side audit patches applied (5):** 1. **manifest.py** (check 8.9, mechanical): Replaced upstream favicon URL with consumer convention `modulex:revolt-themed`. 2. **tools.py** (check 8.4, mechanical): Added credential-validity guard to `create_group`. 3. **tools.py** (check 8.4, mechanical): Added credential-validity guard to `add_group_member`. 4. **tools.py** (check 8.4, mechanical): Added credential-validity guard to `send_friend_request`. 5. **tests/test_revolt.py** (check 6.5, mechanical): Added failure-path test for empty token. **Drift warning (8.1):** URL path interpolation of IDs -- standard REST pattern, low risk. Co-Authored-By: auto-integrate bot Provider: primary Run: 26697546053 Co-Authored-By: auto-integrate bot --- CHANGELOG.md | 5 + pyproject.toml | 1 + .../tools/revolt/README.md | 30 +++ .../tools/revolt/__init__.py | 21 ++ .../tools/revolt/dependencies.toml | 3 + .../tools/revolt/manifest.py | 107 ++++++++++ .../tools/revolt/outputs.py | 42 ++++ .../tools/revolt/tests/__init__.py | 1 + .../tools/revolt/tests/test_revolt.py | 128 ++++++++++++ .../tools/revolt/tools.py | 186 ++++++++++++++++++ 10 files changed, 524 insertions(+) create mode 100644 src/modulex_integrations/tools/revolt/README.md create mode 100644 src/modulex_integrations/tools/revolt/__init__.py create mode 100644 src/modulex_integrations/tools/revolt/dependencies.toml create mode 100644 src/modulex_integrations/tools/revolt/manifest.py create mode 100644 src/modulex_integrations/tools/revolt/outputs.py create mode 100644 src/modulex_integrations/tools/revolt/tests/__init__.py create mode 100644 src/modulex_integrations/tools/revolt/tests/test_revolt.py create mode 100644 src/modulex_integrations/tools/revolt/tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 57ce487..8a8aaa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ### Added +- `revolt` integration — 3 actions, auth: bearer_token. Revolt open-source + chat platform — group management and friend requests (create_group, + add_group_member, send_friend_request). Producer-staged by + integration-drafts; consumer-side audit applied 5 patches before merge. + - `woocommerce` integration — 17 actions, auth: custom. WooCommerce REST API integration for managing orders, products, customers, and refunds on self-hosted WooCommerce stores (create_order, get_order, list_orders, diff --git a/pyproject.toml b/pyproject.toml index 7ba767e..20c48d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,7 @@ hubspot = "modulex_integrations.tools.hubspot" notion = "modulex_integrations.tools.notion" elevenlabs = "modulex_integrations.tools.elevenlabs" reflect = "modulex_integrations.tools.reflect" +revolt = "modulex_integrations.tools.revolt" salesforce = "modulex_integrations.tools.salesforce" clickup = "modulex_integrations.tools.clickup" google_drive = "modulex_integrations.tools.google_drive" diff --git a/src/modulex_integrations/tools/revolt/README.md b/src/modulex_integrations/tools/revolt/README.md new file mode 100644 index 0000000..00239f6 --- /dev/null +++ b/src/modulex_integrations/tools/revolt/README.md @@ -0,0 +1,30 @@ +# Revolt + +Open-source chat platform for group management and social features via the Revolt REST API (`revolt.chat/api`). + +## Authentication + +### Session Token + +- Obtain your session token from the Revolt client (inspect network requests or use the bot token from your Revolt bot settings). +- Required env var: `REVOLT_SESSION_TOKEN` (format: session token string). +- The token is sent as the `x-session-token` header on every request. + +## Tools + +| name | description | required params | +| --- | --- | --- | +| `create_group` | Create a new group channel | `name` | +| `add_group_member` | Add another user to a group channel | `target`, `member` | +| `send_friend_request` | Send a friend request to another user | `username` | + +Every tool takes an additional `auth_type`/`auth_data` pair that the runtime fills in from the resolved credential. + +## Limits & Quotas + +- No officially documented rate limits for the Revolt 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/revolt/__init__.py b/src/modulex_integrations/tools/revolt/__init__.py new file mode 100644 index 0000000..10ff87e --- /dev/null +++ b/src/modulex_integrations/tools/revolt/__init__.py @@ -0,0 +1,21 @@ +"""Revolt integration — discovered via the ``modulex.tools`` entry point.""" +from modulex_integrations.tools.revolt.manifest import manifest +from modulex_integrations.tools.revolt.tools import ( + add_group_member, + create_group, + send_friend_request, +) + +TOOLS = ( + create_group, + add_group_member, + send_friend_request, +) + +__all__ = [ + "TOOLS", + "add_group_member", + "create_group", + "manifest", + "send_friend_request", +] diff --git a/src/modulex_integrations/tools/revolt/dependencies.toml b/src/modulex_integrations/tools/revolt/dependencies.toml new file mode 100644 index 0000000..0d40e86 --- /dev/null +++ b/src/modulex_integrations/tools/revolt/dependencies.toml @@ -0,0 +1,3 @@ +# Runtime dependencies for the revolt integration. +# CI assembles this into the root pyproject's [project.optional-dependencies]. +dependencies = [] diff --git a/src/modulex_integrations/tools/revolt/manifest.py b/src/modulex_integrations/tools/revolt/manifest.py new file mode 100644 index 0000000..29f399e --- /dev/null +++ b/src/modulex_integrations/tools/revolt/manifest.py @@ -0,0 +1,107 @@ +"""Revolt integration manifest.""" +from __future__ import annotations + +from modulex_integrations.schema import ( + ActionDefinition, + BearerTokenAuthSchema, + EnvVar, + IntegrationManifest, + ParameterDef, + SuccessIndicators, + TestEndpoint, +) + +__all__ = ["manifest"] + + +manifest = IntegrationManifest( + name="revolt", + display_name="Revolt", + description="Revolt open-source chat platform — group management and friend requests", + version="1.0.0", + author="ModuleX", + logo="modulex:revolt-themed", + app_url="https://revolt.chat", + categories=["Communication"], + actions=[ + ActionDefinition( + name="create_group", + description="Create a new group channel", + parameters={ + "name": ParameterDef( + type="string", + description="The name of the group", + required=True, + ), + "description": ParameterDef( + type="string", + description="Group description", + ), + "users": ParameterDef( + type="array", + description="IDs of the users to add to the group", + ), + "nsfw": ParameterDef( + type="boolean", + description="Whether this group is age-restricted", + ), + }, + ), + ActionDefinition( + name="add_group_member", + description="Add another user to a group channel", + parameters={ + "target": ParameterDef( + type="string", + description="ID of the group channel", + required=True, + ), + "member": ParameterDef( + type="string", + description="ID of the user to add", + required=True, + ), + }, + ), + ActionDefinition( + name="send_friend_request", + description="Send a friend request to another user", + parameters={ + "username": ParameterDef( + type="string", + description="Username and discriminator combo separated by #", + required=True, + ), + }, + ), + ], + auth_schemas=[ + BearerTokenAuthSchema( + display_name="Session Token", + description=( + "Authenticate using your Revolt session token" + " (sent as x-session-token header)" + ), + setup_environment_variables=[ + EnvVar( + name="REVOLT_SESSION_TOKEN", + display_name="Session Token", + description="Your Revolt session token from the Revolt client", + required=True, + sensitive=True, + ), + ], + test_endpoint=TestEndpoint( + url="https://revolt.chat/api/users/@me", + method="GET", + headers={"x-session-token": "{token}"}, + success_indicators=SuccessIndicators( + status_codes=[200], + response_fields=["_id"], + ), + cost_level="free", + description="Validates the session token by fetching the current user", + ), + ), + ], +) diff --git a/src/modulex_integrations/tools/revolt/outputs.py b/src/modulex_integrations/tools/revolt/outputs.py new file mode 100644 index 0000000..6422dc4 --- /dev/null +++ b/src/modulex_integrations/tools/revolt/outputs.py @@ -0,0 +1,42 @@ +"""Pydantic response models for the revolt integration's @tool functions.""" +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = [ + "AddGroupMemberOutput", + "CreateGroupOutput", + "SendFriendRequestOutput", +] + + +class _Base(BaseModel): + """Shared config for every output model in this integration.""" + + model_config = ConfigDict(extra="forbid") + + +# --- Per-action output models ---------------------------------------------- + + +class CreateGroupOutput(_Base): + success: bool + error: str | None = None + channel_id: str | None = None + channel_type: str | None = None + name: str | None = None + description: str | None = None + owner: str | None = None + nsfw: bool | None = None + + +class AddGroupMemberOutput(_Base): + success: bool + error: str | None = None + + +class SendFriendRequestOutput(_Base): + success: bool + error: str | None = None + user_id: str | None = None + status: str | None = None diff --git a/src/modulex_integrations/tools/revolt/tests/__init__.py b/src/modulex_integrations/tools/revolt/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/modulex_integrations/tools/revolt/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/src/modulex_integrations/tools/revolt/tests/test_revolt.py b/src/modulex_integrations/tools/revolt/tests/test_revolt.py new file mode 100644 index 0000000..c02cf5a --- /dev/null +++ b/src/modulex_integrations/tools/revolt/tests/test_revolt.py @@ -0,0 +1,128 @@ +"""Happy-path tests for every revolt @tool, plus a manifest sanity check.""" +from __future__ import annotations + +from typing import Any + +import pytest + +from modulex_integrations.tools.revolt import ( + TOOLS, + add_group_member, + create_group, + manifest, + send_friend_request, +) +from modulex_integrations.tools.revolt.outputs import ( + AddGroupMemberOutput, + CreateGroupOutput, + SendFriendRequestOutput, +) + +API = "https://revolt.chat/api" + +_AUTH: dict[str, Any] = { + "auth_type": "bearer_token", + "auth_data": {"token": "fake_session_token"}, +} + + +def _args(**extra: Any) -> dict[str, Any]: + return dict(_AUTH, **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_bearer_token_auth(self) -> None: + assert {a.auth_type for a in manifest.auth_schemas} == {"bearer_token"} + + +# --- Per-action happy-path tests ------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_group(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/channels/create", + json={ + # TODO: fill in a representative response shape from the Revolt API docs + "_id": "01ABCDEF", + "channel_type": "Group", + "name": "Test Group", + "owner": "01USER", + "nsfw": False, + }, + ) + + result_dict = await create_group.ainvoke(_args(name="Test Group")) + + assert isinstance(result_dict, dict) + result = CreateGroupOutput.model_validate(result_dict) + assert result.success is True + assert result.channel_id == "01ABCDEF" + assert result.name == "Test Group" + + +@pytest.mark.asyncio +async def test_add_group_member(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="PUT", + url=f"{API}/channels/01GROUP/recipients/01MEMBER", + status_code=204, + ) + + result_dict = await add_group_member.ainvoke( + _args(target="01GROUP", member="01MEMBER") + ) + + assert isinstance(result_dict, dict) + result = AddGroupMemberOutput.model_validate(result_dict) + assert result.success is True + + +@pytest.mark.asyncio +async def test_send_friend_request(httpx_mock): # type: ignore[no-untyped-def] + httpx_mock.add_response( + method="POST", + url=f"{API}/users/friend", + json={ + # TODO: fill in a representative response shape from the Revolt API docs + "_id": "01TARGET", + "status": "Outgoing", + }, + ) + + result_dict = await send_friend_request.ainvoke( + _args(username="testuser#0001") + ) + + assert isinstance(result_dict, dict) + result = SendFriendRequestOutput.model_validate(result_dict) + assert result.success is True + assert result.user_id == "01TARGET" + assert result.status == "Outgoing" + + +# --- Failure-path tests ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_group_empty_token(): # type: ignore[no-untyped-def] + """Empty credential should short-circuit without hitting the wire.""" + result_dict = await create_group.ainvoke( + {"auth_type": "bearer_token", "auth_data": {"token": ""}, "name": "Test"} + ) + + assert isinstance(result_dict, dict) + result = CreateGroupOutput.model_validate(result_dict) + assert result.success is False + assert result.error is not None + assert "token" in result.error.lower() diff --git a/src/modulex_integrations/tools/revolt/tools.py b/src/modulex_integrations/tools/revolt/tools.py new file mode 100644 index 0000000..e26b8b9 --- /dev/null +++ b/src/modulex_integrations/tools/revolt/tools.py @@ -0,0 +1,186 @@ +"""Revolt 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.revolt.outputs import ( + AddGroupMemberOutput, + CreateGroupOutput, + SendFriendRequestOutput, +) + +__all__ = [ + "add_group_member", + "create_group", + "send_friend_request", +] + +_BASE_URL = "https://revolt.chat/api" +_TIMEOUT = 30.0 + + +def _get_auth_headers(auth_type: str, auth_data: dict[str, Any]) -> dict[str, str]: + """Build headers for the Revolt API using the session token.""" + token = auth_data.get("token", "") + return { + "x-session-token": token, + "Content-Type": "application/json", + } + + +# --- Input schemas -------------------------------------------------------- + + +class CreateGroupInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + name: str = Field(description="The name of the group") + description: str | None = Field(default=None, description="Group description") + users: list[str] | None = Field( + default=None, description="IDs of the users to add to the group" + ) + nsfw: bool | None = Field(default=None, description="Whether this group is age-restricted") + + +class AddGroupMemberInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + target: str = Field(description="ID of the group channel") + member: str = Field(description="ID of the user to add") + + +class SendFriendRequestInput(BaseModel): + auth_type: str = Field(description="Authentication type") + auth_data: dict[str, Any] = Field(description="Authentication data") + username: str = Field(description="Username and discriminator combo separated by #") + + +# --- @tool functions ------------------------------------------------------ + + +@tool(args_schema=CreateGroupInput) +@serialize_pydantic_return +async def create_group( + auth_type: str, + auth_data: dict[str, Any], + name: str, + description: str | None = None, + users: list[str] | None = None, + nsfw: bool | None = None, +) -> CreateGroupOutput: + """Create a new group channel.""" + token = auth_data.get("token", "") + if not token or not token.strip(): + return CreateGroupOutput(success=False, error="Missing or empty session token.") + headers = _get_auth_headers(auth_type, auth_data) + payload: dict[str, Any] = {"name": name} + if description is not None: + payload["description"] = description + if users is not None: + payload["users"] = users + if nsfw is not None: + payload["nsfw"] = nsfw + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/channels/create", + headers=headers, + json=payload, + ) + 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, + channel_id=data.get("_id"), + channel_type=data.get("channel_type"), + name=data.get("name"), + description=data.get("description"), + owner=data.get("owner"), + nsfw=data.get("nsfw"), + ) + + +@tool(args_schema=AddGroupMemberInput) +@serialize_pydantic_return +async def add_group_member( + auth_type: str, + auth_data: dict[str, Any], + target: str, + member: str, +) -> AddGroupMemberOutput: + """Add another user to a group channel.""" + token = auth_data.get("token", "") + if not token or not token.strip(): + return AddGroupMemberOutput(success=False, error="Missing or empty session token.") + headers = _get_auth_headers(auth_type, auth_data) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.put( + f"{_BASE_URL}/channels/{target}/recipients/{member}", + headers=headers, + ) + if response.status_code not in (200, 204): + return AddGroupMemberOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + except httpx.TimeoutException: + return AddGroupMemberOutput(success=False, error="Request timed out.") + except Exception as exc: + return AddGroupMemberOutput(success=False, error=f"Call failed: {exc}") + + return AddGroupMemberOutput(success=True) + + +@tool(args_schema=SendFriendRequestInput) +@serialize_pydantic_return +async def send_friend_request( + auth_type: str, + auth_data: dict[str, Any], + username: str, +) -> SendFriendRequestOutput: + """Send a friend request to another user.""" + token = auth_data.get("token", "") + if not token or not token.strip(): + return SendFriendRequestOutput(success=False, error="Missing or empty session token.") + headers = _get_auth_headers(auth_type, auth_data) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + response = await client.post( + f"{_BASE_URL}/users/friend", + headers=headers, + json={"username": username}, + ) + if response.status_code not in (200, 201): + return SendFriendRequestOutput( + success=False, + error=f"API error ({response.status_code}): {response.text}", + ) + data = response.json() + except httpx.TimeoutException: + return SendFriendRequestOutput(success=False, error="Request timed out.") + except Exception as exc: + return SendFriendRequestOutput(success=False, error=f"Call failed: {exc}") + + return SendFriendRequestOutput( + success=True, + user_id=data.get("_id"), + status=data.get("status"), + ) From 2780521cc0c937c68c9176b26bd61f911b2dd01c Mon Sep 17 00:00:00 2001 From: SUY Date: Sat, 30 May 2026 21:53:09 -0500 Subject: [PATCH 14/15] Standardize logo identifiers across integration manifests --- src/modulex_integrations/tools/canvas/manifest.py | 2 +- src/modulex_integrations/tools/cogmento/manifest.py | 2 +- src/modulex_integrations/tools/fellow/manifest.py | 2 +- src/modulex_integrations/tools/gong/manifest.py | 2 +- src/modulex_integrations/tools/heygen/manifest.py | 2 +- src/modulex_integrations/tools/hunter/manifest.py | 2 +- src/modulex_integrations/tools/square/manifest.py | 2 +- src/modulex_integrations/tools/woocommerce/manifest.py | 2 +- src/modulex_integrations/tools/yelp/manifest.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/modulex_integrations/tools/canvas/manifest.py b/src/modulex_integrations/tools/canvas/manifest.py index b1e2bec..257d424 100644 --- a/src/modulex_integrations/tools/canvas/manifest.py +++ b/src/modulex_integrations/tools/canvas/manifest.py @@ -18,7 +18,7 @@ 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", + logo="modulex:instructure_canvas", app_url="https://www.instructure.com/canvas", categories=["Education", "Learning Management"], actions=[ diff --git a/src/modulex_integrations/tools/cogmento/manifest.py b/src/modulex_integrations/tools/cogmento/manifest.py index 1ab42b9..741ac7e 100644 --- a/src/modulex_integrations/tools/cogmento/manifest.py +++ b/src/modulex_integrations/tools/cogmento/manifest.py @@ -21,7 +21,7 @@ description="CRM platform for managing contacts, deals, and tasks", version="1.0.0", author="ModuleX", - logo="modulex:cogmento-themed", + logo="modulex:cogmento", app_url="https://www.cogmento.com", categories=["CRM", "Sales", "Productivity"], actions=[ diff --git a/src/modulex_integrations/tools/fellow/manifest.py b/src/modulex_integrations/tools/fellow/manifest.py index 5aff5ef..ce37050 100644 --- a/src/modulex_integrations/tools/fellow/manifest.py +++ b/src/modulex_integrations/tools/fellow/manifest.py @@ -21,7 +21,7 @@ version="1.0.0", author="ModuleX", logo="modulex:fellow-themed", - app_url="https://fellow.app", + app_url="https://fellow.ai", categories=["Productivity & Collaboration", "meetings"], actions=[ ActionDefinition( diff --git a/src/modulex_integrations/tools/gong/manifest.py b/src/modulex_integrations/tools/gong/manifest.py index a10ef35..2f61526 100644 --- a/src/modulex_integrations/tools/gong/manifest.py +++ b/src/modulex_integrations/tools/gong/manifest.py @@ -21,7 +21,7 @@ description="Revenue intelligence platform for recording, transcribing, and analyzing sales conversations", version="1.0.0", author="ModuleX", - logo="modulex:gong-themed", + logo="modulex:gong", app_url="https://www.gong.io", categories=["Sales", "Revenue Intelligence", "Conversation Analytics"], actions=[ diff --git a/src/modulex_integrations/tools/heygen/manifest.py b/src/modulex_integrations/tools/heygen/manifest.py index cba354e..c8e7a76 100644 --- a/src/modulex_integrations/tools/heygen/manifest.py +++ b/src/modulex_integrations/tools/heygen/manifest.py @@ -20,7 +20,7 @@ description="AI video generation platform for creating talking avatar videos", version="1.0.0", author="ModuleX", - logo="modulex:heygen-themed", + logo="modulex:heygen", app_url="https://www.heygen.com", categories=["AI", "Video", "Content Creation"], actions=[ diff --git a/src/modulex_integrations/tools/hunter/manifest.py b/src/modulex_integrations/tools/hunter/manifest.py index ff3bef3..6b5653f 100644 --- a/src/modulex_integrations/tools/hunter/manifest.py +++ b/src/modulex_integrations/tools/hunter/manifest.py @@ -20,7 +20,7 @@ description="Find and verify professional email addresses using the Hunter.io API", version="1.0.0", author="ModuleX", - logo="modulex:hunter-themed", + logo="modulex:hunter", app_url="https://hunter.io", categories=["Marketing & Sales", "Lead Generation", "Email"], actions=[ diff --git a/src/modulex_integrations/tools/square/manifest.py b/src/modulex_integrations/tools/square/manifest.py index 40207d8..d5b6190 100644 --- a/src/modulex_integrations/tools/square/manifest.py +++ b/src/modulex_integrations/tools/square/manifest.py @@ -21,7 +21,7 @@ description="Payment processing, commerce, and business management platform", version="1.0.0", author="ModuleX", - logo="modulex:square-themed", + logo="logos:square", app_url="https://squareup.com", categories=["payments", "commerce", "finance"], actions=[ diff --git a/src/modulex_integrations/tools/woocommerce/manifest.py b/src/modulex_integrations/tools/woocommerce/manifest.py index e2f0fcc..4b67efc 100644 --- a/src/modulex_integrations/tools/woocommerce/manifest.py +++ b/src/modulex_integrations/tools/woocommerce/manifest.py @@ -18,7 +18,7 @@ description="WooCommerce REST API integration for managing orders, products, customers, and refunds on self-hosted WooCommerce stores.", version="1.0.0", author="ModuleX", - logo="modulex:woocommerce-themed", + logo="logos:woocommerce-icon", app_url="https://woocommerce.com", categories=["E-Commerce", "Retail", "Payments"], actions=[ diff --git a/src/modulex_integrations/tools/yelp/manifest.py b/src/modulex_integrations/tools/yelp/manifest.py index 4c1d45f..2b43ddd 100644 --- a/src/modulex_integrations/tools/yelp/manifest.py +++ b/src/modulex_integrations/tools/yelp/manifest.py @@ -20,7 +20,7 @@ description="Search for businesses, read reviews, and get business details via the Yelp Fusion API", version="1.0.0", author="ModuleX", - logo="modulex:yelp-themed", + logo="modulex:yelp", app_url="https://www.yelp.com", categories=["Local Services", "Reviews", "Business Data"], actions=[ From da30fbd04b94da41598d54a71d2fb24bc4e20937 Mon Sep 17 00:00:00 2001 From: SUY Date: Sat, 30 May 2026 22:01:45 -0500 Subject: [PATCH 15/15] ci: deactivate auto-integrate cron (keep files) Comment out the schedule cron and gate the self-dispatch step off so the hourly pipeline stops running, while keeping the workflow files intact for later reactivation. workflow_dispatch remains active for manual on-demand runs. Reactivation steps are documented inline. Also disabled at the GitHub level via `gh workflow disable`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/auto-integrate.yml | 33 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-integrate.yml b/.github/workflows/auto-integrate.yml index 1e2a435..cccaa9a 100644 --- a/.github/workflows/auto-integrate.yml +++ b/.github/workflows/auto-integrate.yml @@ -26,14 +26,23 @@ name: auto-integrate # GitHub Actions injection-safety guidelines. on: - schedule: - # 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 * * * *' + # ─── DEACTIVATED 2026-05-30 ────────────────────────────────────────────── + # The auto-integrate cron is intentionally disabled. The workflow files are + # kept so the pipeline can be re-enabled later. To reactivate: + # 1. Uncomment the `schedule:` block below. + # 2. Restore the `if:` condition on the "Self-dispatch next run" step + # (its original value is preserved in a comment there). + # 3. Re-enable on GitHub: gh workflow enable auto-integrate.yml + # `workflow_dispatch` stays active so the pipeline can still be run manually + # on demand without restarting the automatic chain. + # schedule: + # # 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: @@ -406,7 +415,13 @@ jobs: # 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' + # DEACTIVATED 2026-05-30: the self-dispatch chain is disabled alongside + # the cron (see the `on:` block at the top of this file). This prevents + # a manual `workflow_dispatch` run from restarting the automatic chain. + # To reactivate, restore the original condition preserved on the next + # line and uncomment the `schedule:` block above. + # Original: steps.run.outputs.skipped != 'true' && steps.run.outputs.failed != 'true' && steps.run.outputs.tool_name != '' && github.event.inputs.dry_run != 'true' + if: false env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TOOL_NAME: ${{ steps.run.outputs.tool_name }}