From 09006915c11f60d615b2f5a7edeb8f26d0542dd7 Mon Sep 17 00:00:00 2001 From: SUY Date: Wed, 27 May 2026 01:18:42 -0500 Subject: [PATCH] Remove appdrag and zendesk integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops both tool packages and their pyproject entry-points. Also cleans up a stale zendesk cross-reference in posthog's manifest docstring. 107 → 105 integrations. Verification gates green (ruff, pytest; mypy unchanged from prior baseline). Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 - .../tools/appdrag/README.md | 38 - .../tools/appdrag/__init__.py | 17 - .../tools/appdrag/dependencies.toml | 1 - .../tools/appdrag/manifest.py | 163 ---- .../tools/appdrag/outputs.py | 44 -- .../tools/appdrag/tests/__init__.py | 0 .../tools/appdrag/tests/test_appdrag.py | 235 ------ .../tools/appdrag/tools.py | 350 --------- .../tools/posthog/manifest.py | 2 +- .../tools/zendesk/README.md | 47 -- .../tools/zendesk/__init__.py | 63 -- .../tools/zendesk/dependencies.toml | 1 - .../tools/zendesk/manifest.py | 435 ----------- .../tools/zendesk/outputs.py | 133 ---- .../tools/zendesk/tests/__init__.py | 0 .../tools/zendesk/tests/test_zendesk.py | 334 -------- .../tools/zendesk/tools.py | 722 ------------------ 18 files changed, 1 insertion(+), 2586 deletions(-) delete mode 100644 src/modulex_integrations/tools/appdrag/README.md delete mode 100644 src/modulex_integrations/tools/appdrag/__init__.py delete mode 100644 src/modulex_integrations/tools/appdrag/dependencies.toml delete mode 100644 src/modulex_integrations/tools/appdrag/manifest.py delete mode 100644 src/modulex_integrations/tools/appdrag/outputs.py delete mode 100644 src/modulex_integrations/tools/appdrag/tests/__init__.py delete mode 100644 src/modulex_integrations/tools/appdrag/tests/test_appdrag.py delete mode 100644 src/modulex_integrations/tools/appdrag/tools.py delete mode 100644 src/modulex_integrations/tools/zendesk/README.md delete mode 100644 src/modulex_integrations/tools/zendesk/__init__.py delete mode 100644 src/modulex_integrations/tools/zendesk/dependencies.toml delete mode 100644 src/modulex_integrations/tools/zendesk/manifest.py delete mode 100644 src/modulex_integrations/tools/zendesk/outputs.py delete mode 100644 src/modulex_integrations/tools/zendesk/tests/__init__.py delete mode 100644 src/modulex_integrations/tools/zendesk/tests/test_zendesk.py delete mode 100644 src/modulex_integrations/tools/zendesk/tools.py diff --git a/pyproject.toml b/pyproject.toml index 493e940..82214c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,6 @@ convertapi = "modulex_integrations.tools.convertapi" crunchbase = "modulex_integrations.tools.crunchbase" dropbox = "modulex_integrations.tools.dropbox" docusign = "modulex_integrations.tools.docusign" -appdrag = "modulex_integrations.tools.appdrag" hackernews = "modulex_integrations.tools.hackernews" heroku = "modulex_integrations.tools.heroku" hootsuite = "modulex_integrations.tools.hootsuite" @@ -134,7 +133,6 @@ supabase = "modulex_integrations.tools.supabase" hubspot = "modulex_integrations.tools.hubspot" notion = "modulex_integrations.tools.notion" elevenlabs = "modulex_integrations.tools.elevenlabs" -zendesk = "modulex_integrations.tools.zendesk" salesforce = "modulex_integrations.tools.salesforce" clickup = "modulex_integrations.tools.clickup" google_drive = "modulex_integrations.tools.google_drive" diff --git a/src/modulex_integrations/tools/appdrag/README.md b/src/modulex_integrations/tools/appdrag/README.md deleted file mode 100644 index ac3ecbf..0000000 --- a/src/modulex_integrations/tools/appdrag/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# AppDrag - -Cloud backend integration for AppDrag: invoke custom API functions and -run INSERT/UPDATE statements against the project's cloud database via -`api.appdrag.com/CloudBackend.aspx`. - -## Authentication - -### API Key + App ID - -- Required env vars: `APPDRAG_API_KEY` (secret), `APPDRAG_APP_ID`. -- Find both in your AppDrag project settings — - . -- Sent as `APIKey` + `appID` form fields, never as headers. Functions - go to `{app_id}.appdrag.site/api{path}`; DB queries go to the - backend `.aspx` URL. - -## Tools - -| name | description | required params | -| --- | --- | --- | -| `execute_api_function` | Call a cloud function on your app | `path` | -| `insert_row` | INSERT INTO `table` | `table`, `columns`, `values` | -| `update_row` | UPDATE `table` WHERE … | `table`, `columns_to_update`, `values`, `where_condition`, `where_values` | - -## Limits & Quotas - -- `insert_row` / `update_row` build raw SQL strings and pass them via - the `CloudDBExecuteRawQuery` command — quoting matches the legacy - implementation (single-quote escaping by doubling). Pre-flight - validation rejects length-mismatched columns/values or `?` - placeholder counts. -- `update_row` requires a non-empty `where_condition` to avoid - accidental full-table updates. - -## Maintainer - -ModuleX core team. diff --git a/src/modulex_integrations/tools/appdrag/__init__.py b/src/modulex_integrations/tools/appdrag/__init__.py deleted file mode 100644 index 2c65c4a..0000000 --- a/src/modulex_integrations/tools/appdrag/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""AppDrag integration.""" -from modulex_integrations.tools.appdrag.manifest import manifest -from modulex_integrations.tools.appdrag.tools import ( - execute_api_function, - insert_row, - update_row, -) - -TOOLS = (execute_api_function, insert_row, update_row) - -__all__ = [ - "TOOLS", - "execute_api_function", - "insert_row", - "manifest", - "update_row", -] diff --git a/src/modulex_integrations/tools/appdrag/dependencies.toml b/src/modulex_integrations/tools/appdrag/dependencies.toml deleted file mode 100644 index 4177f1a..0000000 --- a/src/modulex_integrations/tools/appdrag/dependencies.toml +++ /dev/null @@ -1 +0,0 @@ -dependencies = [] diff --git a/src/modulex_integrations/tools/appdrag/manifest.py b/src/modulex_integrations/tools/appdrag/manifest.py deleted file mode 100644 index 8d20dce..0000000 --- a/src/modulex_integrations/tools/appdrag/manifest.py +++ /dev/null @@ -1,163 +0,0 @@ -"""AppDrag integration manifest.""" -from __future__ import annotations - -from modulex_integrations.schema import ( - ActionDefinition, - ApiKeyAuthSchema, - EnvVar, - IntegrationManifest, - ParameterDef, - SuccessIndicators, - TestEndpoint, -) - -__all__ = ["manifest"] - - -manifest = IntegrationManifest( - name="appdrag", - display_name="AppDrag", - description=( - "Cloud-based development platform for managing cloud functions, " - "databases, and backend services. Build and deploy web applications " - "with serverless architecture." - ), - version="1.0.0", - author="ModuleX", - logo="modulex:appdrag", - app_url="https://appdrag.com", - categories=["Developer Tools & Infrastructure", "automation", "database"], - actions=[ - ActionDefinition( - name="execute_api_function", - description=( - "Execute an API function from an AppDrag cloud backend. " - "Calls custom API functions deployed on your AppDrag " - "application." - ), - parameters={ - "path": ParameterDef( - type="string", - description=( - "Function name path to execute " - "(e.g. '/insert-user', '/get-data')" - ), - required=True, - ), - "method": ParameterDef( - type="string", - description="HTTP method (GET, POST, PUT, PATCH, DELETE)", - default="GET", - ), - "data": ParameterDef( - type="object", - description="Data to pass to the function as key/value pairs", - ), - }, - ), - ActionDefinition( - name="insert_row", - description=( - "Insert a new row into an AppDrag cloud database table. " - "Executes an INSERT query with the specified columns and " - "values." - ), - parameters={ - "table": ParameterDef( - type="string", - description="Name of the database table to insert into", - required=True, - ), - "columns": ParameterDef( - type="array", - description="Column names to insert values into", - required=True, - ), - "values": ParameterDef( - type="array", - description="Values corresponding to each column", - required=True, - ), - }, - ), - ActionDefinition( - name="update_row", - description=( - "Update rows in an AppDrag cloud database table. Executes " - "an UPDATE query with a WHERE condition to target specific " - "rows." - ), - parameters={ - "table": ParameterDef( - type="string", - description="Name of the database table to update", - required=True, - ), - "columns_to_update": ParameterDef( - type="array", - description="Column names to update", - required=True, - ), - "values": ParameterDef( - type="array", - description="New values for each column to update", - required=True, - ), - "where_condition": ParameterDef( - type="string", - description=( - "SQL WHERE condition with ? placeholders " - "(e.g. 'id = ?' or 'email = ? AND status = ?')" - ), - required=True, - ), - "where_values": ParameterDef( - type="array", - description="Values to replace ? placeholders in where_condition", - required=True, - ), - }, - ), - ], - auth_schemas=[ - ApiKeyAuthSchema( - display_name="API Key Authentication", - description=( - "Authenticate using your AppDrag API key and Application " - "ID. Find these in your AppDrag project settings." - ), - setup_environment_variables=[ - EnvVar( - name="APPDRAG_API_KEY", - display_name="AppDrag API Key", - description="Your AppDrag API key for authentication", - required=True, - sensitive=True, - about_url="https://support.appdrag.com/doc/Get-your-API-Key", - ), - EnvVar( - name="APPDRAG_APP_ID", - display_name="AppDrag Application ID", - description="Your AppDrag Application ID (found in project settings)", - required=True, - sensitive=False, - about_url="https://support.appdrag.com/doc/Get-your-API-Key", - ), - ], - test_endpoint=TestEndpoint( - url="https://api.appdrag.com/CloudBackend.aspx", - method="POST", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - body={ - "APIKey": "{api_key}", - "appID": "{APPDRAG_APP_ID}", - "command": "CloudDBGetDataset", - "query": "show tables", - }, - success_indicators=SuccessIndicators(status_codes=[200]), - cost_level="minimal", - description="Validates API key and App ID by listing database tables", - ), - ), - ], -) diff --git a/src/modulex_integrations/tools/appdrag/outputs.py b/src/modulex_integrations/tools/appdrag/outputs.py deleted file mode 100644 index c4033fe..0000000 --- a/src/modulex_integrations/tools/appdrag/outputs.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Pydantic response models for the AppDrag integration.""" -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -__all__ = [ - "ExecuteApiFunctionOutput", - "InsertRowOutput", - "UpdateRowOutput", -] - - -class _Base(BaseModel): - model_config = ConfigDict(extra="forbid") - - -class ExecuteApiFunctionOutput(_Base): - success: bool - error: str | None = None - path: str | None = None - method: str | None = None - # response is whatever the upstream function returned: JSON object, - # string, list — we don't constrain it. - response: Any = None - - -class InsertRowOutput(_Base): - success: bool - error: str | None = None - table: str | None = None - columns: list[str] = Field(default_factory=list) - affected_rows: int = 0 - response: dict[str, Any] | None = None - - -class UpdateRowOutput(_Base): - success: bool - error: str | None = None - table: str | None = None - columns_updated: list[str] = Field(default_factory=list) - affected_rows: int = 0 - response: dict[str, Any] | None = None diff --git a/src/modulex_integrations/tools/appdrag/tests/__init__.py b/src/modulex_integrations/tools/appdrag/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/modulex_integrations/tools/appdrag/tests/test_appdrag.py b/src/modulex_integrations/tools/appdrag/tests/test_appdrag.py deleted file mode 100644 index 22d1d57..0000000 --- a/src/modulex_integrations/tools/appdrag/tests/test_appdrag.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for the AppDrag integration.""" -from __future__ import annotations - -from typing import Any - -import pytest - -from modulex_integrations.tools.appdrag import ( - TOOLS, - execute_api_function, - insert_row, - manifest, - update_row, -) -from modulex_integrations.tools.appdrag.outputs import ( - ExecuteApiFunctionOutput, - InsertRowOutput, - UpdateRowOutput, -) - -BACKEND = "https://api.appdrag.com/CloudBackend.aspx" -FUNCTION_HOST = "https://my-app.appdrag.site" - -_API_KEY = "appdrag-fake-key" -_APP_ID = "my-app" - - -def _args(**extra: Any) -> dict[str, Any]: - return dict(api_key=_API_KEY, app_id=_APP_ID, **extra) - - -class TestManifest: - def test_manifest_exposes_three_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"] - - def test_auth_has_two_env_vars(self) -> None: - auth = manifest.auth_schemas[0] - assert [e.name for e in auth.setup_environment_variables] == [ - "APPDRAG_API_KEY", - "APPDRAG_APP_ID", - ] - - -@pytest.mark.asyncio -async def test_execute_api_function_get(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{FUNCTION_HOST}/api/get-user?APIKey={_API_KEY}&appID={_APP_ID}&id=42", - json={"status": "ok", "payload": {"id": 42, "name": "Ada"}}, - ) - - result_dict = await execute_api_function.ainvoke( - _args(path="/get-user", method="GET", data={"id": "42"}) - ) - assert isinstance(result_dict, dict) - result = ExecuteApiFunctionOutput.model_validate(result_dict) - assert result.success is True - assert result.path == "/get-user" - assert result.method == "GET" - assert isinstance(result.response, dict) - assert result.response["payload"]["name"] == "Ada" - - -@pytest.mark.asyncio -async def test_execute_api_function_post(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{FUNCTION_HOST}/api/insert-user", - json={"status": "ok", "lastInsertId": 99}, - ) - - result_dict = await execute_api_function.ainvoke( - _args(path="/insert-user", method="POST", data={"name": "Ada"}) - ) - result = ExecuteApiFunctionOutput.model_validate(result_dict) - assert result.success is True - assert result.method == "POST" - - -@pytest.mark.asyncio -async def test_execute_api_function_rejects_bad_method() -> None: - result_dict = await execute_api_function.ainvoke( - _args(path="/x", method="OPTIONS") - ) - result = ExecuteApiFunctionOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "Invalid HTTP method" in result.error - - -@pytest.mark.asyncio -async def test_execute_api_function_4xx(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{FUNCTION_HOST}/api/missing", - status_code=404, - text="not found", - ) - result_dict = await execute_api_function.ainvoke( - _args(path="/missing", method="POST") - ) - result = ExecuteApiFunctionOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "404" in result.error - - -@pytest.mark.asyncio -async def test_insert_row(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=BACKEND, - json={"affectedRows": 1, "lastInsertId": 7}, - ) - - result_dict = await insert_row.ainvoke( - _args(table="users", columns=["name", "email"], values=["Ada", "a@x.io"]) - ) - result = InsertRowOutput.model_validate(result_dict) - assert result.success is True - assert result.affected_rows == 1 - assert result.columns == ["name", "email"] - - -@pytest.mark.asyncio -async def test_insert_row_zero_rows(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=BACKEND, - json={"affectedRows": 0}, - ) - result_dict = await insert_row.ainvoke( - _args(table="users", columns=["name"], values=["Ada"]) - ) - result = InsertRowOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "no rows affected" in result.error - - -@pytest.mark.asyncio -async def test_insert_row_validates_column_value_count() -> None: - result_dict = await insert_row.ainvoke( - _args(table="users", columns=["a", "b"], values=["1"]) - ) - result = InsertRowOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "must match" in result.error - - -@pytest.mark.asyncio -async def test_update_row(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=BACKEND, - json={"affectedRows": 2}, - ) - - result_dict = await update_row.ainvoke( - _args( - table="users", - columns_to_update=["status"], - values=["active"], - where_condition="created_at < ?", - where_values=["2026-01-01"], - ) - ) - result = UpdateRowOutput.model_validate(result_dict) - assert result.success is True - assert result.affected_rows == 2 - assert result.columns_updated == ["status"] - - -@pytest.mark.asyncio -async def test_update_row_placeholder_mismatch() -> None: - result_dict = await update_row.ainvoke( - _args( - table="users", - columns_to_update=["status"], - values=["active"], - where_condition="id = ? AND email = ?", - where_values=["1"], - ) - ) - result = UpdateRowOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "placeholders" in result.error - - -@pytest.mark.asyncio -async def test_update_row_requires_where() -> None: - result_dict = await update_row.ainvoke( - _args( - table="users", - columns_to_update=["status"], - values=["active"], - where_condition=" ", - where_values=[], - ) - ) - result = UpdateRowOutput.model_validate(result_dict) - assert result.success is False - assert result.error is not None and "WHERE" in result.error - - -@pytest.mark.asyncio -async def test_empty_credentials_short_circuit() -> None: - no_key = await insert_row.ainvoke( - { - "api_key": "", - "app_id": _APP_ID, - "table": "users", - "columns": ["a"], - "values": ["1"], - } - ) - no_key_result = InsertRowOutput.model_validate(no_key) - assert no_key_result.success is False - assert no_key_result.error is not None and "API key" in no_key_result.error - - no_app = await insert_row.ainvoke( - { - "api_key": _API_KEY, - "app_id": "", - "table": "users", - "columns": ["a"], - "values": ["1"], - } - ) - no_app_result = InsertRowOutput.model_validate(no_app) - assert no_app_result.success is False - assert no_app_result.error is not None and "App ID" in no_app_result.error diff --git a/src/modulex_integrations/tools/appdrag/tools.py b/src/modulex_integrations/tools/appdrag/tools.py deleted file mode 100644 index 9506a73..0000000 --- a/src/modulex_integrations/tools/appdrag/tools.py +++ /dev/null @@ -1,350 +0,0 @@ -"""AppDrag 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.appdrag.outputs import ( - ExecuteApiFunctionOutput, - InsertRowOutput, - UpdateRowOutput, -) - -__all__ = ["execute_api_function", "insert_row", "update_row"] - -_BACKEND_URL = "https://api.appdrag.com/CloudBackend.aspx" -_FUNCTION_URL_TEMPLATE = "https://{app_id}.appdrag.site/api{path}" -_TIMEOUT = 30.0 -_HTTP_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") -_FORM_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"} - - -def _function_url(app_id: str, path: str) -> str: - if not path.startswith("/"): - path = f"/{path}" - return _FUNCTION_URL_TEMPLATE.format(app_id=app_id, path=path) - - -def _auth_form(api_key: str, app_id: str) -> dict[str, str]: - return {"APIKey": api_key, "appID": app_id} - - -def _escape_sql_values(values: list[str]) -> str: - parts: list[str] = [] - for v in values: - if v is None: - parts.append("NULL") - else: - parts.append(f"'{str(v).replace(chr(39), chr(39) * 2)}'") - return ", ".join(parts) - - -def _build_insert_query(table: str, columns: list[str], values: list[str]) -> str: - return ( - f"INSERT INTO {table} ({', '.join(columns)}) " - f"VALUES ({_escape_sql_values(values)})" - ) - - -def _build_update_query( - table: str, - columns: list[str], - values: list[str], - where_condition: str, - where_values: list[str], -) -> str: - set_parts: list[str] = [] - for col, val in zip(columns, values, strict=False): - if val is None: - set_parts.append(f"{col} = NULL") - else: - escaped = str(val).replace("'", "''") - set_parts.append(f"{col} = '{escaped}'") - - where_clause = where_condition - for val in where_values: - if val is None: - where_clause = where_clause.replace("?", "NULL", 1) - else: - escaped = str(val).replace("'", "''") - where_clause = where_clause.replace("?", f"'{escaped}'", 1) - - return f"UPDATE {table} SET {', '.join(set_parts)} WHERE {where_clause}" - - -class ExecuteApiFunctionInput(BaseModel): - api_key: str = Field(description="AppDrag API key (provided by credential system)") - app_id: str = Field(description="AppDrag Application ID") - path: str = Field(description="Function name path (e.g. '/insert-user')") - method: str = Field(default="GET", description="HTTP method") - data: dict[str, Any] | None = Field(default=None, description="Function payload") - - -class InsertRowInput(BaseModel): - api_key: str = Field(description="AppDrag API key (provided by credential system)") - app_id: str = Field(description="AppDrag Application ID") - table: str = Field(description="Database table name") - columns: list[str] = Field(description="Column names to insert into") - values: list[str] = Field(description="Values corresponding to each column") - - -class UpdateRowInput(BaseModel): - api_key: str = Field(description="AppDrag API key (provided by credential system)") - app_id: str = Field(description="AppDrag Application ID") - table: str = Field(description="Database table name") - columns_to_update: list[str] = Field(description="Column names to update") - values: list[str] = Field(description="New values for each column") - where_condition: str = Field(description="SQL WHERE condition with ? placeholders") - where_values: list[str] = Field(description="Values to replace ? placeholders") - - -def _credential_error(name: str, field: str) -> str: - return ( - f"AppDrag {field} is empty for {name}. " - "Please configure a valid credential." - ) - - -@tool(args_schema=ExecuteApiFunctionInput) -@serialize_pydantic_return -async def execute_api_function( - api_key: str, - app_id: str, - path: str, - method: str = "GET", - data: dict[str, Any] | None = None, -) -> ExecuteApiFunctionOutput: - """Execute an API function from an AppDrag cloud backend.""" - if not api_key or not api_key.strip(): - return ExecuteApiFunctionOutput( - success=False, error=_credential_error("execute_api_function", "API key") - ) - if not app_id or not app_id.strip(): - return ExecuteApiFunctionOutput( - success=False, error=_credential_error("execute_api_function", "App ID") - ) - if not path or not path.strip(): - return ExecuteApiFunctionOutput( - success=False, error="Function path is required." - ) - - method_upper = method.upper() - if method_upper not in _HTTP_METHODS: - return ExecuteApiFunctionOutput( - success=False, - error=( - f"Invalid HTTP method: {method}. " - f"Must be one of: {', '.join(_HTTP_METHODS)}" - ), - ) - - url = _function_url(app_id, path) - auth = _auth_form(api_key, app_id) - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - if method_upper == "GET": - params: dict[str, Any] = {**auth, **(data or {})} - response = await client.get(url, params=params, headers=_FORM_HEADERS) - else: - form: dict[str, Any] = {**auth, **(data or {})} - response = await client.request( - method=method_upper, url=url, data=form, headers=_FORM_HEADERS - ) - - try: - parsed: Any = response.json() - except Exception: - parsed = response.text - - if response.status_code >= 400: - return ExecuteApiFunctionOutput( - success=False, - error=f"API error: {response.status_code} - {parsed}", - ) - except Exception as exc: - return ExecuteApiFunctionOutput( - success=False, error=f"Failed to execute API function: {exc}" - ) - - return ExecuteApiFunctionOutput( - success=True, path=path, method=method_upper, response=parsed - ) - - -@tool(args_schema=InsertRowInput) -@serialize_pydantic_return -async def insert_row( - api_key: str, - app_id: str, - table: str, - columns: list[str], - values: list[str], -) -> InsertRowOutput: - """Insert a new row into an AppDrag cloud database table.""" - if not api_key or not api_key.strip(): - return InsertRowOutput( - success=False, error=_credential_error("insert_row", "API key") - ) - if not app_id or not app_id.strip(): - return InsertRowOutput( - success=False, error=_credential_error("insert_row", "App ID") - ) - if not table or not table.strip(): - return InsertRowOutput(success=False, error="Table name is required.") - if not columns: - return InsertRowOutput(success=False, error="At least one column is required.") - if not values: - return InsertRowOutput(success=False, error="At least one value is required.") - if len(columns) != len(values): - return InsertRowOutput( - success=False, - error=( - f"Number of columns ({len(columns)}) must match number of " - f"values ({len(values)})." - ), - ) - - query = _build_insert_query(table, columns, values) - form = { - **_auth_form(api_key, app_id), - "command": "CloudDBExecuteRawQuery", - "query": query, - } - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.post( - _BACKEND_URL, data=form, headers=_FORM_HEADERS - ) - - try: - parsed = response.json() - except Exception: - parsed = {"raw_response": response.text} - - if response.status_code >= 400: - return InsertRowOutput( - success=False, - error=f"API error: {response.status_code} - {parsed}", - ) - if isinstance(parsed, dict) and parsed.get("error"): - return InsertRowOutput(success=False, error=str(parsed.get("error"))) - except Exception as exc: - return InsertRowOutput(success=False, error=f"Failed to insert row: {exc}") - - affected = parsed.get("affectedRows", 0) if isinstance(parsed, dict) else 0 - if isinstance(parsed, dict) and affected == 0: - return InsertRowOutput( - success=False, error="Insert operation failed - no rows affected" - ) - - return InsertRowOutput( - success=True, - table=table, - columns=columns, - affected_rows=affected, - response=parsed if isinstance(parsed, dict) else None, - ) - - -@tool(args_schema=UpdateRowInput) -@serialize_pydantic_return -async def update_row( - api_key: str, - app_id: str, - table: str, - columns_to_update: list[str], - values: list[str], - where_condition: str, - where_values: list[str], -) -> UpdateRowOutput: - """Update rows in an AppDrag cloud database table.""" - if not api_key or not api_key.strip(): - return UpdateRowOutput( - success=False, error=_credential_error("update_row", "API key") - ) - if not app_id or not app_id.strip(): - return UpdateRowOutput( - success=False, error=_credential_error("update_row", "App ID") - ) - if not table or not table.strip(): - return UpdateRowOutput(success=False, error="Table name is required.") - if not columns_to_update: - return UpdateRowOutput( - success=False, error="At least one column to update is required." - ) - if not values: - return UpdateRowOutput(success=False, error="At least one value is required.") - if len(columns_to_update) != len(values): - return UpdateRowOutput( - success=False, - error=( - f"Number of columns ({len(columns_to_update)}) must match " - f"number of values ({len(values)})." - ), - ) - if not where_condition or not where_condition.strip(): - return UpdateRowOutput( - success=False, - error="WHERE condition is required to prevent accidental full table updates.", - ) - - placeholders = where_condition.count("?") - if placeholders != len(where_values): - return UpdateRowOutput( - success=False, - error=( - f"Number of ? placeholders ({placeholders}) must match number " - f"of where_values ({len(where_values)})." - ), - ) - - query = _build_update_query( - table, columns_to_update, values, where_condition, where_values - ) - form = { - **_auth_form(api_key, app_id), - "command": "CloudDBExecuteRawQuery", - "query": query, - } - - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.post( - _BACKEND_URL, data=form, headers=_FORM_HEADERS - ) - - try: - parsed = response.json() - except Exception: - parsed = {"raw_response": response.text} - - if response.status_code >= 400: - return UpdateRowOutput( - success=False, - error=f"API error: {response.status_code} - {parsed}", - ) - if isinstance(parsed, dict) and parsed.get("error"): - return UpdateRowOutput(success=False, error=str(parsed.get("error"))) - except Exception as exc: - return UpdateRowOutput(success=False, error=f"Failed to update row: {exc}") - - affected = parsed.get("affectedRows", 0) if isinstance(parsed, dict) else 0 - if isinstance(parsed, dict) and affected == 0: - return UpdateRowOutput( - success=False, - error="Update operation failed - no rows matched the WHERE condition", - ) - - return UpdateRowOutput( - success=True, - table=table, - columns_updated=columns_to_update, - affected_rows=affected, - response=parsed if isinstance(parsed, dict) else None, - ) diff --git a/src/modulex_integrations/tools/posthog/manifest.py b/src/modulex_integrations/tools/posthog/manifest.py index cfe6e36..1bcd6f9 100644 --- a/src/modulex_integrations/tools/posthog/manifest.py +++ b/src/modulex_integrations/tools/posthog/manifest.py @@ -4,7 +4,7 @@ (``api_key`` personal token, ``project_id``, ``base_url``). We model that here as ``CustomAuthSchema`` with three ``EnvVar`` entries — the modulex runtime injects all three into each tool function as -positional args (same pattern as the zendesk triple-credential setup). +positional args. """ from __future__ import annotations diff --git a/src/modulex_integrations/tools/zendesk/README.md b/src/modulex_integrations/tools/zendesk/README.md deleted file mode 100644 index 94eff86..0000000 --- a/src/modulex_integrations/tools/zendesk/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Zendesk - -Zendesk customer-support integration via the v2 REST API. Pure HTTP. -17 actions across ticket CRUD + tags + comments, custom fields, -users, locales, macros, and help-center articles. - -## Authentication - -- **`api_key` auth_type** with a **triple-credential pattern**: - `subdomain` + `email` + `api_key`. These three together form a - Basic Auth header (`{email}/token:{api_key}` base64-encoded). -- Env vars (all required): `ZENDESK_SUBDOMAIN`, `ZENDESK_EMAIL` - (non-sensitive), `ZENDESK_API_KEY` (sensitive). -- `test_endpoint` hits `GET /users/me.json` and asserts the `user` - field. - -## Runtime convention - -Key-based but with three keys: every `@tool` accepts -`(subdomain, email, api_key, ...)` as positional args. The modulex -runtime injects all three from `auth_data`. - -## Tools - -| group | tools | -| --- | --- | -| Ticket CRUD | `create_ticket`, `update_ticket`, `delete_ticket`, `get_ticket`, `list_tickets`, `search_tickets` | -| Tags | `add_ticket_tags` (PUT — additive), `set_ticket_tags` (POST — replaces), `remove_ticket_tags` (DELETE) | -| Comments | `list_ticket_comments` | -| Custom fields | `set_custom_fields` | -| Users | `get_user` | -| Locales | `list_locales` | -| Macros | `list_macros`, `get_macro` | -| Help Center | `list_articles`, `get_article` | - -## Notes - -- All actions wrap in try/except → `success=False` envelope. -- 30s timeout on every request. -- `per_page` clamped to 100 (Zendesk's max). -- HTTP method semantics for tags are non-obvious (preserved from - legacy): **PUT** appends, **POST** replaces, **DELETE** removes - specific items. Documented in each action's docstring. - -## Maintainer - -ModuleX core team. diff --git a/src/modulex_integrations/tools/zendesk/__init__.py b/src/modulex_integrations/tools/zendesk/__init__.py deleted file mode 100644 index 6d91dff..0000000 --- a/src/modulex_integrations/tools/zendesk/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Zendesk integration.""" -from modulex_integrations.tools.zendesk.manifest import manifest -from modulex_integrations.tools.zendesk.tools import ( - add_ticket_tags, - create_ticket, - delete_ticket, - get_article, - get_macro, - get_ticket, - get_user, - list_articles, - list_locales, - list_macros, - list_ticket_comments, - list_tickets, - remove_ticket_tags, - search_tickets, - set_custom_fields, - set_ticket_tags, - update_ticket, -) - -TOOLS = ( - create_ticket, - update_ticket, - delete_ticket, - get_ticket, - list_tickets, - search_tickets, - add_ticket_tags, - set_ticket_tags, - remove_ticket_tags, - list_ticket_comments, - set_custom_fields, - get_user, - list_locales, - list_macros, - get_macro, - list_articles, - get_article, -) - -__all__ = [ - "TOOLS", - "add_ticket_tags", - "create_ticket", - "delete_ticket", - "get_article", - "get_macro", - "get_ticket", - "get_user", - "list_articles", - "list_locales", - "list_macros", - "list_ticket_comments", - "list_tickets", - "manifest", - "remove_ticket_tags", - "search_tickets", - "set_custom_fields", - "set_ticket_tags", - "update_ticket", -] diff --git a/src/modulex_integrations/tools/zendesk/dependencies.toml b/src/modulex_integrations/tools/zendesk/dependencies.toml deleted file mode 100644 index 4177f1a..0000000 --- a/src/modulex_integrations/tools/zendesk/dependencies.toml +++ /dev/null @@ -1 +0,0 @@ -dependencies = [] diff --git a/src/modulex_integrations/tools/zendesk/manifest.py b/src/modulex_integrations/tools/zendesk/manifest.py deleted file mode 100644 index 3f9a2ae..0000000 --- a/src/modulex_integrations/tools/zendesk/manifest.py +++ /dev/null @@ -1,435 +0,0 @@ -"""Zendesk integration manifest. - -Zendesk uses an unusual triple-credential pattern: `subdomain` + -`email` + `api_key` together form a Basic Auth header -(`{email}/token:{api_key}` base64-encoded). Modeled as -``api_key`` auth_type with three env vars. -""" -from __future__ import annotations - -from modulex_integrations.schema import ( - ActionDefinition, - ApiKeyAuthSchema, - EnvVar, - IntegrationManifest, - OAuth2AuthSchema, - OAuthConfig, - ParameterDef, - SuccessIndicators, - TestEndpoint, -) - -__all__ = ["manifest"] - - -def _subdomain_param() -> ParameterDef: - return ParameterDef( - type="string", - description="Zendesk subdomain (e.g. 'mycompany' for mycompany.zendesk.com)", - required=True, - ) - - -def _email_param() -> ParameterDef: - return ParameterDef( - type="string", description="Zendesk user email", required=True - ) - - -def _ticket_id_param() -> ParameterDef: - return ParameterDef( - type="integer", description="Zendesk ticket ID", required=True - ) - - -def _per_page() -> ParameterDef: - return ParameterDef( - type="integer", description="Results per page (max 100)", default=25 - ) - - -def _sort_order() -> ParameterDef: - return ParameterDef( - type="string", description="Sort order: asc or desc", default="desc" - ) - - -manifest = IntegrationManifest( - name="zendesk", - display_name="Zendesk", - description=( - "Zendesk customer support integration: ticket CRUD + tags, " - "macros, users, locales, and help center articles." - ), - version="1.0.0", - author="ModuleX", - logo="logos:zendesk-icon", - app_url="https://www.zendesk.com", - categories=["Customer Support", "Helpdesk", "Communication & Collaboration"], - actions=[ - ActionDefinition( - name="create_ticket", - description="Create a new support ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "subject": ParameterDef( - type="string", description="Ticket subject", required=True - ), - "comment_body": ParameterDef( - type="string", - description="Initial comment body", - required=True, - ), - "priority": ParameterDef( - type="string", - description="urgent / high / normal / low", - ), - "status": ParameterDef( - type="string", - description="new / open / pending / hold / solved / closed", - ), - "requester_email": ParameterDef( - type="string", description="Requester email" - ), - "assignee_id": ParameterDef( - type="integer", description="Assignee user ID" - ), - "tags": ParameterDef(type="array", description="Tags to add"), - "custom_fields": ParameterDef( - type="array", - description="List of {id, value} custom field objects", - ), - }, - ), - ActionDefinition( - name="update_ticket", - description="Update an existing ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "subject": ParameterDef(type="string", description="New subject"), - "comment_body": ParameterDef( - type="string", description="Comment to add" - ), - "comment_public": ParameterDef( - type="boolean", - description="Public (true) or internal (false)", - default=True, - ), - "priority": ParameterDef(type="string", description="Priority"), - "status": ParameterDef(type="string", description="Status"), - "assignee_id": ParameterDef( - type="integer", description="Assignee user ID" - ), - "tags": ParameterDef( - type="array", description="Tags (replaces existing)" - ), - }, - ), - ActionDefinition( - name="delete_ticket", - description="Delete a ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - }, - ), - ActionDefinition( - name="get_ticket", - description="Get a ticket by ID", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - }, - ), - ActionDefinition( - name="list_tickets", - description="List tickets with optional sorting", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "sort_by": ParameterDef(type="string", description="Sort field"), - "sort_order": _sort_order(), - "per_page": _per_page(), - }, - ), - ActionDefinition( - name="search_tickets", - description="Search tickets using Zendesk search syntax", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "query": ParameterDef( - type="string", - description="Zendesk search query", - required=True, - ), - "sort_by": ParameterDef(type="string", description="Sort field"), - "sort_order": _sort_order(), - "per_page": _per_page(), - }, - ), - ActionDefinition( - name="add_ticket_tags", - description="Append tags to a ticket (PUT — additive)", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "tags": ParameterDef( - type="array", description="Tags to add", required=True - ), - }, - ), - ActionDefinition( - name="set_ticket_tags", - description="Replace all tags on a ticket (POST — destructive)", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "tags": ParameterDef( - type="array", - description="Tags (replaces existing)", - required=True, - ), - }, - ), - ActionDefinition( - name="remove_ticket_tags", - description="Remove specific tags from a ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "tags": ParameterDef( - type="array", - description="Tags to remove", - required=True, - ), - }, - ), - ActionDefinition( - name="list_ticket_comments", - description="List comments on a ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "sort_order": ParameterDef( - type="string", description="asc or desc", default="asc" - ), - "per_page": _per_page(), - }, - ), - ActionDefinition( - name="set_custom_fields", - description="Set custom field values on a ticket", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "ticket_id": _ticket_id_param(), - "custom_fields": ParameterDef( - type="array", - description="List of {id, value} custom field objects", - required=True, - ), - }, - ), - ActionDefinition( - name="get_user", - description="Get a Zendesk user by ID", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "user_id": ParameterDef( - type="integer", description="User ID", required=True - ), - }, - ), - ActionDefinition( - name="list_locales", - description="List supported locales", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - }, - ), - ActionDefinition( - name="list_macros", - description="List Zendesk macros with filtering / sorting", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "access": ParameterDef( - type="string", - description="personal / agents / shared / account", - ), - "active": ParameterDef(type="boolean", description="Active filter"), - "category": ParameterDef( - type="integer", description="Category ID filter" - ), - "group_id": ParameterDef( - type="integer", description="Group ID filter" - ), - "sort_by": ParameterDef( - type="string", - description="alphabetical / created_at / updated_at / usage_*", - ), - "sort_order": ParameterDef( - type="string", description="asc or desc", default="asc" - ), - "per_page": _per_page(), - }, - ), - ActionDefinition( - name="get_macro", - description="Get a macro by ID", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "macro_id": ParameterDef( - type="integer", description="Macro ID", required=True - ), - }, - ), - ActionDefinition( - name="list_articles", - description="List help-center articles", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "locale": ParameterDef(type="string", description="Locale code"), - "category_id": ParameterDef( - type="integer", description="Category ID filter" - ), - "section_id": ParameterDef( - type="integer", description="Section ID filter" - ), - "per_page": _per_page(), - }, - ), - ActionDefinition( - name="get_article", - description="Get a help-center article by ID", - parameters={ - "subdomain": _subdomain_param(), - "email": _email_param(), - "article_id": ParameterDef( - type="integer", description="Article ID", required=True - ), - "locale": ParameterDef(type="string", description="Locale code"), - }, - ), - ], - auth_schemas=[ - OAuth2AuthSchema( - display_name="OAuth2 Authentication", - description=( - "Connect using Zendesk OAuth2 (recommended — avoids the " - "Basic Auth Base64 limitation of API token auth)." - ), - setup_environment_variables=[ - EnvVar( - name="ZENDESK_SUBDOMAIN", - display_name="Subdomain", - description="Subdomain (e.g. 'mycompany' for mycompany.zendesk.com)", - required=True, - sensitive=False, - sample_format="mycompany", - ), - EnvVar( - name="ZENDESK_OAUTH2_CLIENT_ID", - display_name="Client ID", - description=( - "Zendesk OAuth Client ID (Admin Center > Apps and " - "integrations > APIs > Zendesk API > OAuth Clients)" - ), - required=True, - sensitive=False, - only_for_custom=True, - about_url="https://support.zendesk.com/hc/en-us/articles/4408845965210", - ), - EnvVar( - name="ZENDESK_OAUTH2_CLIENT_SECRET", - display_name="Client Secret", - description="Zendesk OAuth Client Secret", - required=True, - sensitive=True, - only_for_custom=True, - about_url="https://support.zendesk.com/hc/en-us/articles/4408845965210", - ), - ], - oauth_config=OAuthConfig( - auth_url="https://{subdomain}.zendesk.com/oauth/authorizations/new", - token_url="https://{subdomain}.zendesk.com/oauth/tokens", - scopes=["read", "write"], - token_auth_method="body", - ), - # No test_endpoint for OAuth2: the test URL would need to - # interpolate {ZENDESK_SUBDOMAIN} (tenant-specific) but the - # modulex OAuth callback only stores OAuth tokens in - # auth_data. The api_key schema below still gets a working - # test because UI custom-fields ship the subdomain with the - # api_key credential. tools.py reads the subdomain from - # auth_data at action call time for both schemas. - test_endpoint=None, - ), - ApiKeyAuthSchema( - display_name="API Token Authentication", - description=( - "Authenticate using your Zendesk email + API token. " - "Generate the token in Admin Center > Apps and " - "integrations > APIs > Zendesk API." - ), - setup_environment_variables=[ - EnvVar( - name="ZENDESK_SUBDOMAIN", - display_name="Subdomain", - description="Subdomain (e.g. 'mycompany')", - required=True, - sensitive=False, - sample_format="mycompany", - ), - EnvVar( - name="ZENDESK_EMAIL", - display_name="Email", - description="Zendesk user email", - required=True, - sensitive=False, - ), - EnvVar( - name="ZENDESK_API_KEY", - display_name="API Token", - description="Zendesk API token", - required=True, - sensitive=True, - ), - ], - test_endpoint=TestEndpoint( - url="https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/users/me.json", - method="GET", - # Zendesk's Basic Auth username is a composite - # ``{email}/token`` (placeholder + literal suffix), - # which the modulex BasicAuthSpec directive doesn't - # express. We keep the reachability + 401-as-success - # workaround here; users wanting a proper credential - # test should use the OAuth2 auth schema above. - headers={"Authorization": "Basic {ZENDESK_API_KEY}"}, - success_indicators=SuccessIndicators(status_codes=[200, 401]), - cost_level="free", - description=( - "Reachability + subdomain check against Zendesk " - "/users/me. Accepts 200 or 401 (literal placeholder " - "auth — expected; composite Basic Auth username " - "isn't expressible via BasicAuthSpec). Prefer the " - "OAuth2 auth schema for proper credential validation." - ), - ), - ), - ], -) diff --git a/src/modulex_integrations/tools/zendesk/outputs.py b/src/modulex_integrations/tools/zendesk/outputs.py deleted file mode 100644 index 2df2e64..0000000 --- a/src/modulex_integrations/tools/zendesk/outputs.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Pydantic response models for the Zendesk integration.""" -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -__all__ = [ - "AddTicketTagsOutput", - "CreateTicketOutput", - "DeleteTicketOutput", - "GetArticleOutput", - "GetMacroOutput", - "GetTicketOutput", - "GetUserOutput", - "ListArticlesOutput", - "ListLocalesOutput", - "ListMacrosOutput", - "ListTicketCommentsOutput", - "ListTicketsOutput", - "RemoveTicketTagsOutput", - "SearchTicketsOutput", - "SetCustomFieldsOutput", - "SetTicketTagsOutput", - "TicketSummary", - "UpdateTicketOutput", -] - - -class _Base(BaseModel): - model_config = ConfigDict(extra="forbid") - success: bool - error: str | None = None - - -class TicketSummary(BaseModel): - model_config = ConfigDict(extra="forbid") - id: int | None = None - subject: str | None = None - status: str | None = None - priority: str | None = None - created_at: str | None = None - updated_at: str | None = None - ticket: dict[str, Any] | None = None - - -class CreateTicketOutput(TicketSummary, _Base): - pass - - -class UpdateTicketOutput(TicketSummary, _Base): - pass - - -class DeleteTicketOutput(_Base): - id: int | None = None - deleted: bool = False - - -class GetTicketOutput(_Base): - result: dict[str, Any] | None = None - - -class ListTicketsOutput(_Base): - tickets: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - next_page: str | None = None - previous_page: str | None = None - - -class SearchTicketsOutput(_Base): - results: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - next_page: str | None = None - previous_page: str | None = None - - -class _TagsOutput(_Base): - ticket_id: int | None = None - tags: list[str] = Field(default_factory=list) - - -class AddTicketTagsOutput(_TagsOutput): - added_count: int = 0 - - -class SetTicketTagsOutput(_TagsOutput): - pass - - -class RemoveTicketTagsOutput(_TagsOutput): - removed_count: int = 0 - - -class ListTicketCommentsOutput(_Base): - ticket_id: int | None = None - comments: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - next_page: str | None = None - - -class SetCustomFieldsOutput(_Base): - id: int | None = None - custom_fields: list[dict[str, Any]] = Field(default_factory=list) - - -class GetUserOutput(_Base): - result: dict[str, Any] | None = None - - -class ListLocalesOutput(_Base): - locales: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - - -class ListMacrosOutput(_Base): - macros: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - next_page: str | None = None - - -class GetMacroOutput(_Base): - result: dict[str, Any] | None = None - - -class ListArticlesOutput(_Base): - articles: list[dict[str, Any]] = Field(default_factory=list) - count: int = 0 - next_page: str | None = None - - -class GetArticleOutput(_Base): - result: dict[str, Any] | None = None diff --git a/src/modulex_integrations/tools/zendesk/tests/__init__.py b/src/modulex_integrations/tools/zendesk/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/modulex_integrations/tools/zendesk/tests/test_zendesk.py b/src/modulex_integrations/tools/zendesk/tests/test_zendesk.py deleted file mode 100644 index 1078b02..0000000 --- a/src/modulex_integrations/tools/zendesk/tests/test_zendesk.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Tests for the Zendesk integration.""" -from __future__ import annotations - -from typing import Any - -import pytest - -from modulex_integrations.tools.zendesk import ( - TOOLS, - add_ticket_tags, - create_ticket, - delete_ticket, - get_article, - get_macro, - get_ticket, - get_user, - list_articles, - list_locales, - list_macros, - list_ticket_comments, - list_tickets, - manifest, - remove_ticket_tags, - search_tickets, - set_custom_fields, - set_ticket_tags, - update_ticket, -) -from modulex_integrations.tools.zendesk.outputs import ( - AddTicketTagsOutput, - CreateTicketOutput, - DeleteTicketOutput, - GetArticleOutput, - GetMacroOutput, - GetTicketOutput, - GetUserOutput, - ListArticlesOutput, - ListLocalesOutput, - ListMacrosOutput, - ListTicketCommentsOutput, - ListTicketsOutput, - RemoveTicketTagsOutput, - SearchTicketsOutput, - SetCustomFieldsOutput, - SetTicketTagsOutput, - UpdateTicketOutput, -) - -API = "https://acme.zendesk.com/api/v2" -_CREDS: dict[str, Any] = { - "subdomain": "acme", - "email": "agent@x.io", - "api_key": "fake_token", -} - - -def _args(**extra: Any) -> dict[str, Any]: - return dict(_CREDS, **extra) - - -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_oauth2_and_api_key_auth(self) -> None: - assert [a.auth_type for a in manifest.auth_schemas] == [ - "oauth2", - "api_key", - ] - - -@pytest.mark.asyncio -async def test_create_ticket(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/tickets.json", - status_code=201, - json={"ticket": {"id": 42, "subject": "Help", "status": "new"}}, - ) - result = CreateTicketOutput.model_validate( - await create_ticket.ainvoke( - _args(subject="Help", comment_body="Need help") - ) - ) - assert result.success is True - assert result.id == 42 - - -@pytest.mark.asyncio -async def test_update_ticket_no_changes() -> None: - result = UpdateTicketOutput.model_validate( - await update_ticket.ainvoke(_args(ticket_id=42)) - ) - assert result.success is False - assert result.error is not None and "No update" in result.error - - -@pytest.mark.asyncio -async def test_update_ticket(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="PUT", - url=f"{API}/tickets/42.json", - json={"ticket": {"id": 42, "status": "solved"}}, - ) - result = UpdateTicketOutput.model_validate( - await update_ticket.ainvoke(_args(ticket_id=42, status="solved")) - ) - assert result.success is True - assert result.status == "solved" - - -@pytest.mark.asyncio -async def test_delete_ticket(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="DELETE", url=f"{API}/tickets/42.json", status_code=204 - ) - result = DeleteTicketOutput.model_validate( - await delete_ticket.ainvoke(_args(ticket_id=42)) - ) - assert result.success is True - assert result.deleted is True - - -@pytest.mark.asyncio -async def test_get_ticket(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/tickets/42.json", - json={"ticket": {"id": 42, "subject": "Hi"}}, - ) - result = GetTicketOutput.model_validate( - await get_ticket.ainvoke(_args(ticket_id=42)) - ) - assert result.success is True - assert result.result is not None and result.result["id"] == 42 - - -@pytest.mark.asyncio -async def test_get_ticket_404_envelope(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/tickets/999.json", - status_code=404, - text="not found", - ) - result = GetTicketOutput.model_validate( - await get_ticket.ainvoke(_args(ticket_id=999)) - ) - assert result.success is False - assert result.error is not None and "404" in result.error - - -@pytest.mark.asyncio -async def test_list_tickets(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/tickets.json?per_page=25&sort_order=desc", - json={"tickets": [{"id": 1}, {"id": 2}], "next_page": None}, - ) - result = ListTicketsOutput.model_validate( - await list_tickets.ainvoke(_args()) - ) - assert result.success is True - assert result.count == 2 - - -@pytest.mark.asyncio -async def test_search_tickets(httpx_mock: Any) -> None: - import re - - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{API}/search\.json\?.*"), - json={"results": [{"id": 1}], "count": 1}, - ) - result = SearchTicketsOutput.model_validate( - await search_tickets.ainvoke(_args(query="type:ticket status:open")) - ) - assert result.success is True - assert result.count == 1 - - -@pytest.mark.asyncio -async def test_add_ticket_tags(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="PUT", - url=f"{API}/tickets/42/tags.json", - json={"tags": ["urgent", "billing"]}, - ) - result = AddTicketTagsOutput.model_validate( - await add_ticket_tags.ainvoke(_args(ticket_id=42, tags=["urgent"])) - ) - assert result.success is True - assert result.added_count == 1 - - -@pytest.mark.asyncio -async def test_set_ticket_tags(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="POST", - url=f"{API}/tickets/42/tags.json", - json={"tags": ["new"]}, - ) - result = SetTicketTagsOutput.model_validate( - await set_ticket_tags.ainvoke(_args(ticket_id=42, tags=["new"])) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_remove_ticket_tags(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="DELETE", - url=f"{API}/tickets/42/tags.json", - json={"tags": []}, - ) - result = RemoveTicketTagsOutput.model_validate( - await remove_ticket_tags.ainvoke(_args(ticket_id=42, tags=["urgent"])) - ) - assert result.success is True - assert result.removed_count == 1 - - -@pytest.mark.asyncio -async def test_list_ticket_comments(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/tickets/42/comments.json?per_page=25&sort_order=asc", - json={"comments": [{"id": 1, "body": "hi"}]}, - ) - result = ListTicketCommentsOutput.model_validate( - await list_ticket_comments.ainvoke(_args(ticket_id=42)) - ) - assert result.success is True - assert result.count == 1 - - -@pytest.mark.asyncio -async def test_set_custom_fields(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="PUT", - url=f"{API}/tickets/42.json", - json={"ticket": {"id": 42, "custom_fields": [{"id": 1, "value": "x"}]}}, - ) - result = SetCustomFieldsOutput.model_validate( - await set_custom_fields.ainvoke( - _args(ticket_id=42, custom_fields=[{"id": 1, "value": "x"}]) - ) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_get_user(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/users/1.json", - json={"user": {"id": 1, "email": "x@y.io"}}, - ) - result = GetUserOutput.model_validate( - await get_user.ainvoke(_args(user_id=1)) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_list_locales(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/locales.json", - json={"locales": [{"locale": "en-US", "id": 1}]}, - ) - result = ListLocalesOutput.model_validate( - await list_locales.ainvoke(_args()) - ) - assert result.success is True - assert result.count == 1 - - -@pytest.mark.asyncio -async def test_list_macros(httpx_mock: Any) -> None: - import re - - httpx_mock.add_response( - method="GET", - url=re.compile(rf"{API}/macros\.json\?.*"), - json={"macros": [{"id": 1, "title": "Greeting"}]}, - ) - result = ListMacrosOutput.model_validate( - await list_macros.ainvoke(_args(active=True)) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_get_macro(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/macros/1.json", - json={"macro": {"id": 1, "title": "Greeting"}}, - ) - result = GetMacroOutput.model_validate( - await get_macro.ainvoke(_args(macro_id=1)) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_list_articles_with_locale(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/help_center/en-us/articles.json?per_page=25", - json={"articles": [{"id": 1, "title": "FAQ"}]}, - ) - result = ListArticlesOutput.model_validate( - await list_articles.ainvoke(_args(locale="en-us")) - ) - assert result.success is True - - -@pytest.mark.asyncio -async def test_get_article(httpx_mock: Any) -> None: - httpx_mock.add_response( - method="GET", - url=f"{API}/help_center/articles/1.json", - json={"article": {"id": 1, "title": "FAQ"}}, - ) - result = GetArticleOutput.model_validate( - await get_article.ainvoke(_args(article_id=1)) - ) - assert result.success is True diff --git a/src/modulex_integrations/tools/zendesk/tools.py b/src/modulex_integrations/tools/zendesk/tools.py deleted file mode 100644 index fa73017..0000000 --- a/src/modulex_integrations/tools/zendesk/tools.py +++ /dev/null @@ -1,722 +0,0 @@ -"""Zendesk LangChain ``@tool`` functions. - -Pure HTTP integration against the Zendesk v2 REST API. **Triple- -credential pattern**: every action accepts ``subdomain``, ``email``, -``api_key`` as separate parameters — together they form a Basic Auth -header (`{email}/token:{api_key}` base64-encoded). - -17 actions across ticket CRUD + tags + comments, custom fields, users, -locales, macros, and help-center articles. All actions wrap in -try/except → unified ``success=False`` envelope. -""" -from __future__ import annotations - -import base64 -from typing import Any - -import httpx -from langchain_core.tools import tool -from pydantic import BaseModel, Field - -from modulex_integrations import serialize_pydantic_return -from modulex_integrations.tools.zendesk.outputs import ( - AddTicketTagsOutput, - CreateTicketOutput, - DeleteTicketOutput, - GetArticleOutput, - GetMacroOutput, - GetTicketOutput, - GetUserOutput, - ListArticlesOutput, - ListLocalesOutput, - ListMacrosOutput, - ListTicketCommentsOutput, - ListTicketsOutput, - RemoveTicketTagsOutput, - SearchTicketsOutput, - SetCustomFieldsOutput, - SetTicketTagsOutput, - UpdateTicketOutput, -) - -__all__ = [ - "add_ticket_tags", - "create_ticket", - "delete_ticket", - "get_article", - "get_macro", - "get_ticket", - "get_user", - "list_articles", - "list_locales", - "list_macros", - "list_ticket_comments", - "list_tickets", - "remove_ticket_tags", - "search_tickets", - "set_custom_fields", - "set_ticket_tags", - "update_ticket", -] - -_TIMEOUT = 30.0 - - -def _headers(email: str, api_key: str) -> dict[str, str]: - encoded = base64.b64encode(f"{email}/token:{api_key}".encode()).decode() - return { - "Authorization": f"Basic {encoded}", - "Content-Type": "application/json", - "Accept": "application/json", - } - - -def _base_url(subdomain: str) -> str: - return f"https://{subdomain}.zendesk.com/api/v2" - - -def _api_err(status: int, body: str) -> str: - return f"API error {status}: {body}" - - -async def _call( - method: str, - subdomain: str, - email: str, - api_key: str, - path: str, - *, - json_body: dict[str, Any] | None = None, - params: dict[str, Any] | None = None, - success_codes: tuple[int, ...] = (200,), -) -> tuple[bool, str | None, dict[str, Any]]: - try: - async with httpx.AsyncClient(timeout=_TIMEOUT) as client: - response = await client.request( - method, - f"{_base_url(subdomain)}{path}", - headers=_headers(email, api_key), - json=json_body, - params=params, - ) - if response.status_code not in success_codes: - return False, _api_err(response.status_code, response.text), {} - # 204 No Content → empty body - if response.status_code == 204: - return True, None, {} - return True, None, response.json() or {} - except httpx.TimeoutException: - return False, "Request timed out", {} - except Exception as exc: - return False, str(exc), {} - - -# --- Input schemas --------------------------------------------------------- - - -class _CredFields(BaseModel): - subdomain: str = Field(description="Zendesk subdomain") - email: str = Field(description="Zendesk user email") - api_key: str = Field(description="Zendesk API token") - - -class CreateTicketInput(_CredFields): - subject: str = Field(description="Ticket subject") - comment_body: str = Field(description="Initial comment body") - priority: str | None = None - status: str | None = None - requester_email: str | None = None - assignee_id: int | None = None - tags: list[str] | None = None - custom_fields: list[dict[str, Any]] | None = None - - -class UpdateTicketInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - subject: str | None = None - comment_body: str | None = None - comment_public: bool | None = True - priority: str | None = None - status: str | None = None - assignee_id: int | None = None - tags: list[str] | None = None - - -class DeleteTicketInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - - -class GetTicketInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - - -class ListTicketsInput(_CredFields): - sort_by: str | None = None - sort_order: str | None = "desc" - per_page: int | None = 25 - - -class SearchTicketsInput(_CredFields): - query: str = Field(description="Search query") - sort_by: str | None = None - sort_order: str | None = "desc" - per_page: int | None = 25 - - -class _TicketTagsInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - tags: list[str] = Field(description="Tags") - - -class ListTicketCommentsInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - sort_order: str | None = "asc" - per_page: int | None = 25 - - -class SetCustomFieldsInput(_CredFields): - ticket_id: int = Field(description="Ticket ID") - custom_fields: list[dict[str, Any]] = Field(description="Custom field objects") - - -class GetUserInput(_CredFields): - user_id: int = Field(description="User ID") - - -class ListLocalesInput(_CredFields): - pass - - -class ListMacrosInput(_CredFields): - access: str | None = None - active: bool | None = None - category: int | None = None - group_id: int | None = None - sort_by: str | None = None - sort_order: str | None = "asc" - per_page: int | None = 25 - - -class GetMacroInput(_CredFields): - macro_id: int = Field(description="Macro ID") - - -class ListArticlesInput(_CredFields): - locale: str | None = None - category_id: int | None = None - section_id: int | None = None - per_page: int | None = 25 - - -class GetArticleInput(_CredFields): - article_id: int = Field(description="Article ID") - locale: str | None = None - - -# --- Tools — tickets ------------------------------------------------------- - - -@tool(args_schema=CreateTicketInput) -@serialize_pydantic_return -async def create_ticket( - subdomain: str, - email: str, - api_key: str, - subject: str, - comment_body: str, - priority: str | None = None, - status: str | None = None, - requester_email: str | None = None, - assignee_id: int | None = None, - tags: list[str] | None = None, - custom_fields: list[dict[str, Any]] | None = None, -) -> CreateTicketOutput: - """Create a new support ticket.""" - ticket_data: dict[str, Any] = { - "subject": subject, - "comment": {"body": comment_body}, - } - if priority: - ticket_data["priority"] = priority - if status: - ticket_data["status"] = status - if requester_email: - ticket_data["requester"] = {"email": requester_email} - if assignee_id: - ticket_data["assignee_id"] = assignee_id - if tags: - ticket_data["tags"] = tags - if custom_fields: - ticket_data["custom_fields"] = custom_fields - - ok, e, data = await _call( - "POST", - subdomain, - email, - api_key, - "/tickets.json", - json_body={"ticket": ticket_data}, - success_codes=(200, 201), - ) - if not ok: - return CreateTicketOutput(success=False, error=e) - ticket = data.get("ticket") or {} - return CreateTicketOutput( - success=True, - id=ticket.get("id"), - subject=ticket.get("subject"), - status=ticket.get("status"), - priority=ticket.get("priority"), - created_at=ticket.get("created_at"), - ticket=ticket, - ) - - -@tool(args_schema=UpdateTicketInput) -@serialize_pydantic_return -async def update_ticket( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - subject: str | None = None, - comment_body: str | None = None, - comment_public: bool | None = True, - priority: str | None = None, - status: str | None = None, - assignee_id: int | None = None, - tags: list[str] | None = None, -) -> UpdateTicketOutput: - """Update an existing ticket.""" - ticket_data: dict[str, Any] = {} - if subject: - ticket_data["subject"] = subject - if comment_body: - ticket_data["comment"] = { - "body": comment_body, - "public": comment_public, - } - if priority: - ticket_data["priority"] = priority - if status: - ticket_data["status"] = status - if assignee_id: - ticket_data["assignee_id"] = assignee_id - if tags is not None: - ticket_data["tags"] = tags - - if not ticket_data: - return UpdateTicketOutput( - success=False, error="No update parameters provided" - ) - - ok, e, data = await _call( - "PUT", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}.json", - json_body={"ticket": ticket_data}, - ) - if not ok: - return UpdateTicketOutput(success=False, error=e) - ticket = data.get("ticket") or {} - return UpdateTicketOutput( - success=True, - id=ticket.get("id"), - subject=ticket.get("subject"), - status=ticket.get("status"), - priority=ticket.get("priority"), - updated_at=ticket.get("updated_at"), - ticket=ticket, - ) - - -@tool(args_schema=DeleteTicketInput) -@serialize_pydantic_return -async def delete_ticket( - subdomain: str, email: str, api_key: str, ticket_id: int -) -> DeleteTicketOutput: - """Delete a ticket.""" - ok, e, _ = await _call( - "DELETE", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}.json", - success_codes=(200, 204), - ) - if not ok: - return DeleteTicketOutput(success=False, error=e) - return DeleteTicketOutput(success=True, id=ticket_id, deleted=True) - - -@tool(args_schema=GetTicketInput) -@serialize_pydantic_return -async def get_ticket( - subdomain: str, email: str, api_key: str, ticket_id: int -) -> GetTicketOutput: - """Get a ticket by ID.""" - ok, e, data = await _call( - "GET", subdomain, email, api_key, f"/tickets/{ticket_id}.json" - ) - if not ok: - return GetTicketOutput(success=False, error=e) - return GetTicketOutput(success=True, result=data.get("ticket") or {}) - - -@tool(args_schema=ListTicketsInput) -@serialize_pydantic_return -async def list_tickets( - subdomain: str, - email: str, - api_key: str, - sort_by: str | None = None, - sort_order: str | None = "desc", - per_page: int | None = 25, -) -> ListTicketsOutput: - """List tickets with optional sorting.""" - params: dict[str, Any] = {"per_page": min(per_page or 25, 100)} - if sort_by: - params["sort_by"] = sort_by - if sort_order: - params["sort_order"] = sort_order - ok, e, data = await _call( - "GET", subdomain, email, api_key, "/tickets.json", params=params - ) - if not ok: - return ListTicketsOutput(success=False, error=e) - tickets = data.get("tickets") or [] - return ListTicketsOutput( - success=True, - tickets=tickets, - count=len(tickets), - next_page=data.get("next_page"), - previous_page=data.get("previous_page"), - ) - - -@tool(args_schema=SearchTicketsInput) -@serialize_pydantic_return -async def search_tickets( - subdomain: str, - email: str, - api_key: str, - query: str, - sort_by: str | None = None, - sort_order: str | None = "desc", - per_page: int | None = 25, -) -> SearchTicketsOutput: - """Search tickets using Zendesk search syntax.""" - params: dict[str, Any] = { - "query": query, - "per_page": min(per_page or 25, 100), - } - if sort_by: - params["sort_by"] = sort_by - if sort_order: - params["sort_order"] = sort_order - ok, e, data = await _call( - "GET", subdomain, email, api_key, "/search.json", params=params - ) - if not ok: - return SearchTicketsOutput(success=False, error=e) - results = data.get("results") or [] - return SearchTicketsOutput( - success=True, - results=results, - count=data.get("count", len(results)), - next_page=data.get("next_page"), - previous_page=data.get("previous_page"), - ) - - -# --- Tools — ticket tags --------------------------------------------------- - - -@tool(args_schema=_TicketTagsInput) -@serialize_pydantic_return -async def add_ticket_tags( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - tags: list[str], -) -> AddTicketTagsOutput: - """Append tags to a ticket (additive).""" - ok, e, data = await _call( - "PUT", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}/tags.json", - json_body={"tags": tags}, - ) - if not ok: - return AddTicketTagsOutput(success=False, error=e) - return AddTicketTagsOutput( - success=True, - ticket_id=ticket_id, - tags=data.get("tags") or [], - added_count=len(tags), - ) - - -@tool(args_schema=_TicketTagsInput) -@serialize_pydantic_return -async def set_ticket_tags( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - tags: list[str], -) -> SetTicketTagsOutput: - """Replace all tags on a ticket.""" - ok, e, data = await _call( - "POST", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}/tags.json", - json_body={"tags": tags}, - ) - if not ok: - return SetTicketTagsOutput(success=False, error=e) - return SetTicketTagsOutput( - success=True, - ticket_id=ticket_id, - tags=data.get("tags") or [], - ) - - -@tool(args_schema=_TicketTagsInput) -@serialize_pydantic_return -async def remove_ticket_tags( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - tags: list[str], -) -> RemoveTicketTagsOutput: - """Remove specific tags from a ticket.""" - ok, e, data = await _call( - "DELETE", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}/tags.json", - json_body={"tags": tags}, - ) - if not ok: - return RemoveTicketTagsOutput(success=False, error=e) - return RemoveTicketTagsOutput( - success=True, - ticket_id=ticket_id, - tags=data.get("tags") or [], - removed_count=len(tags), - ) - - -@tool(args_schema=ListTicketCommentsInput) -@serialize_pydantic_return -async def list_ticket_comments( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - sort_order: str | None = "asc", - per_page: int | None = 25, -) -> ListTicketCommentsOutput: - """List comments on a ticket.""" - params: dict[str, Any] = {"per_page": min(per_page or 25, 100)} - if sort_order: - params["sort_order"] = sort_order - ok, e, data = await _call( - "GET", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}/comments.json", - params=params, - ) - if not ok: - return ListTicketCommentsOutput(success=False, error=e) - comments = data.get("comments") or [] - return ListTicketCommentsOutput( - success=True, - ticket_id=ticket_id, - comments=comments, - count=len(comments), - next_page=data.get("next_page"), - ) - - -@tool(args_schema=SetCustomFieldsInput) -@serialize_pydantic_return -async def set_custom_fields( - subdomain: str, - email: str, - api_key: str, - ticket_id: int, - custom_fields: list[dict[str, Any]], -) -> SetCustomFieldsOutput: - """Set custom field values on a ticket.""" - ok, e, data = await _call( - "PUT", - subdomain, - email, - api_key, - f"/tickets/{ticket_id}.json", - json_body={"ticket": {"custom_fields": custom_fields}}, - ) - if not ok: - return SetCustomFieldsOutput(success=False, error=e) - ticket = data.get("ticket") or {} - return SetCustomFieldsOutput( - success=True, - id=ticket.get("id", ticket_id), - custom_fields=ticket.get("custom_fields") or custom_fields, - ) - - -# --- Tools — users / locales / macros / articles -------------------------- - - -@tool(args_schema=GetUserInput) -@serialize_pydantic_return -async def get_user( - subdomain: str, email: str, api_key: str, user_id: int -) -> GetUserOutput: - """Get a Zendesk user by ID.""" - ok, e, data = await _call( - "GET", subdomain, email, api_key, f"/users/{user_id}.json" - ) - if not ok: - return GetUserOutput(success=False, error=e) - return GetUserOutput(success=True, result=data.get("user") or {}) - - -@tool(args_schema=ListLocalesInput) -@serialize_pydantic_return -async def list_locales( - subdomain: str, email: str, api_key: str -) -> ListLocalesOutput: - """List supported locales.""" - ok, e, data = await _call("GET", subdomain, email, api_key, "/locales.json") - if not ok: - return ListLocalesOutput(success=False, error=e) - locales = data.get("locales") or [] - return ListLocalesOutput(success=True, locales=locales, count=len(locales)) - - -@tool(args_schema=ListMacrosInput) -@serialize_pydantic_return -async def list_macros( - subdomain: str, - email: str, - api_key: str, - access: str | None = None, - active: bool | None = None, - category: int | None = None, - group_id: int | None = None, - sort_by: str | None = None, - sort_order: str | None = "asc", - per_page: int | None = 25, -) -> ListMacrosOutput: - """List macros with filtering / sorting.""" - params: dict[str, Any] = {"per_page": min(per_page or 25, 100)} - if access: - params["access"] = access - if active is not None: - params["active"] = "true" if active else "false" - if category is not None: - params["category"] = category - if group_id is not None: - params["group_id"] = group_id - if sort_by: - params["sort_by"] = sort_by - if sort_order: - params["sort_order"] = sort_order - ok, e, data = await _call( - "GET", subdomain, email, api_key, "/macros.json", params=params - ) - if not ok: - return ListMacrosOutput(success=False, error=e) - macros = data.get("macros") or [] - return ListMacrosOutput( - success=True, - macros=macros, - count=len(macros), - next_page=data.get("next_page"), - ) - - -@tool(args_schema=GetMacroInput) -@serialize_pydantic_return -async def get_macro( - subdomain: str, email: str, api_key: str, macro_id: int -) -> GetMacroOutput: - """Get a macro by ID.""" - ok, e, data = await _call( - "GET", subdomain, email, api_key, f"/macros/{macro_id}.json" - ) - if not ok: - return GetMacroOutput(success=False, error=e) - return GetMacroOutput(success=True, result=data.get("macro") or {}) - - -@tool(args_schema=ListArticlesInput) -@serialize_pydantic_return -async def list_articles( - subdomain: str, - email: str, - api_key: str, - locale: str | None = None, - category_id: int | None = None, - section_id: int | None = None, - per_page: int | None = 25, -) -> ListArticlesOutput: - """List help-center articles.""" - base_path = ( - f"/help_center/{locale}/articles.json" - if locale - else "/help_center/articles.json" - ) - params: dict[str, Any] = {"per_page": min(per_page or 25, 100)} - if category_id is not None: - params["category"] = category_id - if section_id is not None: - params["section"] = section_id - ok, e, data = await _call( - "GET", subdomain, email, api_key, base_path, params=params - ) - if not ok: - return ListArticlesOutput(success=False, error=e) - articles = data.get("articles") or [] - return ListArticlesOutput( - success=True, - articles=articles, - count=len(articles), - next_page=data.get("next_page"), - ) - - -@tool(args_schema=GetArticleInput) -@serialize_pydantic_return -async def get_article( - subdomain: str, - email: str, - api_key: str, - article_id: int, - locale: str | None = None, -) -> GetArticleOutput: - """Get a help-center article by ID.""" - path = ( - f"/help_center/{locale}/articles/{article_id}.json" - if locale - else f"/help_center/articles/{article_id}.json" - ) - ok, e, data = await _call("GET", subdomain, email, api_key, path) - if not ok: - return GetArticleOutput(success=False, error=e) - return GetArticleOutput(success=True, result=data.get("article") or {})