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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 30db015..8a8aaa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,89 @@ 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, + 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). + 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). + 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 + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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..20c48d7 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" @@ -89,9 +90,11 @@ 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" +hunter = "modulex_integrations.tools.hunter" lemon_squeezy = "modulex_integrations.tools.lemon_squeezy" short_io = "modulex_integrations.tools.short_io" nasdaq = "modulex_integrations.tools.nasdaq" @@ -103,9 +106,11 @@ 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" +livestorm = "modulex_integrations.tools.livestorm" luma = "modulex_integrations.tools.luma" ahrefs = "modulex_integrations.tools.ahrefs" airtable = "modulex_integrations.tools.airtable" @@ -121,9 +126,11 @@ 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" +gong = "modulex_integrations.tools.gong" godaddy = "modulex_integrations.tools.godaddy" google_cloud = "modulex_integrations.tools.google_cloud" google_contacts = "modulex_integrations.tools.google_contacts" @@ -136,16 +143,19 @@ 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" 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" 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" @@ -176,6 +186,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" @@ -184,6 +195,8 @@ 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" [tool.hatch.version] @@ -578,10 +591,57 @@ 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"] "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"] +# 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"] +# 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"] +# 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"] +"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"] +# 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"] +# 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"] +# 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/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..257d424 --- /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:instructure_canvas", + 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"), + ), + ) 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..741ac7e --- /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", + 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) 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) 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..ce37050 --- /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.ai", + 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) 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..2f61526 --- /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", + 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", []), + ) 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..c8e7a76 --- /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", + 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"), + ) 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..6b5653f --- /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", + 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) 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")) 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)) 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"), + ) 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..d5b6190 --- /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="logos:square", + 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"), + ), + ) 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..4b67efc --- /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="logos:woocommerce-icon", + 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) 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..2b43ddd --- /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", + 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 + ], + )