From 105cc7a085ffd4de30d6204c7b6159dd710ecc31 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Sat, 25 Jul 2026 20:14:00 -0700 Subject: [PATCH 01/12] feat(nodes): add tool_pipedrive CRM tool node Exposes the Pipedrive REST API v1 to agents: 256 tools across 24 resource groups (deals, persons, organizations, activities, pipelines, stages, notes, leads, products, fields, files, users, roles, permission sets, teams, goals, filters, webhooks, subscriptions, mailbox, call logs, projects, misc), plus a generic `request` tool that reaches any remaining v1 endpoint through the same auth, rate-limit and read-only layer. Full coverage is more tools than an LLM can choose between reliably, so `pipedrive.toolGroups` controls which groups are published. The default eight core CRM groups publish 108 tools; `["all"]` publishes everything. Filtering happens in `IInstance._collect_tool_methods()`, so a gated tool is invisible to `tool.query` and refused by `tool.invoke` alike. Client details specific to Pipedrive: - `{"success", "data", "additional_data"}` envelope is unwrapped; `success: false` on an HTTP 200 is raised as an error. - `x-ratelimit-reset` is the seconds remaining in the window, not an epoch timestamp as in the GitHub client this otherwise mirrors. - Offset pagination (`start`/`limit`, max 500) everywhere except projects, which use cursor pagination. - Personal API tokens go in the `api_token` query parameter; JWT, long or `Bearer `-prefixed values go in an Authorization header. - Custom fields are grouped under `custom_fields` on read and written through the `extra` passthrough that every create and update tool accepts. Tests: 81 unit tests that need no credentials or network, plus an env-gated live suite behind PIPEDRIVE_API_TOKEN, with record-creating tests additionally behind PIPEDRIVE_ALLOW_WRITES. Also corrects the stale node/service counts in docs/README-nodes.md (88 -> 118 directories, 118 -> 148 services). Closes #1675 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 + docs/README-nodes.md | 3 +- nodes/src/nodes/tool_pipedrive/IGlobal.py | 92 ++ nodes/src/nodes/tool_pipedrive/IInstance.py | 184 ++++ nodes/src/nodes/tool_pipedrive/README.md | 513 ++++++++++ nodes/src/nodes/tool_pipedrive/__init__.py | 38 + nodes/src/nodes/tool_pipedrive/pipedrive.svg | 4 + .../nodes/tool_pipedrive/pipedrive_client.py | 898 ++++++++++++++++++ .../src/nodes/tool_pipedrive/requirements.txt | 2 + nodes/src/nodes/tool_pipedrive/services.json | 83 ++ nodes/src/nodes/tool_pipedrive/tool_groups.py | 118 +++ .../nodes/tool_pipedrive/tools/__init__.py | 108 +++ nodes/src/nodes/tool_pipedrive/tools/_base.py | 213 +++++ .../nodes/tool_pipedrive/tools/activities.py | 288 ++++++ .../nodes/tool_pipedrive/tools/call_logs.py | 147 +++ nodes/src/nodes/tool_pipedrive/tools/deals.py | 581 +++++++++++ .../src/nodes/tool_pipedrive/tools/fields.py | 170 ++++ nodes/src/nodes/tool_pipedrive/tools/files.py | 190 ++++ .../src/nodes/tool_pipedrive/tools/filters.py | 143 +++ nodes/src/nodes/tool_pipedrive/tools/goals.py | 151 +++ nodes/src/nodes/tool_pipedrive/tools/leads.py | 252 +++++ .../src/nodes/tool_pipedrive/tools/mailbox.py | 124 +++ nodes/src/nodes/tool_pipedrive/tools/misc.py | 179 ++++ nodes/src/nodes/tool_pipedrive/tools/notes.py | 221 +++++ .../tool_pipedrive/tools/organizations.py | 403 ++++++++ .../src/nodes/tool_pipedrive/tools/persons.py | 350 +++++++ .../nodes/tool_pipedrive/tools/pipelines.py | 306 ++++++ .../nodes/tool_pipedrive/tools/products.py | 325 +++++++ .../nodes/tool_pipedrive/tools/projects.py | 395 ++++++++ nodes/src/nodes/tool_pipedrive/tools/roles.py | 247 +++++ .../src/nodes/tool_pipedrive/tools/search.py | 171 ++++ .../tool_pipedrive/tools/subscriptions.py | 207 ++++ nodes/src/nodes/tool_pipedrive/tools/teams.py | 173 ++++ nodes/src/nodes/tool_pipedrive/tools/users.py | 187 ++++ .../nodes/tool_pipedrive/tools/webhooks.py | 115 +++ nodes/test/tool_pipedrive/__init__.py | 0 nodes/test/tool_pipedrive/test_pipedrive.py | 747 +++++++++++++++ nodes/test/tool_pipedrive/test_tools.py | 485 ++++++++++ 38 files changed, 8816 insertions(+), 1 deletion(-) create mode 100644 nodes/src/nodes/tool_pipedrive/IGlobal.py create mode 100644 nodes/src/nodes/tool_pipedrive/IInstance.py create mode 100644 nodes/src/nodes/tool_pipedrive/README.md create mode 100644 nodes/src/nodes/tool_pipedrive/__init__.py create mode 100644 nodes/src/nodes/tool_pipedrive/pipedrive.svg create mode 100644 nodes/src/nodes/tool_pipedrive/pipedrive_client.py create mode 100644 nodes/src/nodes/tool_pipedrive/requirements.txt create mode 100644 nodes/src/nodes/tool_pipedrive/services.json create mode 100644 nodes/src/nodes/tool_pipedrive/tool_groups.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/__init__.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/_base.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/activities.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/call_logs.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/deals.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/fields.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/files.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/filters.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/goals.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/leads.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/mailbox.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/misc.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/notes.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/organizations.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/persons.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/pipelines.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/products.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/projects.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/roles.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/search.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/subscriptions.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/teams.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/users.py create mode 100644 nodes/src/nodes/tool_pipedrive/tools/webhooks.py create mode 100644 nodes/test/tool_pipedrive/__init__.py create mode 100644 nodes/test/tool_pipedrive/test_pipedrive.py create mode 100644 nodes/test/tool_pipedrive/test_tools.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7ffe6dc..6826c5c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased]: since 2026-06-08 +### Added + +- **`tool_pipedrive` node**: exposes the Pipedrive CRM REST API v1 to agents — deals, persons, organizations, activities, pipelines, stages, notes, leads, products, fields, files, users, roles, permission sets, teams, goals, filters, webhooks, subscriptions, mail threads, call logs and projects. 256 tools across 24 resource groups, plus a generic `request` tool that reaches any remaining v1 endpoint through the same auth, rate-limit and read-only layer. Because the full surface is larger than an LLM can choose between reliably, `pipedrive.toolGroups` controls which groups are published (default: the eight core CRM groups, 108 tools; `all` publishes everything). Supports personal API tokens and OAuth bearer tokens, company-domain base URLs, offset and cursor pagination, custom fields via an `extra` passthrough, and read-only mode (#1675) + ## [3.3.0] - 2026-06-08 ### ⚠ Breaking Changes: Client SDKs (`rocketride` / `rocketride-python`) diff --git a/docs/README-nodes.md b/docs/README-nodes.md index 5153b42f4..8447fcd2e 100644 --- a/docs/README-nodes.md +++ b/docs/README-nodes.md @@ -11,7 +11,7 @@ each expose multiple variants), which is why the catalog below lists **services* rather than directories. > This catalog is compiled by hand from the `services*.json` definitions on `develop` -> (88 node directories → 118 services). For node testing, see +> (118 node directories → 148 services). For node testing, see > [README-node-testing.md](README-node-testing.md). --- @@ -109,6 +109,7 @@ channel; they have no data lanes and **bind to an agent** (see | `tool_firecrawl` | Firecrawl web-scraping operations | | `tool_http_request` | Arbitrary HTTP requests, "curl for agents" | | `tool_github` | GitHub repository operations | +| `tool_pipedrive` | Pipedrive CRM operations: deals, persons, organizations, activities, and the rest of API v1 | | `tool_git` | Local Git repository operations | | `tool_filesystem` | File-system access | | `tool_python` | Executes Python in a restricted in-process sandbox | diff --git a/nodes/src/nodes/tool_pipedrive/IGlobal.py b/nodes/src/nodes/tool_pipedrive/IGlobal.py new file mode 100644 index 000000000..ba193eff9 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/IGlobal.py @@ -0,0 +1,92 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Pipedrive tool node - global (shared) state. + +Reads the API token, company domain, read-only flag, published tool groups, +and the raw-request switch from config. +""" + +from __future__ import annotations + +from ai.common.config import Config +from rocketlib import IGlobalBase, OPEN_MODE, warning + +from .pipedrive_client import BASE_URL, base_url_for +from .tool_groups import ALL_GROUPS, DEFAULT_GROUPS, normalize_groups + + +class IGlobal(IGlobalBase): + """Global state for tool_pipedrive.""" + + token: str = '' + company_domain: str = '' + base_url: str = BASE_URL + read_only: bool = False + tool_groups: frozenset = DEFAULT_GROUPS + allow_raw_request: bool = True + + def beginGlobal(self) -> None: + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + self.token = str((cfg.get('apiToken') or '')).strip() + self.company_domain = str((cfg.get('companyDomain') or '')).strip() + self.base_url = base_url_for(self.company_domain) + self.read_only = bool(cfg.get('readOnly', False)) + self.tool_groups = normalize_groups(cfg.get('toolGroups')) + self.allow_raw_request = bool(cfg.get('allowRawRequest', True)) + + if not self.token: + raise Exception('tool_pipedrive: apiToken is required') + + def validateConfig(self) -> None: + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + if not str((cfg.get('apiToken') or '')).strip(): + warning('apiToken is required') + unknown = _unknown_groups(cfg.get('toolGroups')) + if unknown: + warning(f'unknown tool group(s): {", ".join(unknown)}') + except Exception as e: + warning(str(e)) + + def endGlobal(self) -> None: + self.token = '' + self.company_domain = '' + self.base_url = BASE_URL + self.read_only = False + self.tool_groups = DEFAULT_GROUPS + self.allow_raw_request = True + + +def _unknown_groups(raw) -> list[str]: + """Group names in the config that this node does not implement.""" + if not isinstance(raw, (list, tuple)): + return [] + known = ALL_GROUPS | {'all'} + return sorted({str(g).strip() for g in raw if str(g).strip() and str(g).strip() not in known}) diff --git a/nodes/src/nodes/tool_pipedrive/IInstance.py b/nodes/src/nodes/tool_pipedrive/IInstance.py new file mode 100644 index 000000000..be6d9d92e --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/IInstance.py @@ -0,0 +1,184 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Pipedrive tool node instance. + +Composes one mixin per Pipedrive resource group, publishes only the groups the +operator enabled, and adds the generic ``request`` escape hatch that reaches any +v1 endpoint the typed tools do not model. +""" + +from __future__ import annotations + +from typing import Callable + +from rocketlib import IInstanceBase, tool_function + +from ai.common.utils import normalize_tool_input, require_str + +from .IGlobal import IGlobal +from .tool_groups import RAW_REQUEST_TOOL +from .tools import ( + ActivitiesMixin, + CallLogsMixin, + DealsMixin, + FieldsMixin, + FilesMixin, + FiltersMixin, + GoalsMixin, + LeadsMixin, + MailboxMixin, + MiscMixin, + NotesMixin, + OrganizationsMixin, + PersonsMixin, + PipelinesMixin, + ProductsMixin, + ProjectsMixin, + RolesMixin, + SearchMixin, + SubscriptionsMixin, + TeamsMixin, + UsersMixin, + WebhooksMixin, +) + +_SAFE_METHODS = {'GET', 'HEAD'} +_ALLOWED_METHODS = {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'} + + +class IInstance( + DealsMixin, + PersonsMixin, + OrganizationsMixin, + ActivitiesMixin, + PipelinesMixin, + NotesMixin, + SearchMixin, + LeadsMixin, + ProductsMixin, + FieldsMixin, + FilesMixin, + UsersMixin, + RolesMixin, + TeamsMixin, + GoalsMixin, + FiltersMixin, + WebhooksMixin, + SubscriptionsMixin, + MailboxMixin, + CallLogsMixin, + ProjectsMixin, + MiscMixin, + IInstanceBase, +): + IGlobal: IGlobal + + # ----------------------------------------------------------------------- + # Tool publication + # ----------------------------------------------------------------------- + + def _collect_tool_methods(self) -> dict[str, Callable]: + """Publish only the tools whose group the operator enabled. + + The base implementation returns every ``@tool_function`` on the class, + which for full Pipedrive coverage is far more than an agent can choose + between. Filtering here covers both ``tool.query`` (the agent never sees + a disabled tool) and ``tool.invoke`` (calling one anyway is refused). + """ + methods = super()._collect_tool_methods() + enabled = self.IGlobal.tool_groups + allow_raw = self.IGlobal.allow_raw_request + + published: dict[str, Callable] = {} + for name, method in methods.items(): + if name == RAW_REQUEST_TOOL: + if allow_raw: + published[name] = method + continue + group = getattr(getattr(type(self), name, None), '__pipedrive_group__', None) + if group is None or group in enabled: + published[name] = method + return published + + # ----------------------------------------------------------------------- + # Escape hatch + # ----------------------------------------------------------------------- + + @tool_function( + input_schema={ + 'type': 'object', + 'required': ['method', 'path'], + 'properties': { + 'method': { + 'type': 'string', + 'enum': sorted(_ALLOWED_METHODS), + 'description': 'HTTP method. In read-only mode only GET and HEAD are permitted.', + }, + 'path': { + 'type': 'string', + 'description': ( + 'Pipedrive API v1 path, relative to the API root and starting with a slash — ' + 'for example "/goals", "/deals/123/followers" or "/organizationRelationships". ' + 'Do not include the host or the /api/v1 prefix.' + ), + }, + 'params': { + 'type': 'object', + 'description': 'Query-string parameters. The api_token is added automatically.', + }, + 'body': {'type': 'object', 'description': 'JSON request body for POST, PUT and PATCH.'}, + }, + }, + description=( + 'Call any Pipedrive REST API v1 endpoint directly. Use this only for endpoints that have no ' + 'dedicated tool here — the typed tools validate their inputs and return compact results, this ' + 'one returns the raw API payload. Authentication, rate-limit retries and read-only enforcement ' + 'still apply. See https://developers.pipedrive.com/docs/api/v1 for the endpoint reference.' + ), + ) + def request(self, args): + """Call any Pipedrive v1 endpoint directly.""" + args = normalize_tool_input(args, tool_name='tool_pipedrive') + method = require_str(args, 'method', tool_name='request').upper() + path = require_str(args, 'path', tool_name='request') + + if method not in _ALLOWED_METHODS: + raise ValueError(f'request: method must be one of {", ".join(sorted(_ALLOWED_METHODS))}') + if method not in _SAFE_METHODS: + self._require_write() + + if '://' in path: + raise ValueError('request: "path" must be a path such as "/deals", not a full URL') + + params = args.get('params') + body = args.get('body') + if params is not None and not isinstance(params, dict): + raise ValueError('request: "params" must be an object') + if body is not None and not isinstance(body, dict): + raise ValueError('request: "body" must be an object') + + return self._call_envelope(method, path, params=params, body=body) diff --git a/nodes/src/nodes/tool_pipedrive/README.md b/nodes/src/nodes/tool_pipedrive/README.md new file mode 100644 index 000000000..e122a821a --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/README.md @@ -0,0 +1,513 @@ +# tool_pipedrive + +A RocketRide tool node that exposes the Pipedrive CRM to an AI agent. + +## What it does + +Gives an agent the full Pipedrive REST API v1: deals, persons, organizations, +activities, pipelines, stages, notes, leads, products, files, users, roles, teams, +goals, filters, webhooks, subscriptions, mail, call logs and projects — 256 tools in +all, plus a generic `request` tool for anything Pipedrive adds later. Useful for +sales automation, lead triage, CRM reporting, and RAG pipelines that read from a CRM. + +Uses the **requests** library against `https://api.pipedrive.com/api/v1` (or your +company domain) with a 30-second timeout, and **tenacity** to retry Pipedrive's +rate limits. Responses are unwrapped from Pipedrive's `{"success", "data", +"additional_data"}` envelope and stripped of noise — the wide `*_flat` duplicates, +picture blobs and raw custom-field hashes are dropped, and custom fields are grouped +under a `custom_fields` key. + +An API token is **required**: the pipeline fails to start without one. Write +operations are **allowed by default**; enable **read-only mode** to block every +mutating tool. + +--- + +## Configuration + +| Field | Type | Description | +|---|---|---| +| `apiToken` | string | Default empty. Pipedrive API token (Settings -> Personal preferences -> API), or an OAuth access token. Stored encrypted. | +| `companyDomain` | string | Default empty. The "acme" in `https://acme.pipedrive.com`. When set, requests go to `https://{domain}.pipedrive.com/api/v1`; otherwise `https://api.pipedrive.com/api/v1`. | +| `readOnly` | boolean | Default false. When enabled, every create, update and delete tool is blocked and `request` only accepts GET. | +| `toolGroups` | array | Default `["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"]`. Which groups of tools to publish. Use `["all"]` for everything. | +| `allowRawRequest` | boolean | Default true. Publishes the generic `request` tool. | + +### Tool groups + +Full v1 coverage is 256 tools. That is more than an LLM can choose between reliably, +and more than some providers accept in one request, so the node only publishes the +groups listed in **Tool groups**. The default eight groups publish 108 tools — the +everyday CRM surface. Add group names to reach further, or use `all`. + +Available groups: + +`activities`, `call_logs`, `deals`, `fields`, `files`, `filters`, `goals`, `leads`, +`mailbox`, `misc`, `notes`, `org_relationships`, `organizations`, `permission_sets`, +`persons`, `pipelines`, `products`, `projects`, `roles`, `search`, `stages`, +`subscriptions`, `teams`, `users`, `webhooks`. + +A tool in a group that is not published is invisible to the agent and refused if +invoked anyway. + +### Pagination + +List tools take `start` (offset, default 0) and `limit` (1-500, default 100), and +return `{items, count, more_items_in_collection, next_start}`. Pass `next_start` back +as `start` for the next page. The project tools use Pipedrive's cursor pagination +instead: pass `cursor` and read `next_cursor`. + +### Custom fields + +Pipedrive stores custom fields under 40-character hex keys. Use `field_list` (group +`fields`) to discover them, then write them through the `extra` object that every +create and update tool accepts: + +```json +{ "title": "New deal", "extra": { "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678": "Gold" } } +``` + +`extra` is merged into the request body after the typed parameters, so it also +reaches any API parameter this node does not model explicitly. + +--- + +## Authentication + +Personal API tokens are sent as the `api_token` query parameter; OAuth access tokens +(JWTs, anything longer than 64 characters, or a value prefixed with `Bearer `) are +sent as an `Authorization: Bearer` header. Both go in the same **API Token** field. + +## Rate limits + +Pipedrive uses a daily token budget plus a short burst window, and answers `429` when +either is exhausted (escalating to `403` if a client keeps hammering). The client +retries up to three times, honouring `Retry-After` first and then `x-ratelimit-reset` +— which, unlike GitHub's header of a similar name, is **seconds remaining in the +window, not an epoch timestamp**. If the required wait is longer than the 30-second +request timeout the call fails immediately with the wait time in the error message +rather than blocking the pipeline. + +## Read-only mode + +With **Read-only mode** enabled, every `*_create`, `*_update`, `*_delete`, `*_add`, +`*_merge`, `*_set` and bulk-delete tool raises before making a request, and `request` +accepts only `GET` and `HEAD`. Listing, getting, searching and reporting tools are +unaffected. + +--- + +## Available tools + +Tools are published as `pipedrive.`. + +#### `activities` — 13 tools + +| Tool | Description | +|---|---| +| `activity_create` | Create an activity (call, meeting, task, ...) and optionally link it to a deal, person or organization. | +| `activity_delete` | Delete an activity. | +| `activity_delete_bulk` | Delete multiple activities in one call. | +| `activity_field_list` | List the activity fields, including custom fields and their 40-character keys. | +| `activity_get` | Get a single activity by id. | +| `activity_list` | List activities, optionally filtered by owner, type, completion state or due-date range. | +| `activity_mark_done` | Mark an activity as done (or reopen it with done="0"). | +| `activity_type_create` | Create a custom activity type. | +| `activity_type_delete` | Delete an activity type. | +| `activity_type_delete_bulk` | Delete multiple activity types in one call. | +| `activity_type_list` | List the activity types configured in this Pipedrive account, with the key to pass as activity "type". | +| `activity_type_update` | Update an activity type. | +| `activity_update` | Update an activity. Pass done=1 to mark it complete. | + +#### `call_logs` — 5 tools + +| Tool | Description | +|---|---| +| `call_log_create` | Log a phone call. | +| `call_log_delete` | Delete a call log. | +| `call_log_get` | Get a single call log. | +| `call_log_list` | List the call logs of the authenticated user. | +| `call_log_recording_add` | Attach an audio recording to a call log. | + +#### `deals` — 28 tools + +| Tool | Description | +|---|---| +| `deal_activities_list` | List activities associated with a deal. | +| `deal_create` | Create a deal. | +| `deal_delete` | Delete a deal. Pipedrive keeps it recoverable for 30 days. | +| `deal_delete_bulk` | Delete multiple deals in one call. | +| `deal_duplicate` | Duplicate a deal. | +| `deal_files_list` | List files attached to a deal. | +| `deal_follower_add` | Add a follower to a deal. | +| `deal_follower_delete` | Remove a follower from a deal. | +| `deal_followers_list` | List followers of a deal. | +| `deal_followers_users_list` | List users who follow a deal, resolved to full user records. | +| `deal_get` | Get a single deal by id, including its custom fields. | +| `deal_list` | List deals, optionally filtered by owner, stage, status or a saved filter. | +| `deal_mail_messages_list` | List email messages associated with a deal. | +| `deal_merge` | Merge one deal into another. The source deal is removed. | +| `deal_participant_add` | Add a person as a participant of a deal. | +| `deal_participant_delete` | Remove a participant from a deal. | +| `deal_participants_list` | List participants (persons) of a deal. | +| `deal_permitted_users_list` | List users who have permission to see or edit a deal. | +| `deal_persons_list` | List persons linked to a deal, either directly or through its organization. | +| `deal_product_add` | Attach a product to a deal. | +| `deal_product_delete` | Detach a product from a deal. | +| `deal_product_update` | Update a product already attached to a deal. | +| `deal_products_list` | List products attached to a deal, with their quantities and prices. | +| `deal_search` | Search deals by title, notes or custom field values. | +| `deal_summary` | Get a summary of deals: total count and value, grouped by currency. | +| `deal_timeline` | Get deals grouped into time periods, with per-period totals. Useful for pipeline trend questions. | +| `deal_update` | Update a deal. Only the fields you pass are changed. | +| `deal_updates_list` | Get the update history (flow) of a deal: field changes, notes, activities and emails. | + +#### `fields` — 6 tools + +| Tool | Description | +|---|---| +| `field_create` | Create a custom field on deals, persons, organizations or products. | +| `field_delete` | Delete a custom field. The data stored in it is lost. | +| `field_delete_bulk` | Delete multiple custom fields in one call. | +| `field_get` | Get a single field definition, including its options for enum and set fields. | +| `field_list` | List the fields of deals, persons, organizations or products, including custom fields and the 40-character keys used to read and write them. | +| `field_update` | Update a custom field. Field type cannot be changed after creation. | + +#### `files` — 8 tools + +| Tool | Description | +|---|---| +| `file_create` | Upload a file and attach it to a deal, person, organization, product, activity or lead. | +| `file_create_remote` | Create a new empty remote document (Google Drive) and attach it to a record. | +| `file_delete` | Delete a file. | +| `file_download` | Download a file. Returns base64 content by default, or decoded text with as_text="1". | +| `file_get` | Get the metadata of a single file. | +| `file_link_remote` | Link an existing remote file (Google Drive) to a record. | +| `file_list` | List files stored in Pipedrive. | +| `file_update` | Rename a file or change its description. | + +#### `filters` — 7 tools + +| Tool | Description | +|---|---| +| `filter_create` | Create a saved filter. | +| `filter_delete` | Delete a saved filter. | +| `filter_delete_bulk` | Delete multiple saved filters in one call. | +| `filter_get` | Get a single filter, including its condition tree. | +| `filter_helpers_get` | Get the field ids, operators and value formats that filter conditions accept. Call this before building a filter. | +| `filter_list` | List saved filters. Pass a returned filter id as filter_id to the list tools to reuse a filter the user already built in the UI. | +| `filter_update` | Update a saved filter. | + +#### `goals` — 5 tools + +| Tool | Description | +|---|---| +| `goal_create` | Create a goal. | +| `goal_delete` | Delete a goal. | +| `goal_find` | Find goals by title, assignee, type or period. | +| `goal_results_get` | Get the progress of a goal over a period: how much of the target has been reached. | +| `goal_update` | Update a goal. | + +#### `leads` — 12 tools + +| Tool | Description | +|---|---| +| `lead_create` | Create a lead. Link it to a person or an organization (at least one is required). | +| `lead_delete` | Delete a lead. | +| `lead_get` | Get a single lead by its uuid. | +| `lead_label_create` | Create a lead label. | +| `lead_label_delete` | Delete a lead label. | +| `lead_label_list` | List the lead labels configured in this account, with the uuids to pass as label_ids. | +| `lead_label_update` | Update a lead label. | +| `lead_list` | List leads from the Leads Inbox. | +| `lead_permitted_users_list` | List users who have permission to see or edit a lead. | +| `lead_search` | Search leads by title, notes or custom field values. | +| `lead_source_list` | List the lead sources available in this account. | +| `lead_update` | Update a lead. Only the fields you pass are changed. | + +#### `mailbox` — 6 tools + +| Tool | Description | +|---|---| +| `mail_message_get` | Get a single mail message, optionally with its body. | +| `mail_thread_delete` | Delete a mail thread. | +| `mail_thread_get` | Get a single mail thread. | +| `mail_thread_list` | List mail threads in a mailbox folder. | +| `mail_thread_messages_list` | List the messages in a mail thread. | +| `mail_thread_update` | Link a mail thread to a deal or lead, or change its shared, read and archived flags. | + +#### `misc` — 8 tools + +| Tool | Description | +|---|---| +| `billing_addons_list` | List the billing add-ons the company has subscribed to. | +| `channel_conversation_delete` | Delete a conversation from a messaging channel. | +| `channel_create` | Register a messaging channel so an external inbox can appear in Pipedrive. | +| `channel_delete` | Delete a messaging channel. | +| `channel_message_receive` | Deliver an inbound message from an external messaging provider into a Pipedrive channel. | +| `currency_list` | List the currencies supported by the account, with their ids and decimal precision. | +| `meeting_link_create` | Link a Pipedrive user to a video-calling provider so meeting links can be generated. | +| `meeting_link_delete` | Remove the link between a Pipedrive user and a video-calling provider. | + +#### `notes` — 11 tools + +| Tool | Description | +|---|---| +| `note_comment_create` | Add a comment to a note. | +| `note_comment_delete` | Delete a comment from a note. | +| `note_comment_get` | Get a single comment on a note. | +| `note_comment_list` | List comments on a note. | +| `note_comment_update` | Update a comment on a note. | +| `note_create` | Create a note. Attach it by passing at least one of deal_id, person_id, org_id, lead_id or project_id. | +| `note_delete` | Delete a note. | +| `note_field_list` | List the note fields available in this account. | +| `note_get` | Get a single note by id. | +| `note_list` | List notes, optionally filtered by the record they are attached to or by date. | +| `note_update` | Update a note. | + +#### `org_relationships` — 5 tools + +| Tool | Description | +|---|---| +| `org_relationship_create` | Create a relationship between two organizations. | +| `org_relationship_delete` | Delete an organization relationship. | +| `org_relationship_get` | Get a single organization relationship. | +| `org_relationship_list` | List parent/child relationships of an organization. | +| `org_relationship_update` | Update an organization relationship. | + +#### `organizations` — 18 tools + +| Tool | Description | +|---|---| +| `organization_activities_list` | List activities associated with an organization. | +| `organization_create` | Create an organization. | +| `organization_deals_list` | List deals associated with an organization. | +| `organization_delete` | Delete an organization. | +| `organization_delete_bulk` | Delete multiple organizations in one call. | +| `organization_files_list` | List files attached to an organization. | +| `organization_follower_add` | Add a follower to an organization. | +| `organization_follower_delete` | Remove a follower from an organization. | +| `organization_followers_list` | List followers of an organization. | +| `organization_get` | Get a single organization by id, including its custom fields. | +| `organization_list` | List organizations (companies), optionally filtered by owner, saved filter or first letter. | +| `organization_mail_messages_list` | List email messages associated with an organization. | +| `organization_merge` | Merge one organization into another. The source organization is removed. | +| `organization_permitted_users_list` | List users who have permission to see or edit an organization. | +| `organization_persons_list` | List persons that belong to an organization. | +| `organization_search` | Search organizations by name, address, notes or custom field values. | +| `organization_update` | Update an organization. Only the fields you pass are changed. | +| `organization_updates_list` | Get the update history (flow) of an organization. | + +#### `permission_sets` — 3 tools + +| Tool | Description | +|---|---| +| `permission_set_assignments_list` | List the users assigned to a permission set. | +| `permission_set_get` | Get a single permission set. | +| `permission_set_list` | List the permission sets available in the account. | + +#### `persons` — 18 tools + +| Tool | Description | +|---|---| +| `person_activities_list` | List activities associated with a person. | +| `person_create` | Create a person (contact). | +| `person_deals_list` | List deals associated with a person. | +| `person_delete` | Delete a person. | +| `person_delete_bulk` | Delete multiple persons in one call. | +| `person_files_list` | List files attached to a person. | +| `person_follower_add` | Add a follower to a person. | +| `person_follower_delete` | Remove a follower from a person. | +| `person_followers_list` | List followers of a person. | +| `person_get` | Get a single person by id, including emails, phones and custom fields. | +| `person_list` | List persons (contacts), optionally filtered by owner, saved filter or first letter. | +| `person_mail_messages_list` | List email messages associated with a person. | +| `person_merge` | Merge one person into another. The source person is removed. | +| `person_permitted_users_list` | List users who have permission to see or edit a person. | +| `person_products_list` | List products associated with a person through their deals. | +| `person_search` | Search persons by name, email, phone, notes or custom field values. | +| `person_update` | Update a person. Only the fields you pass are changed; passing email or phone replaces the whole list. | +| `person_updates_list` | Get the update history (flow) of a person. | + +#### `pipelines` — 8 tools + +| Tool | Description | +|---|---| +| `pipeline_conversion_stats` | Get stage-to-stage conversion rates and win/lost rates for a pipeline over a date range. | +| `pipeline_create` | Create a pipeline. | +| `pipeline_deals_list` | List deals in a pipeline. | +| `pipeline_delete` | Delete a pipeline. | +| `pipeline_get` | Get a single pipeline, including per-stage deal totals. | +| `pipeline_list` | List all pipelines. Start here to find the pipeline_id and stage layout of the account. | +| `pipeline_movement_stats` | Get how deals moved into, through and out of each stage of a pipeline over a date range. | +| `pipeline_update` | Update a pipeline. | + +#### `products` — 16 tools + +| Tool | Description | +|---|---| +| `product_create` | Create a product in the catalogue. | +| `product_deals_list` | List deals a product is attached to. | +| `product_delete` | Delete a product from the catalogue. | +| `product_files_list` | List files attached to a product. | +| `product_follower_add` | Add a follower to a product. | +| `product_follower_delete` | Remove a follower from a product. | +| `product_followers_list` | List followers of a product. | +| `product_get` | Get a single product by id, including its prices. | +| `product_list` | List products from the catalogue. | +| `product_permitted_users_list` | List users who have permission to see or edit a product. | +| `product_search` | Search products by name, code or custom field values. | +| `product_update` | Update a product. | +| `product_variation_create` | Create a product variation. | +| `product_variation_delete` | Delete a product variation. | +| `product_variation_list` | List the variations of a product. | +| `product_variation_update` | Update a product variation. | + +#### `projects` — 22 tools + +| Tool | Description | +|---|---| +| `project_activities_list` | List the activities of a project. | +| `project_archive` | Archive a project. | +| `project_board_get` | Get a single project board. | +| `project_board_list` | List project boards. Board ids are needed to create a project. | +| `project_create` | Create a project. | +| `project_delete` | Delete a project. | +| `project_get` | Get a single project. | +| `project_groups_list` | List the groups of a project. | +| `project_list` | List projects. | +| `project_phase_get` | Get a single project phase. | +| `project_phase_list` | List the phases of a project board. Phase ids are needed to create a project. | +| `project_plan_activity_update` | Move an activity to another phase or group within a project plan. | +| `project_plan_get` | Get the plan of a project: its tasks and activities with their scheduled dates. | +| `project_plan_task_update` | Move a task to another phase or group within a project plan. | +| `project_task_create` | Create a project task. | +| `project_task_delete` | Delete a project task. | +| `project_task_get` | Get a single project task. | +| `project_task_list` | List project tasks. | +| `project_task_update` | Update a project task. Pass done=1 to complete it. | +| `project_template_get` | Get a single project template. | +| `project_template_list` | List project templates. | +| `project_update` | Update a project. | + +#### `request` — 1 tools + +| Tool | Description | +|---|---| +| `request` | Call any Pipedrive REST API v1 endpoint directly. Use this only for endpoints that have no dedicated tool here — the typed tools validate their inputs and return compact results, this one returns the raw API payload. Authentication, rate-limit retries and read-only enforcement still apply. See https://developers.pipedrive.com/docs/api/v1 for the endpoint reference. | + +#### `roles` — 13 tools + +| Tool | Description | +|---|---| +| `role_assignment_add` | Assign a user to a role. | +| `role_assignment_delete` | Remove a user from a role. | +| `role_assignments_list` | List the users assigned to a role. | +| `role_create` | Create a role. | +| `role_delete` | Delete a role. | +| `role_get` | Get a single role. | +| `role_list` | List the roles configured in the account. | +| `role_pipelines_list` | List which pipelines a role can see. | +| `role_pipelines_set` | Set which pipelines a role can see. | +| `role_setting_set` | Change one visibility setting of a role. | +| `role_settings_get` | Get the visibility settings of a role. | +| `role_sub_roles_list` | List the sub-roles of a role. | +| `role_update` | Update a role. | + +#### `search` — 4 tools + +| Tool | Description | +|---|---| +| `item_search` | Search across every Pipedrive item type at once — deals, persons, organizations, products, leads, files and projects. Use this when you do not know which record type holds the answer. | +| `item_search_by_field` | Search a single field for a value — for example find every deal whose custom "Contract number" field starts with a string. Returns distinct field values by default. | +| `lookup` | Quick global lookup that returns just the id, type and name of each match. Cheaper than item_search when you only need to resolve a name to an id. | +| `recents_list` | List every record created or changed since a timestamp. Use this to sync a CRM snapshot incrementally instead of re-listing everything. | + +#### `stages` — 7 tools + +| Tool | Description | +|---|---| +| `stage_create` | Create a stage in a pipeline. | +| `stage_deals_list` | List deals sitting in a stage. | +| `stage_delete` | Delete a stage. | +| `stage_delete_bulk` | Delete multiple stages in one call. | +| `stage_get` | Get a single stage. | +| `stage_list` | List stages, optionally restricted to a single pipeline. | +| `stage_update` | Update a stage. | + +#### `subscriptions` — 9 tools + +| Tool | Description | +|---|---| +| `subscription_delete` | Delete a subscription and detach it from its deal. | +| `subscription_find_by_deal` | Find the subscription attached to a deal. | +| `subscription_get` | Get a single revenue subscription. | +| `subscription_installment_create` | Create an installment subscription on a deal. | +| `subscription_installment_update` | Replace the payment schedule of an installment subscription. | +| `subscription_payments_list` | List the payments of a subscription. | +| `subscription_recurring_cancel` | Cancel a recurring subscription. | +| `subscription_recurring_create` | Create a recurring revenue subscription on a deal. | +| `subscription_recurring_update` | Update a recurring subscription from a given date onward. | + +#### `teams` — 8 tools + +| Tool | Description | +|---|---| +| `team_create` | Create a team. | +| `team_get` | Get a single team. | +| `team_list` | List all teams. | +| `team_list_for_user` | List the teams a user belongs to. | +| `team_update` | Update a team. | +| `team_user_add` | Add users to a team. | +| `team_user_delete` | Remove users from a team. | +| `team_users_list` | List the user ids that belong to a team. | + +#### `users` — 12 tools + +| Tool | Description | +|---|---| +| `user_connections_list` | List the third-party accounts (Google, Microsoft, ...) connected to the authenticated user. | +| `user_create` | Invite a new user to the Pipedrive account. | +| `user_find` | Find users by name or email. | +| `user_followers_list` | List the followers of a user. | +| `user_get` | Get a single user by id. | +| `user_list` | List all users in the Pipedrive account. Use this to resolve a user name to the user_id needed by owner filters. | +| `user_me` | Get the user the API token belongs to, including the company id, domain and default currency. | +| `user_permissions_get` | List the effective permissions of a user (what they can see, add, edit and delete). | +| `user_role_assignments_list` | List the role assignments of a user. | +| `user_role_settings_get` | Get the role settings that apply to a user. | +| `user_settings_get` | Get the authenticated user settings: timezone, currency, date format and feature flags. | +| `user_update` | Activate or deactivate a user. | + +#### `webhooks` — 3 tools + +| Tool | Description | +|---|---| +| `webhook_create` | Create a webhook subscription. | +| `webhook_delete` | Delete a webhook subscription. | +| `webhook_list` | List the webhooks registered by this API token. | + +--- + +## Running the tests + +```bash +# Unit tests — no credentials, no network +python -m pytest nodes/test/tool_pipedrive/test_pipedrive.py -v + +# Live suite — reads only +export PIPEDRIVE_API_TOKEN= +export PIPEDRIVE_COMPANY_DOMAIN= # optional +python -m pytest nodes/test/tool_pipedrive/test_tools.py -v + +# Live suite including tests that create and delete records +export PIPEDRIVE_ALLOW_WRITES=1 +python -m pytest nodes/test/tool_pipedrive/test_tools.py -v +``` + +The live suite writes to a real Pipedrive account. Point it at a sandbox. + +--- + + + diff --git a/nodes/src/nodes/tool_pipedrive/__init__.py b/nodes/src/nodes/tool_pipedrive/__init__.py new file mode 100644 index 000000000..934a632a4 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/__init__.py @@ -0,0 +1,38 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Pipedrive CRM node for RocketRide Engine. + +Exposes the Pipedrive REST API v1 as agent tools: deals, persons, +organizations, activities, pipelines, notes, leads, products, files, users, +projects and the rest of the v1 surface, surfaced as @tool_function +decorators on IInstance. +""" + +from .IGlobal import IGlobal as IGlobal +from .IInstance import IInstance as IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive.svg b/nodes/src/nodes/tool_pipedrive/pipedrive.svg new file mode 100644 index 000000000..989aa04df --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/pipedrive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py new file mode 100644 index 000000000..ab0b0a9bb --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py @@ -0,0 +1,898 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Pipedrive REST API v1 client. + +Thin wrapper around requests — handles auth, the Pipedrive response envelope, +rate-limit retries, error parsing, and response normalisation. All tool methods +in IInstance call through here. + +Pipedrive wraps every response in ``{"success": bool, "data": ..., "additional_data": +{"pagination": {...}}}``; :func:`call` unwraps that to ``data`` and :func:`call_envelope` +returns the whole thing for callers that need pagination or ``related_objects``. +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +import requests +from tenacity import RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential + +BASE_URL = 'https://api.pipedrive.com/api/v1' +DEFAULT_TIMEOUT = 30 + +#: Pipedrive's offset pagination caps ``limit`` at 500. +MAX_LIMIT = 500 + +#: Custom fields come back keyed by a 40-char hex field id mixed in with the +#: standard keys; the cleaners move them into a ``custom_fields`` sub-dict. +_CUSTOM_FIELD_KEY_RE = re.compile(r'^[0-9a-f]{40}$') + + +class PipedriveAPIError(ValueError): + """Raised when the Pipedrive API returns an error response.""" + + def __init__(self, status_code: int, message: str, code: str = ''): + super().__init__(f'Pipedrive API {status_code}: {message}') + self.status_code = status_code + self.message = message + self.code = code + + +class RateLimitError(Exception): + """Raised when Pipedrive signals a rate limit (429, or 403 after sustained 429s).""" + + def __init__(self, response: requests.Response): + self.response = response + + +# --------------------------------------------------------------------------- +# Headers +# --------------------------------------------------------------------------- + + +def _hdr(headers: Any, name: str) -> str | None: + """Case-insensitive header lookup. + + ``requests`` returns a CaseInsensitiveDict, but tests (and the odd caller) + pass a plain dict, so try the common casings before giving up. + """ + if not headers: + return None + for candidate in (name, name.lower(), name.upper(), name.title()): + value = headers.get(candidate) + if value is not None: + return value + return None + + +def _rate_limit_hint(headers: Any) -> str: + """Human-readable retry hint from rate-limit headers, or '' if none present.""" + retry_after = _hdr(headers, 'Retry-After') + if retry_after: + return f'retry after {retry_after}s' + if _hdr(headers, 'x-daily-requests-left') == '0': + return 'daily API token budget exhausted; it resets at midnight Pipedrive server time' + reset = _hdr(headers, 'x-ratelimit-reset') + if reset: + return f'rate limit window resets in ~{reset}s' + return '' + + +def _is_rate_limited(resp: requests.Response) -> bool: + """Whether a failed response is a rate limit rather than a real error. + + 429 is the documented signal. Pipedrive escalates to 403 when a client keeps + hammering through 429s, so treat a 403 that still carries rate-limit markers + as a rate limit too — a plain 403 (no permission) must not be retried. + """ + if resp.status_code == 429: + return True + if resp.status_code != 403: + return False + if _hdr(resp.headers, 'Retry-After') is not None: + return True + if _hdr(resp.headers, 'x-ratelimit-remaining') == '0': + return True + return 'rate limit' in (resp.text or '').lower() + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + + +def _use_bearer(token: str) -> bool: + """Whether the configured credential is an OAuth access token. + + Personal API tokens are short opaque hex strings and go in the ``api_token`` + query parameter; OAuth access tokens are JWTs (or simply much longer) and go + in an ``Authorization: Bearer`` header. An explicit ``Bearer `` prefix wins. + """ + token = token.strip() + if token.lower().startswith('bearer '): + return True + if token.count('.') == 2: + return True + return len(token) > 64 + + +def _auth(token: str) -> tuple[dict, dict]: + """Return ``(headers, params)`` carrying the credential.""" + token = token.strip() + if _use_bearer(token): + if token.lower().startswith('bearer '): + token = token[7:].strip() + return {'Authorization': f'Bearer {token}'}, {} + return {}, {'api_token': token} + + +def base_url_for(company_domain: str | None) -> str: + """Build the API base URL for an optional company domain. + + Accepts ``acme``, ``acme.pipedrive.com`` or ``https://acme.pipedrive.com/`` + and normalises all three to ``https://acme.pipedrive.com/api/v1``. + """ + domain = (company_domain or '').strip() + if not domain: + return BASE_URL + domain = re.sub(r'^https?://', '', domain).strip('/') + domain = domain.split('/')[0] + if domain.endswith('.pipedrive.com'): + domain = domain[: -len('.pipedrive.com')] + if not domain: + return BASE_URL + return f'https://{domain}.pipedrive.com/api/v1' + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +def _raise_pipedrive_error(resp: requests.Response) -> None: + """Parse a Pipedrive error response and raise PipedriveAPIError.""" + code = '' + try: + err = resp.json() + except Exception: + err = None + if isinstance(err, dict): + msg = err.get('error') or resp.text or resp.reason or 'unknown error' + info = err.get('error_info') + if info and info not in msg: + msg = f'{msg} — {info}' + code = err.get('code') or '' + if code: + msg = f'{msg} [{code}]' + else: + msg = resp.text or resp.reason or 'unknown error' + hint = _rate_limit_hint(resp.headers) + if hint: + msg = f'{msg} ({hint})' + raise PipedriveAPIError(resp.status_code, msg, code) + + +# --------------------------------------------------------------------------- +# Transport +# --------------------------------------------------------------------------- + + +def call_envelope( + token: str, + method: str, + path: str, + *, + base_url: str = BASE_URL, + params: dict | None = None, + body: dict | None = None, + files: dict | None = None, + form: dict | None = None, + raw: bool = False, + timeout: int = DEFAULT_TIMEOUT, +) -> Any: + """Make an authenticated Pipedrive API call and return the full response envelope. + + Returns the parsed ``{"success": ..., "data": ..., "additional_data": ...}`` dict. + With ``raw=True`` the undecoded response body is returned instead (file downloads). + Raises :class:`PipedriveAPIError` with a human-readable message on HTTP errors. + """ + auth_headers, auth_params = _auth(token) + headers = {'Accept': 'application/json', **auth_headers} + + query = {k: v for k, v in (params or {}).items() if v is not None} + query.update(auth_params) + + url = base_url.rstrip('/') + '/' + path.lstrip('/') + + def _attempt() -> Any: + try: + resp = requests.request( + method.upper(), + url, + headers=headers, + params=query, + json=body if files is None and form is None else None, + data=form, + files=files, + timeout=timeout, + ) + except requests.RequestException as exc: + raise ValueError(f'Pipedrive request failed: {exc}') from exc + + if not resp.ok: + if _is_rate_limited(resp): + raise RateLimitError(resp) + _raise_pipedrive_error(resp) + + if raw: + return resp.content + + if resp.status_code == 204 or not (resp.content or b'').strip(): + return {'success': True, 'data': {}} + + try: + payload = resp.json() + except ValueError as exc: + raise PipedriveAPIError(resp.status_code, f'response was not JSON: {resp.text[:200]}') from exc + + # A few endpoints answer 200 with success=false rather than an HTTP error. + if isinstance(payload, dict) and payload.get('success') is False: + _raise_pipedrive_error(resp) + + return payload + + def pipedrive_rate_limit_wait(retry_state: RetryCallState) -> float: + exc = retry_state.outcome.exception() + if isinstance(exc, RateLimitError): + hdrs = exc.response.headers + + def _check_and_cap(w: float) -> float: + if not math.isfinite(w) or w < 0: + raise ValueError() + if w > timeout: + # Fail fast if the server requires waiting longer than the local cap + raise exc + return w + + # 1. Retry-After states exactly how many seconds to wait. + retry_after = _hdr(hdrs, 'Retry-After') + if retry_after is not None: + try: + return _check_and_cap(float(retry_after)) + except ValueError: + pass + + # 2. x-ratelimit-reset is the seconds REMAINING in the current window + # (not an epoch timestamp, unlike GitHub's X-RateLimit-Reset). + reset = _hdr(hdrs, 'x-ratelimit-reset') + if reset is not None: + try: + return _check_and_cap(max(1.0, float(reset))) + except ValueError: + pass + return float(wait_exponential(multiplier=2, min=2, max=timeout)(retry_state)) + + try: + return Retrying( + stop=stop_after_attempt(3), + wait=pipedrive_rate_limit_wait, + retry=retry_if_exception_type(RateLimitError), + reraise=True, + )(_attempt) + except RateLimitError as exc: + _raise_pipedrive_error(exc.response) + + +def call(token: str, method: str, path: str, **kwargs: Any) -> Any: + """Make a Pipedrive API call and return the unwrapped ``data`` payload. + + ``data`` is ``null`` for some successful responses (e.g. deletes that report + only through ``success``); those come back as an empty dict. + """ + envelope = call_envelope(token, method, path, **kwargs) + if kwargs.get('raw'): + return envelope + if not isinstance(envelope, dict): + return envelope + data = envelope.get('data') + return {} if data is None else data + + +def paginated(envelope: Any, items: list) -> dict: + """Wrap a cleaned list plus Pipedrive's pagination cursor for the agent.""" + pagination = {} + if isinstance(envelope, dict): + pagination = ((envelope.get('additional_data') or {}).get('pagination')) or {} + return { + 'items': items, + 'count': len(items), + 'more_items_in_collection': bool(pagination.get('more_items_in_collection', False)), + 'next_start': pagination.get('next_start'), + } + + +# --------------------------------------------------------------------------- +# Response cleaners — Pipedrive payloads are very wide (every custom field, plus +# *_flat duplicates and picture blobs), so trim them to what an agent can use. +# --------------------------------------------------------------------------- + + +def _pick(obj: Any, keys: tuple[str, ...]) -> dict: + if not isinstance(obj, dict): + return {} + return {k: obj[k] for k in keys if k in obj} + + +def split_custom_fields(obj: dict) -> dict: + """Move 40-char-hex custom field keys into a ``custom_fields`` sub-dict.""" + custom = {k: v for k, v in obj.items() if _CUSTOM_FIELD_KEY_RE.match(k)} + return custom + + +def _ref(value: Any, name_key: str = 'name') -> Any: + """Flatten Pipedrive's ``{"value": id, "name": "..."}`` reference objects.""" + if isinstance(value, dict): + out = {'id': value.get('value', value.get('id'))} + if name_key in value: + out[name_key] = value[name_key] + if 'email' in value: + out['email'] = value['email'] + return out + return value + + +def clean_deal(d: Any) -> dict: + if not isinstance(d, dict): + return {} + out = _pick( + d, + ( + 'id', + 'title', + 'value', + 'currency', + 'status', + 'probability', + 'stage_id', + 'pipeline_id', + 'expected_close_date', + 'won_time', + 'lost_time', + 'lost_reason', + 'close_time', + 'add_time', + 'update_time', + 'stage_change_time', + 'next_activity_date', + 'next_activity_subject', + 'activities_count', + 'done_activities_count', + 'undone_activities_count', + 'email_messages_count', + 'notes_count', + 'files_count', + 'products_count', + 'weighted_value', + 'label', + 'visible_to', + 'active', + 'deleted', + ), + ) + out['person'] = _ref(d.get('person_id')) + out['organization'] = _ref(d.get('org_id')) + out['owner'] = _ref(d.get('user_id')) + out['creator'] = _ref(d.get('creator_user_id')) + custom = split_custom_fields(d) + if custom: + out['custom_fields'] = custom + return out + + +def clean_person(p: Any) -> dict: + if not isinstance(p, dict): + return {} + out = _pick( + p, + ( + 'id', + 'name', + 'first_name', + 'last_name', + 'label', + 'add_time', + 'update_time', + 'open_deals_count', + 'closed_deals_count', + 'won_deals_count', + 'lost_deals_count', + 'activities_count', + 'notes_count', + 'files_count', + 'last_activity_date', + 'next_activity_date', + 'visible_to', + 'active_flag', + ), + ) + out['emails'] = [e.get('value') for e in (p.get('email') or []) if isinstance(e, dict)] + out['phones'] = [e.get('value') for e in (p.get('phone') or []) if isinstance(e, dict)] + out['organization'] = _ref(p.get('org_id')) + out['owner'] = _ref(p.get('owner_id')) + custom = split_custom_fields(p) + if custom: + out['custom_fields'] = custom + return out + + +def clean_organization(o: Any) -> dict: + if not isinstance(o, dict): + return {} + out = _pick( + o, + ( + 'id', + 'name', + 'address', + 'address_formatted_address', + 'label', + 'add_time', + 'update_time', + 'people_count', + 'open_deals_count', + 'closed_deals_count', + 'won_deals_count', + 'lost_deals_count', + 'activities_count', + 'notes_count', + 'files_count', + 'last_activity_date', + 'next_activity_date', + 'visible_to', + 'active_flag', + 'cc_email', + ), + ) + out['owner'] = _ref(o.get('owner_id')) + custom = split_custom_fields(o) + if custom: + out['custom_fields'] = custom + return out + + +def clean_activity(a: Any) -> dict: + if not isinstance(a, dict): + return {} + out = _pick( + a, + ( + 'id', + 'subject', + 'type', + 'due_date', + 'due_time', + 'duration', + 'done', + 'busy_flag', + 'note', + 'public_description', + 'location', + 'add_time', + 'update_time', + 'marked_as_done_time', + 'deal_id', + 'person_id', + 'org_id', + 'lead_id', + 'project_id', + 'user_id', + 'owner_name', + 'person_name', + 'org_name', + 'deal_title', + 'active_flag', + ), + ) + if a.get('participants'): + out['participants'] = a['participants'] + if a.get('attendees'): + out['attendees'] = a['attendees'] + return out + + +def clean_pipeline(p: Any) -> dict: + return _pick(p, ('id', 'name', 'order_nr', 'active', 'deal_probability', 'add_time', 'update_time', 'selected')) + + +def clean_stage(s: Any) -> dict: + return _pick( + s, + ( + 'id', + 'name', + 'order_nr', + 'pipeline_id', + 'pipeline_name', + 'deal_probability', + 'rotten_flag', + 'rotten_days', + 'active_flag', + 'add_time', + 'update_time', + ), + ) + + +def clean_note(n: Any) -> dict: + if not isinstance(n, dict): + return {} + out = _pick( + n, + ( + 'id', + 'content', + 'add_time', + 'update_time', + 'deal_id', + 'person_id', + 'org_id', + 'lead_id', + 'project_id', + 'user_id', + 'pinned_to_deal_flag', + 'pinned_to_person_flag', + 'pinned_to_organization_flag', + 'pinned_to_lead_flag', + 'active_flag', + ), + ) + if isinstance(n.get('user'), dict): + out['user'] = _pick(n['user'], ('id', 'name', 'email')) + return out + + +def clean_lead(lead: Any) -> dict: + if not isinstance(lead, dict): + return {} + out = _pick( + lead, + ( + 'id', + 'title', + 'owner_id', + 'creator_id', + 'label_ids', + 'person_id', + 'organization_id', + 'source_name', + 'origin', + 'channel', + 'is_archived', + 'was_seen', + 'expected_close_date', + 'next_activity_id', + 'add_time', + 'update_time', + 'visible_to', + 'cc_email', + ), + ) + if isinstance(lead.get('value'), dict): + out['value'] = lead['value'] + return out + + +def clean_product(p: Any) -> dict: + if not isinstance(p, dict): + return {} + out = _pick( + p, + ( + 'id', + 'name', + 'code', + 'description', + 'unit', + 'tax', + 'category', + 'active_flag', + 'selectable', + 'visible_to', + 'owner_id', + 'add_time', + 'update_time', + 'billing_frequency', + 'billing_frequency_cycles', + ), + ) + if p.get('prices'): + out['prices'] = [_pick(pr, ('id', 'price', 'currency', 'cost', 'overhead_cost')) for pr in p['prices']] + custom = split_custom_fields(p) + if custom: + out['custom_fields'] = custom + return out + + +def clean_file(f: Any) -> dict: + return _pick( + f, + ( + 'id', + 'name', + 'file_name', + 'file_type', + 'file_size', + 'description', + 'url', + 'remote_location', + 'remote_id', + 'deal_id', + 'person_id', + 'org_id', + 'lead_id', + 'product_id', + 'activity_id', + 'add_time', + 'update_time', + 'active_flag', + ), + ) + + +def clean_user(u: Any) -> dict: + return _pick( + u, + ( + 'id', + 'name', + 'email', + 'active_flag', + 'is_admin', + 'role_id', + 'timezone_name', + 'locale', + 'lang', + 'created', + 'modified', + 'last_login', + ), + ) + + +def clean_field(f: Any) -> dict: + if not isinstance(f, dict): + return {} + out = _pick( + f, + ( + 'id', + 'key', + 'name', + 'field_type', + 'mandatory_flag', + 'edit_flag', + 'active_flag', + 'add_visible_flag', + 'important_flag', + 'bulk_edit_allowed', + 'searchable_flag', + 'add_time', + 'update_time', + ), + ) + if f.get('options'): + out['options'] = f['options'] + return out + + +def clean_search_item(item: Any) -> dict: + """Search results wrap the record in ``{"result_score": n, "item": {...}}``.""" + if not isinstance(item, dict): + return {} + inner = item.get('item') if isinstance(item.get('item'), dict) else item + out = _pick(inner, ('id', 'type', 'name', 'title', 'status', 'value', 'currency', 'visible_to')) + for key in ('person', 'organization', 'owner', 'phones', 'emails', 'custom_fields'): + if inner.get(key): + out[key] = inner[key] + if 'result_score' in item: + out['result_score'] = item['result_score'] + return out + + +def clean_project(p: Any) -> dict: + return _pick( + p, + ( + 'id', + 'title', + 'board_id', + 'phase_id', + 'description', + 'status', + 'owner_id', + 'start_date', + 'end_date', + 'deal_ids', + 'org_id', + 'person_id', + 'labels', + 'add_time', + 'update_time', + 'archive_time', + ), + ) + + +def clean_task(t: Any) -> dict: + return _pick( + t, + ( + 'id', + 'title', + 'project_id', + 'parent_task_id', + 'description', + 'assignee_id', + 'creator_id', + 'done', + 'due_date', + 'add_time', + 'update_time', + 'marked_as_done_time', + ), + ) + + +def clean_mail_thread(t: Any) -> dict: + return _pick( + t, + ( + 'id', + 'subject', + 'snippet', + 'read_flag', + 'has_attachments_flag', + 'deal_id', + 'lead_id', + 'person_id', + 'org_id', + 'message_count', + 'last_message_timestamp', + 'add_time', + 'update_time', + 'parties', + ), + ) + + +def clean_mail_message(m: Any) -> dict: + return _pick( + m, + ( + 'id', + 'subject', + 'snippet', + 'body_url', + 'from', + 'to', + 'cc', + 'bcc', + 'message_time', + 'has_body_flag', + 'read_flag', + 'draft_flag', + 'sent_flag', + 'mail_thread_id', + ), + ) + + +def clean_call_log(c: Any) -> dict: + return _pick( + c, + ( + 'id', + 'subject', + 'duration', + 'outcome', + 'from_phone_number', + 'to_phone_number', + 'start_time', + 'end_time', + 'person_id', + 'org_id', + 'deal_id', + 'lead_id', + 'user_id', + 'activity_id', + 'note', + 'has_recording', + ), + ) + + +def clean_goal(g: Any) -> dict: + return _pick( + g, + ('id', 'title', 'type', 'assignee', 'interval', 'duration', 'expected_outcome', 'is_active', 'report_ids'), + ) + + +def clean_filter(f: Any) -> dict: + return _pick(f, ('id', 'name', 'type', 'active_flag', 'user_id', 'visible_to', 'add_time', 'update_time')) + + +def clean_webhook(w: Any) -> dict: + return _pick( + w, + ( + 'id', + 'subscription_url', + 'event_action', + 'event_object', + 'user_id', + 'active_flag', + 'add_time', + 'update_time', + 'last_delivery_time', + 'last_http_status', + ), + ) + + +def clean_subscription(s: Any) -> dict: + return _pick( + s, + ( + 'id', + 'deal_id', + 'currency', + 'description', + 'cadence_type', + 'cycles_count', + 'cycle_amount', + 'infinite', + 'is_active', + 'start_date', + 'end_date', + 'final_status', + 'lifetime_value', + 'add_time', + 'update_time', + ), + ) + + +def clean_team(t: Any) -> dict: + return _pick(t, ('id', 'name', 'description', 'manager_id', 'users', 'active_flag', 'add_time')) + + +def clean_role(r: Any) -> dict: + return _pick(r, ('id', 'parent_role_id', 'name', 'active_flag', 'assignment_count', 'sub_role_count', 'level')) diff --git a/nodes/src/nodes/tool_pipedrive/requirements.txt b/nodes/src/nodes/tool_pipedrive/requirements.txt new file mode 100644 index 000000000..365ee776b --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/requirements.txt @@ -0,0 +1,2 @@ +requests +tenacity diff --git a/nodes/src/nodes/tool_pipedrive/services.json b/nodes/src/nodes/tool_pipedrive/services.json new file mode 100644 index 000000000..136c7e02b --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/services.json @@ -0,0 +1,83 @@ +{ + "title": "Pipedrive", + "protocol": "tool_pipedrive://", + "classType": ["tool"], + "capabilities": ["invoke"], + "register": "filter", + "node": "python", + "path": "nodes.tool_pipedrive", + "prefix": "pipedrive", + "icon": "pipedrive.svg", + "documentation": "https://docs.rocketride.org", + "description": ["Exposes the Pipedrive CRM REST API v1 as agent tools.", "Covers deals, persons, organizations, activities, pipelines, stages, notes, leads,", "products, files, users, teams, goals, projects, and every other v1 resource."], + "tile": ["Pipedrive${parameters.pipedrive.companyDomain ? ': ' + parameters.pipedrive.companyDomain : ''}"], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "Pipedrive", + "apiToken": "", + "companyDomain": "", + "readOnly": false, + "toolGroups": ["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"], + "allowRawRequest": true + } + } + }, + "fields": { + "pipedrive.apiToken": { + "type": "string", + "title": "API Token", + "description": "Pipedrive API token (Settings -> Personal preferences -> API), or an OAuth access token. Tokens starting with a JWT-style prefix are sent as a Bearer header; everything else is sent as the api_token query parameter.", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + }, + "pipedrive.companyDomain": { + "type": "string", + "title": "Company Domain", + "description": "Your Pipedrive company domain, i.e. the \"acme\" in https://acme.pipedrive.com. When set, requests go to https://{domain}.pipedrive.com/api/v1; otherwise https://api.pipedrive.com/api/v1 is used.", + "default": "", + "optional": true + }, + "pipedrive.readOnly": { + "type": "boolean", + "title": "Read-only mode", + "description": "When enabled, every create, update and delete tool is blocked, and the generic request tool only accepts GET. Safe for agents that should only inspect the CRM.", + "default": false, + "enum": [ + [true, "Yes: read only"], + [false, "No: allow writes"] + ] + }, + "pipedrive.toolGroups": { + "type": "array", + "title": "Tool groups", + "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is ~230 tools, which is more than an LLM can choose between reliably, so only the listed groups are exposed. Use all to publish everything. Available groups: deals, persons, organizations, org_relationships, activities, pipelines, stages, notes, search, fields, leads, products, files, users, roles, permission_sets, teams, goals, filters, webhooks, subscriptions, mailbox, call_logs, projects, misc.", + "items": { + "type": "string" + }, + "default": ["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"] + }, + "pipedrive.allowRawRequest": { + "type": "boolean", + "title": "Allow raw API requests", + "description": "Publishes the generic request tool, which can call any Pipedrive v1 endpoint by method and path. It uses the same authentication, rate-limit handling and read-only enforcement as the typed tools. Disable to restrict the agent to the typed tools only.", + "default": true, + "enum": [ + [true, "Yes: expose the request tool"], + [false, "No: typed tools only"] + ] + } + }, + "shape": [ + { + "section": "Pipe", + "title": "Pipedrive", + "properties": ["type", "pipedrive.apiToken", "pipedrive.companyDomain", "pipedrive.readOnly", "pipedrive.toolGroups", "pipedrive.allowRawRequest"] + } + ] +} diff --git a/nodes/src/nodes/tool_pipedrive/tool_groups.py b/nodes/src/nodes/tool_pipedrive/tool_groups.py new file mode 100644 index 000000000..c18f6f224 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tool_groups.py @@ -0,0 +1,118 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Tool grouping for the Pipedrive node. + +Full v1 coverage is ~230 tools, which is far more than an LLM can choose between +reliably. Every tool is therefore tagged with a resource group, and the node only +publishes the groups named in the ``pipedrive.toolGroups`` config field. The +filtering happens in ``IInstance._collect_tool_methods()``, so a group that is not +published is invisible to ``tool.query`` and rejected by ``tool.invoke`` alike. +""" + +from __future__ import annotations + +from typing import Callable + +from rocketlib import tool_function + +#: Every group this node implements. +ALL_GROUPS = frozenset( + { + 'activities', + 'call_logs', + 'deals', + 'fields', + 'files', + 'filters', + 'goals', + 'leads', + 'mailbox', + 'misc', + 'notes', + 'org_relationships', + 'organizations', + 'permission_sets', + 'persons', + 'pipelines', + 'products', + 'projects', + 'roles', + 'search', + 'stages', + 'subscriptions', + 'teams', + 'users', + 'webhooks', + } +) + +#: Published when the operator has not chosen otherwise — the everyday CRM surface. +DEFAULT_GROUPS = frozenset( + {'deals', 'persons', 'organizations', 'activities', 'pipelines', 'stages', 'notes', 'search'} +) + +#: The generic escape-hatch tool, gated by ``pipedrive.allowRawRequest`` instead +#: of by a tool group. +RAW_REQUEST_TOOL = 'request' + + +def normalize_groups(raw) -> frozenset: + """Turn the configured ``toolGroups`` value into a set of known group names. + + Accepts a list or a comma-separated string, ignores unknown names (they are + surfaced as a warning by ``IGlobal.validateConfig``), and treats ``all`` as + every implemented group. An empty or missing value falls back to the defaults. + """ + if isinstance(raw, str): + raw = raw.split(',') + if not isinstance(raw, (list, tuple, set, frozenset)): + return DEFAULT_GROUPS + + names = {str(g).strip().lower() for g in raw if str(g).strip()} + if not names: + return DEFAULT_GROUPS + if 'all' in names or '*' in names: + return ALL_GROUPS + selected = names & ALL_GROUPS + return frozenset(selected) if selected else DEFAULT_GROUPS + + +def pipedrive_tool(*, group: str, input_schema=None, description=None) -> Callable: + """``@tool_function`` plus the resource group the tool belongs to. + + The group is stamped on the function so ``IInstance._collect_tool_methods()`` + can filter the published tool list without a separate registry to keep in sync. + """ + if group not in ALL_GROUPS: + raise ValueError(f'pipedrive_tool: unknown group "{group}"') + + def decorator(fn: Callable) -> Callable: + fn = tool_function(input_schema=input_schema, description=description)(fn) + fn.__pipedrive_group__ = group + return fn + + return decorator diff --git a/nodes/src/nodes/tool_pipedrive/tools/__init__.py b/nodes/src/nodes/tool_pipedrive/tools/__init__.py new file mode 100644 index 000000000..247846e41 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/__init__.py @@ -0,0 +1,108 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Pipedrive tool mixins, one module per resource group. + +``IInstance`` composes every mixin; which of their tools are actually published +is decided at runtime by the ``pipedrive.toolGroups`` config field. +""" + +from ._base import PipedriveToolsBase +from .activities import ActivitiesMixin +from .call_logs import CallLogsMixin +from .deals import DealsMixin +from .fields import FieldsMixin +from .files import FilesMixin +from .filters import FiltersMixin +from .goals import GoalsMixin +from .leads import LeadsMixin +from .mailbox import MailboxMixin +from .misc import MiscMixin +from .notes import NotesMixin +from .organizations import OrganizationsMixin +from .persons import PersonsMixin +from .pipelines import PipelinesMixin +from .products import ProductsMixin +from .projects import ProjectsMixin +from .roles import RolesMixin +from .search import SearchMixin +from .subscriptions import SubscriptionsMixin +from .teams import TeamsMixin +from .users import UsersMixin +from .webhooks import WebhooksMixin + +#: Order matters only for readability — the mixins do not override each other. +ALL_MIXINS = ( + DealsMixin, + PersonsMixin, + OrganizationsMixin, + ActivitiesMixin, + PipelinesMixin, + NotesMixin, + SearchMixin, + LeadsMixin, + ProductsMixin, + FieldsMixin, + FilesMixin, + UsersMixin, + RolesMixin, + TeamsMixin, + GoalsMixin, + FiltersMixin, + WebhooksMixin, + SubscriptionsMixin, + MailboxMixin, + CallLogsMixin, + ProjectsMixin, + MiscMixin, +) + +__all__ = [ + 'ActivitiesMixin', + 'ALL_MIXINS', + 'CallLogsMixin', + 'DealsMixin', + 'FieldsMixin', + 'FilesMixin', + 'FiltersMixin', + 'GoalsMixin', + 'LeadsMixin', + 'MailboxMixin', + 'MiscMixin', + 'NotesMixin', + 'OrganizationsMixin', + 'PersonsMixin', + 'PipedriveToolsBase', + 'PipelinesMixin', + 'ProductsMixin', + 'ProjectsMixin', + 'RolesMixin', + 'SearchMixin', + 'SubscriptionsMixin', + 'TeamsMixin', + 'UsersMixin', + 'WebhooksMixin', +] diff --git a/nodes/src/nodes/tool_pipedrive/tools/_base.py b/nodes/src/nodes/tool_pipedrive/tools/_base.py new file mode 100644 index 000000000..24c23983a --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/_base.py @@ -0,0 +1,213 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Shared plumbing for the Pipedrive tool mixins. + +Holds the credential/base-URL accessors, the read-only gate, the offset-pagination +helper, and the small schema builders every group module uses so the 250-plus tool +definitions stay readable. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from ai.common.utils import normalize_tool_input, require_int, require_str + +from ..pipedrive_client import MAX_LIMIT, call, call_envelope, paginated + +TOOL_NAME = 'tool_pipedrive' + +#: Appended to the description of every tool that writes custom fields. +EXTRA_DESC = ( + 'Any additional Pipedrive API fields to send verbatim, including custom fields keyed by ' + 'their 40-character field key (get those from the *_field_list tools). Merged into the ' + 'request body after the typed parameters.' +) + + +# --------------------------------------------------------------------------- +# Schema builders +# --------------------------------------------------------------------------- + + +def passthrough(value: Any) -> Any: + """Response cleaner that returns the payload untouched. + + Used for endpoints whose payload is already small, or whose shape varies + (some return a bare list of ids), where ``dict`` would raise. + """ + return value + + +def schema(*, required: Iterable[str] = (), **properties: dict) -> dict: + """Build a tool input schema from keyword properties.""" + out: dict = {'type': 'object', 'properties': properties} + required = list(required) + if required: + out['required'] = required + return out + + +def INT(description: str) -> dict: + return {'type': 'integer', 'description': description} + + +def NUM(description: str) -> dict: + return {'type': 'number', 'description': description} + + +def STR(description: str) -> dict: + return {'type': 'string', 'description': description} + + +def BOOL(description: str) -> dict: + return {'type': 'boolean', 'description': description} + + +def OBJ(description: str) -> dict: + return {'type': 'object', 'description': description} + + +def ENUM(description: str, values: Iterable[Any]) -> dict: + return {'type': 'string', 'enum': list(values), 'description': description} + + +def ARR(description: str, item_type: str = 'string') -> dict: + return {'type': 'array', 'items': {'type': item_type}, 'description': description} + + +def EXTRA() -> dict: + return OBJ(EXTRA_DESC) + + +#: Offset-pagination properties, spread into list-tool schemas. +def PAGING() -> dict: + return { + 'start': INT('Pagination offset, 0-based (default 0). Pass the next_start value from a previous call.'), + 'limit': INT(f'Number of records to return (1-{MAX_LIMIT}, default 100).'), + } + + +# --------------------------------------------------------------------------- +# Mixin base +# --------------------------------------------------------------------------- + + +class PipedriveToolsBase: + """Credential access, request helpers, and the read-only gate. + + Every group mixin inherits from this; ``IInstance`` supplies ``IGlobal``. + """ + + # -- credentials ------------------------------------------------------ + + def _token(self) -> str: + return self.IGlobal.token + + def _base(self) -> str: + return self.IGlobal.base_url + + def _require_write(self) -> None: + if self.IGlobal.read_only: + raise ValueError('This operation is not permitted: the node is configured in read-only mode') + + # -- requests --------------------------------------------------------- + + def _call(self, method: str, path: str, **kwargs: Any) -> Any: + return call(self._token(), method, path, base_url=self._base(), **kwargs) + + def _call_envelope(self, method: str, path: str, **kwargs: Any) -> Any: + return call_envelope(self._token(), method, path, base_url=self._base(), **kwargs) + + def _list(self, path: str, args: dict, cleaner, *, extra: dict | None = None) -> dict: + """GET a collection with offset pagination and return cleaned items + cursor.""" + params = paging_params(args) + if extra: + params.update(extra) + envelope = self._call_envelope('GET', path, params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + items = [cleaner(item) for item in (data or [])] + return paginated(envelope, items) + + def _get(self, path: str, cleaner, *, params: dict | None = None) -> dict: + return cleaner(self._call('GET', path, params=params)) + + def _write(self, method: str, path: str, cleaner, *, body: dict | None = None, params: dict | None = None) -> dict: + self._require_write() + return cleaner(self._call(method, path, body=body, params=params)) + + def _delete(self, path: str, *, params: dict | None = None) -> dict: + self._require_write() + data = self._call('DELETE', path, params=params) + return {'deleted': True, 'data': data} + + +# --------------------------------------------------------------------------- +# Argument helpers +# --------------------------------------------------------------------------- + + +def args_of(args: Any) -> dict: + """Normalise agent-supplied tool input to a plain dict.""" + return normalize_tool_input(args, tool_name=TOOL_NAME) + + +def paging_params(args: dict) -> dict: + """Clamp start/limit to what Pipedrive accepts.""" + params: dict = {} + if args.get('start') is not None: + params['start'] = max(0, int(args['start'])) + if args.get('limit') is not None: + params['limit'] = max(1, min(int(args['limit']), MAX_LIMIT)) + return params + + +def body_from(args: dict, keys: Iterable[str], *, extra_key: str = 'extra') -> dict: + """Collect the provided typed keys into a request body, then merge ``extra``. + + Keys absent from ``args`` are omitted entirely so a PUT never blanks a field + the agent did not mention. ``extra`` carries custom fields and any parameter + this node does not model explicitly. + """ + body = {k: args[k] for k in keys if args.get(k) is not None} + extra = args.get(extra_key) + if isinstance(extra, dict): + body.update(extra) + return body + + +def params_from(args: dict, keys: Iterable[str]) -> dict: + """Collect the provided keys into a query-parameter dict.""" + return {k: args[k] for k in keys if args.get(k) is not None} + + +def require_id(args: dict, key: str, tool: str) -> int: + return require_int(args, key, tool_name=tool) + + +def require_text(args: dict, key: str, tool: str) -> str: + return require_str(args, key, tool_name=tool) diff --git a/nodes/src/nodes/tool_pipedrive/tools/activities.py b/nodes/src/nodes/tool_pipedrive/tools/activities.py new file mode 100644 index 000000000..6a630df38 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/activities.py @@ -0,0 +1,288 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Activity tools: calls, meetings and tasks, plus activity types and fields.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_activity, clean_field +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_ACTIVITY_WRITE_KEYS = ( + 'subject', + 'type', + 'due_date', + 'due_time', + 'duration', + 'done', + 'busy_flag', + 'note', + 'public_description', + 'location', + 'user_id', + 'deal_id', + 'lead_id', + 'person_id', + 'org_id', + 'project_id', + 'participants', + 'attendees', +) + +_ACTIVITY_WRITE_PROPS = { + 'subject': STR('Activity subject line.'), + 'type': STR( + 'Activity type key, e.g. "call", "meeting", "task", "deadline", "email", "lunch". See activity_type_list for the keys configured in this account.' + ), + 'due_date': STR('Due date, YYYY-MM-DD.'), + 'due_time': STR('Due time, HH:MM.'), + 'duration': STR('Duration, HH:MM.'), + 'done': INT('0 for not done, 1 for done.'), + 'busy_flag': BOOL('Whether the activity marks the user as busy in the calendar.'), + 'note': STR('Note body (HTML is accepted).'), + 'public_description': STR('Description shown to calendar invitees.'), + 'location': STR('Location of the activity.'), + 'user_id': INT('Owner user id. Defaults to the authenticated user.'), + 'deal_id': INT('Deal this activity belongs to.'), + 'lead_id': STR('Lead uuid this activity belongs to.'), + 'person_id': INT('Person this activity belongs to.'), + 'org_id': INT('Organization this activity belongs to.'), + 'project_id': INT('Project this activity belongs to.'), + 'participants': { + 'type': 'array', + 'items': {'type': 'object'}, + 'description': 'Participants, e.g. [{"person_id": 1, "primary_flag": true}].', + }, + 'attendees': { + 'type': 'array', + 'items': {'type': 'object'}, + 'description': 'Calendar attendees, e.g. [{"email": "a@example.com"}].', + }, + 'extra': EXTRA(), +} + + +class ActivitiesMixin(PipedriveToolsBase): + """Tools for the ``activities`` group.""" + + @pipedrive_tool( + group='activities', + input_schema=schema( + **PAGING(), + user_id=INT('Only activities owned by this user id. Use 0 for all users the token can see.'), + filter_id=INT('Apply a saved filter by id (see filter_list).'), + type=STR('Comma-separated activity type keys to include, e.g. "call,meeting".'), + done=INT('0 for pending activities, 1 for completed. Omit for both.'), + start_date=STR('Only activities due on or after this date, YYYY-MM-DD.'), + end_date=STR('Only activities due on or before this date, YYYY-MM-DD.'), + ), + description='List activities, optionally filtered by owner, type, completion state or due-date range.', + ) + def activity_list(self, args): + args = args_of(args) + return self._list( + '/activities', + args, + clean_activity, + extra=params_from(args, ('user_id', 'filter_id', 'type', 'done', 'start_date', 'end_date')), + ) + + @pipedrive_tool( + group='activities', + input_schema=schema(required=['activity_id'], activity_id=INT('Activity id.')), + description='Get a single activity by id.', + ) + def activity_get(self, args): + args = args_of(args) + return self._get(f'/activities/{require_id(args, "activity_id", "activity_get")}', clean_activity) + + @pipedrive_tool( + group='activities', + input_schema=schema(required=['subject'], **_ACTIVITY_WRITE_PROPS), + description='Create an activity (call, meeting, task, ...) and optionally link it to a deal, person or organization.', + ) + def activity_create(self, args): + args = args_of(args) + require_text(args, 'subject', 'activity_create') + return self._write('POST', '/activities', clean_activity, body=body_from(args, _ACTIVITY_WRITE_KEYS)) + + @pipedrive_tool( + group='activities', + input_schema=schema( + required=['activity_id'], activity_id=INT('Activity id to update.'), **_ACTIVITY_WRITE_PROPS + ), + description='Update an activity. Pass done=1 to mark it complete.', + ) + def activity_update(self, args): + args = args_of(args) + activity_id = require_id(args, 'activity_id', 'activity_update') + return self._write( + 'PUT', f'/activities/{activity_id}', clean_activity, body=body_from(args, _ACTIVITY_WRITE_KEYS) + ) + + @pipedrive_tool( + group='activities', + input_schema=schema(required=['activity_id'], activity_id=INT('Activity id to delete.')), + description='Delete an activity.', + ) + def activity_delete(self, args): + args = args_of(args) + return self._delete(f'/activities/{require_id(args, "activity_id", "activity_delete")}') + + @pipedrive_tool( + group='activities', + input_schema=schema(ids=ARR('Activity ids to delete.', 'integer')), + description='Delete multiple activities in one call.', + ) + def activity_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('activity_delete_bulk: "ids" must be a non-empty array of activity ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/activities', params=params)} + + # -- activity types --------------------------------------------------- + + @pipedrive_tool( + group='activities', + input_schema=schema(), + description='List the activity types configured in this Pipedrive account, with the key to pass as activity "type".', + ) + def activity_type_list(self, args): + args_of(args) + data = self._call('GET', '/activityTypes') + return {'items': list(data or [])} + + @pipedrive_tool( + group='activities', + input_schema=schema( + required=['name', 'icon_key'], + name=STR('Display name of the activity type.'), + icon_key=STR('Icon key, e.g. "call", "meeting", "task", "email", "deadline", "lunch".'), + color=STR('6-character hex colour without the "#".'), + order_nr=INT('Position of the type in the list.'), + ), + description='Create a custom activity type.', + ) + def activity_type_create(self, args): + args = args_of(args) + require_text(args, 'name', 'activity_type_create') + require_text(args, 'icon_key', 'activity_type_create') + return self._write( + 'POST', '/activityTypes', passthrough, body=body_from(args, ('name', 'icon_key', 'color', 'order_nr')) + ) + + @pipedrive_tool( + group='activities', + input_schema=schema( + required=['activity_type_id'], + activity_type_id=INT('Activity type id to update.'), + name=STR('New display name.'), + icon_key=STR('New icon key.'), + color=STR('New 6-character hex colour without the "#".'), + order_nr=INT('New position in the list.'), + ), + description='Update an activity type.', + ) + def activity_type_update(self, args): + args = args_of(args) + type_id = require_id(args, 'activity_type_id', 'activity_type_update') + return self._write( + 'PUT', + f'/activityTypes/{type_id}', + passthrough, + body=body_from(args, ('name', 'icon_key', 'color', 'order_nr')), + ) + + @pipedrive_tool( + group='activities', + input_schema=schema(required=['activity_type_id'], activity_type_id=INT('Activity type id to delete.')), + description='Delete an activity type.', + ) + def activity_type_delete(self, args): + args = args_of(args) + type_id = require_id(args, 'activity_type_id', 'activity_type_delete') + return self._delete(f'/activityTypes/{type_id}') + + @pipedrive_tool( + group='activities', + input_schema=schema(ids=ARR('Activity type ids to delete.', 'integer')), + description='Delete multiple activity types in one call.', + ) + def activity_type_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('activity_type_delete_bulk: "ids" must be a non-empty array of activity type ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/activityTypes', params=params)} + + # -- activity fields -------------------------------------------------- + + @pipedrive_tool( + group='activities', + input_schema=schema(**PAGING()), + description='List the activity fields, including custom fields and their 40-character keys.', + ) + def activity_field_list(self, args): + args = args_of(args) + return self._list('/activityFields', args, clean_field) + + # -- convenience ------------------------------------------------------ + + @pipedrive_tool( + group='activities', + input_schema=schema( + required=['activity_id'], + activity_id=INT('Activity id to mark as done.'), + done=ENUM('Set to "0" to reopen the activity instead.', ['0', '1']), + ), + description='Mark an activity as done (or reopen it with done="0").', + ) + def activity_mark_done(self, args): + args = args_of(args) + activity_id = require_id(args, 'activity_id', 'activity_mark_done') + done = 0 if str(args.get('done', '1')) == '0' else 1 + return self._write('PUT', f'/activities/{activity_id}', clean_activity, body={'done': done}) diff --git a/nodes/src/nodes/tool_pipedrive/tools/call_logs.py b/nodes/src/nodes/tool_pipedrive/tools/call_logs.py new file mode 100644 index 000000000..4b0757062 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/call_logs.py @@ -0,0 +1,147 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Call log tools, including uploading a call recording.""" + +from __future__ import annotations + +import base64 + +from ..pipedrive_client import clean_call_log +from ..tool_groups import pipedrive_tool +from ._base import ( + ENUM, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + require_text, + schema, +) + +_CALL_LOG_KEYS = ( + 'subject', + 'duration', + 'outcome', + 'from_phone_number', + 'to_phone_number', + 'start_time', + 'end_time', + 'person_id', + 'org_id', + 'deal_id', + 'lead_id', + 'user_id', + 'activity_id', + 'note', +) + + +class CallLogsMixin(PipedriveToolsBase): + """Tools for the ``call_logs`` group.""" + + @pipedrive_tool( + group='call_logs', + input_schema=schema(**PAGING()), + description='List the call logs of the authenticated user.', + ) + def call_log_list(self, args): + args = args_of(args) + return self._list('/callLogs', args, clean_call_log) + + @pipedrive_tool( + group='call_logs', + input_schema=schema(required=['call_log_id'], call_log_id=STR('Call log id.')), + description='Get a single call log.', + ) + def call_log_get(self, args): + args = args_of(args) + log_id = require_text(args, 'call_log_id', 'call_log_get') + return self._get(f'/callLogs/{log_id}', clean_call_log) + + @pipedrive_tool( + group='call_logs', + input_schema=schema( + required=['outcome', 'to_phone_number', 'start_time', 'end_time'], + subject=STR('Call subject. Defaults to the phone number.'), + duration=INT('Call duration in seconds.'), + outcome=ENUM( + 'How the call ended.', + ['connected', 'no_answer', 'left_message', 'left_voicemail', 'wrong_number', 'busy'], + ), + from_phone_number=STR('Caller number.'), + to_phone_number=STR('Number that was called.'), + start_time=STR('Call start, RFC3339 UTC, e.g. "2026-07-26T10:00:00Z".'), + end_time=STR('Call end, RFC3339 UTC.'), + person_id=INT('Person the call relates to.'), + org_id=INT('Organization the call relates to.'), + deal_id=INT('Deal the call relates to.'), + lead_id=STR('Lead uuid the call relates to.'), + user_id=INT('User who made the call. Defaults to the authenticated user.'), + activity_id=INT('Existing activity to attach the call to.'), + note=STR('Free-text note about the call.'), + ), + description='Log a phone call.', + ) + def call_log_create(self, args): + args = args_of(args) + for key in ('outcome', 'to_phone_number', 'start_time', 'end_time'): + require_text(args, key, 'call_log_create') + return self._write('POST', '/callLogs', clean_call_log, body=body_from(args, _CALL_LOG_KEYS)) + + @pipedrive_tool( + group='call_logs', + input_schema=schema(required=['call_log_id'], call_log_id=STR('Call log id to delete.')), + description='Delete a call log.', + ) + def call_log_delete(self, args): + args = args_of(args) + log_id = require_text(args, 'call_log_id', 'call_log_delete') + return self._delete(f'/callLogs/{log_id}') + + @pipedrive_tool( + group='call_logs', + input_schema=schema( + required=['call_log_id', 'file_name', 'content_base64'], + call_log_id=STR('Call log id.'), + file_name=STR('Recording file name, including the extension (mp3, wav, ...).'), + content_base64=STR('Recording contents, base64-encoded.'), + ), + description='Attach an audio recording to a call log.', + ) + def call_log_recording_add(self, args): + args = args_of(args) + self._require_write() + log_id = require_text(args, 'call_log_id', 'call_log_recording_add') + file_name = require_text(args, 'file_name', 'call_log_recording_add') + content_b64 = require_text(args, 'content_base64', 'call_log_recording_add') + try: + content = base64.b64decode(content_b64, validate=True) + except Exception as exc: + raise ValueError('call_log_recording_add: "content_base64" is not valid base64') from exc + files = {'file': (file_name, content)} + return self._call('POST', f'/callLogs/{log_id}/recordings', files=files) diff --git a/nodes/src/nodes/tool_pipedrive/tools/deals.py b/nodes/src/nodes/tool_pipedrive/tools/deals.py new file mode 100644 index 000000000..8485cff34 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/deals.py @@ -0,0 +1,581 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Deal tools: the deal record plus its products, followers, participants and flow.""" + +from __future__ import annotations + +from ..pipedrive_client import ( + clean_activity, + clean_deal, + clean_file, + clean_mail_message, + clean_person, + clean_search_item, + clean_user, + paginated, +) +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + NUM, + OBJ, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + paging_params, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_DEAL_WRITE_KEYS = ( + 'title', + 'value', + 'currency', + 'user_id', + 'person_id', + 'org_id', + 'stage_id', + 'pipeline_id', + 'status', + 'expected_close_date', + 'probability', + 'lost_reason', + 'visible_to', + 'add_time', + 'label', +) + +_DEAL_WRITE_PROPS = { + 'title': STR('Deal title.'), + 'value': NUM('Deal value as a number.'), + 'currency': STR('3-letter currency code, e.g. "USD". Defaults to the company currency.'), + 'user_id': INT('Owner user id. Defaults to the authenticated user.'), + 'person_id': INT('Linked person id.'), + 'org_id': INT('Linked organization id.'), + 'stage_id': INT('Stage id. Determines the pipeline when pipeline_id is omitted.'), + 'pipeline_id': INT('Pipeline id.'), + 'status': ENUM('Deal status.', ['open', 'won', 'lost', 'deleted']), + 'expected_close_date': STR('Expected close date, YYYY-MM-DD.'), + 'probability': NUM('Success probability percentage (0-100).'), + 'lost_reason': STR('Free-text reason, only meaningful when status is "lost".'), + 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'label': STR('Deal label id, or a comma-separated list of label ids.'), + 'extra': EXTRA(), +} + + +class DealsMixin(PipedriveToolsBase): + """Tools for the ``deals`` group.""" + + # -- the deal record -------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema( + **PAGING(), + user_id=INT('Only deals owned by this user id.'), + filter_id=INT('Apply a saved filter by id (see filter_list).'), + stage_id=INT('Only deals in this stage.'), + status=ENUM( + 'Filter by status (default all_not_deleted).', ['open', 'won', 'lost', 'deleted', 'all_not_deleted'] + ), + sort=STR('Sort clause, e.g. "update_time DESC, id ASC". Field names must be in the deal table.'), + owned_by_you=INT('Set to 1 to return only deals owned by the authenticated user.'), + ), + description='List deals, optionally filtered by owner, stage, status or a saved filter.', + ) + def deal_list(self, args): + args = args_of(args) + return self._list( + '/deals', + args, + clean_deal, + extra=params_from(args, ('user_id', 'filter_id', 'stage_id', 'status', 'sort', 'owned_by_you')), + ) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.')), + description='Get a single deal by id, including its custom fields.', + ) + def deal_get(self, args): + args = args_of(args) + return self._get(f'/deals/{require_id(args, "deal_id", "deal_get")}', clean_deal) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + fields=STR('Comma-separated fields to search in: custom_fields, notes, title.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + person_id=INT('Only deals linked to this person.'), + organization_id=INT('Only deals linked to this organization.'), + status=ENUM('Only deals with this status.', ['open', 'won', 'lost']), + include_fields=STR('Extra fields to include, e.g. "deal.cc_email".'), + **PAGING(), + ), + description='Search deals by title, notes or custom field values.', + ) + def deal_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'deal_search') + params.update( + params_from(args, ('fields', 'exact_match', 'person_id', 'organization_id', 'status', 'include_fields')) + ) + envelope = self._call_envelope('GET', '/deals/search', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + return paginated(envelope, [clean_search_item(i) for i in items]) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['title'], **_DEAL_WRITE_PROPS), + description='Create a deal.', + ) + def deal_create(self, args): + args = args_of(args) + require_text(args, 'title', 'deal_create') + return self._write('POST', '/deals', clean_deal, body=body_from(args, _DEAL_WRITE_KEYS)) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id to update.'), **_DEAL_WRITE_PROPS), + description='Update a deal. Only the fields you pass are changed.', + ) + def deal_update(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_update') + return self._write('PUT', f'/deals/{deal_id}', clean_deal, body=body_from(args, _DEAL_WRITE_KEYS)) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id to delete.')), + description='Delete a deal. Pipedrive keeps it recoverable for 30 days.', + ) + def deal_delete(self, args): + args = args_of(args) + return self._delete(f'/deals/{require_id(args, "deal_id", "deal_delete")}') + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id to duplicate.')), + description='Duplicate a deal.', + ) + def deal_duplicate(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_duplicate') + return self._write('POST', f'/deals/{deal_id}/duplicate', clean_deal) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'merge_with_id'], + deal_id=INT('Deal id that will be merged away.'), + merge_with_id=INT('Deal id that survives the merge.'), + ), + description='Merge one deal into another. The source deal is removed.', + ) + def deal_merge(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_merge') + merge_with_id = require_id(args, 'merge_with_id', 'deal_merge') + return self._write('PUT', f'/deals/{deal_id}/merge', clean_deal, body={'merge_with_id': merge_with_id}) + + # -- reporting -------------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema( + status=ENUM('Only count deals with this status.', ['open', 'won', 'lost']), + filter_id=INT('Apply a saved filter by id.'), + user_id=INT('Only deals owned by this user id.'), + stage_id=INT('Only deals in this stage.'), + ), + description='Get a summary of deals: total count and value, grouped by currency.', + ) + def deal_summary(self, args): + args = args_of(args) + params = params_from(args, ('status', 'filter_id', 'user_id', 'stage_id')) + return self._call('GET', '/deals/summary', params=params) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['start_date', 'interval', 'amount', 'field_key'], + start_date=STR('First period start, YYYY-MM-DD.'), + interval=ENUM('Period length.', ['day', 'week', 'month', 'quarter']), + amount=INT('How many periods to return, counting forward from start_date.'), + field_key=STR('Date field the deals are grouped by, e.g. "add_time" or "expected_close_date".'), + user_id=INT('Only deals owned by this user id.'), + pipeline_id=INT('Only deals in this pipeline.'), + filter_id=INT('Apply a saved filter by id.'), + exclude_deals=INT('Set to 1 to return only totals, without the deal records.'), + totals_convert_currency=STR('Convert totals to this 3-letter currency code, or "default_currency".'), + ), + description='Get deals grouped into time periods, with per-period totals. Useful for pipeline trend questions.', + ) + def deal_timeline(self, args): + args = args_of(args) + params = { + 'start_date': require_text(args, 'start_date', 'deal_timeline'), + 'interval': require_text(args, 'interval', 'deal_timeline'), + 'amount': require_id(args, 'amount', 'deal_timeline'), + 'field_key': require_text(args, 'field_key', 'deal_timeline'), + } + params.update( + params_from( + args, + ('user_id', 'pipeline_id', 'filter_id', 'exclude_deals', 'totals_convert_currency'), + ) + ) + return self._call('GET', '/deals/timeline', params=params) + + # -- related records -------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id'], + deal_id=INT('Deal id.'), + done=INT('0 for pending activities, 1 for completed. Omit for both.'), + exclude=STR('Comma-separated activity ids to leave out.'), + **PAGING(), + ), + description='List activities associated with a deal.', + ) + def deal_activities_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_activities_list') + return self._list( + f'/deals/{deal_id}/activities', args, clean_activity, extra=params_from(args, ('done', 'exclude')) + ) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id'], + deal_id=INT('Deal id.'), + sort=STR('Sort clause, e.g. "add_time DESC".'), + **PAGING(), + ), + description='List files attached to a deal.', + ) + def deal_files_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_files_list') + return self._list(f'/deals/{deal_id}/files', args, clean_file, extra=params_from(args, ('sort',))) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id'], + deal_id=INT('Deal id.'), + all_changes=STR('Set to "1" to include changes from Pipedrive automations and integrations.'), + items=STR('Comma-separated update types to include, e.g. "activity,note,change".'), + **PAGING(), + ), + description='Get the update history (flow) of a deal: field changes, notes, activities and emails.', + ) + def deal_updates_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_updates_list') + params = paging_params(args) + params.update(params_from(args, ('all_changes', 'items'))) + envelope = self._call_envelope('GET', f'/deals/{deal_id}/flow', params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + return paginated(envelope, list(data or [])) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), + description='List persons linked to a deal, either directly or through its organization.', + ) + def deal_persons_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_persons_list') + return self._list(f'/deals/{deal_id}/persons', args, clean_person) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), + description='List email messages associated with a deal.', + ) + def deal_mail_messages_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_mail_messages_list') + return self._list(f'/deals/{deal_id}/mailMessages', args, clean_mail_message) + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.')), + description='List users who have permission to see or edit a deal.', + ) + def deal_permitted_users_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_permitted_users_list') + return {'user_ids': self._call('GET', f'/deals/{deal_id}/permittedUsers')} + + # -- followers -------------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), + description='List followers of a deal.', + ) + def deal_followers_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_followers_list') + return self._list(f'/deals/{deal_id}/followers', args, passthrough) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'user_id'], deal_id=INT('Deal id.'), user_id=INT('User id to add as a follower.') + ), + description='Add a follower to a deal.', + ) + def deal_follower_add(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_follower_add') + user_id = require_id(args, 'user_id', 'deal_follower_add') + return self._write('POST', f'/deals/{deal_id}/followers', passthrough, body={'user_id': user_id}) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'follower_id'], + deal_id=INT('Deal id.'), + follower_id=INT('Follower id (from deal_followers_list, not the user id).'), + ), + description='Remove a follower from a deal.', + ) + def deal_follower_delete(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_follower_delete') + follower_id = require_id(args, 'follower_id', 'deal_follower_delete') + return self._delete(f'/deals/{deal_id}/followers/{follower_id}') + + # -- participants ----------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), + description='List participants (persons) of a deal.', + ) + def deal_participants_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_participants_list') + return self._list(f'/deals/{deal_id}/participants', args, clean_person) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'person_id'], deal_id=INT('Deal id.'), person_id=INT('Person id to add.') + ), + description='Add a person as a participant of a deal.', + ) + def deal_participant_add(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_participant_add') + person_id = require_id(args, 'person_id', 'deal_participant_add') + return self._write('POST', f'/deals/{deal_id}/participants', passthrough, body={'person_id': person_id}) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'participant_id'], + deal_id=INT('Deal id.'), + participant_id=INT('Participant id (from deal_participants_list).'), + ), + description='Remove a participant from a deal.', + ) + def deal_participant_delete(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_participant_delete') + participant_id = require_id(args, 'participant_id', 'deal_participant_delete') + return self._delete(f'/deals/{deal_id}/participants/{participant_id}') + + # -- products --------------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id'], + deal_id=INT('Deal id.'), + include_product_data=INT('Set to 1 to embed the full product record in each row.'), + **PAGING(), + ), + description='List products attached to a deal, with their quantities and prices.', + ) + def deal_products_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_products_list') + return self._list( + f'/deals/{deal_id}/products', args, passthrough, extra=params_from(args, ('include_product_data',)) + ) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'product_id', 'item_price', 'quantity'], + deal_id=INT('Deal id.'), + product_id=INT('Product id to attach.'), + item_price=NUM('Price per unit for this deal.'), + quantity=NUM('Number of units.'), + discount=NUM('Discount amount or percentage, see discount_type.'), + discount_type=ENUM('How discount is interpreted (default percentage).', ['percentage', 'amount']), + tax=NUM('Tax amount or percentage, see tax_method.'), + tax_method=ENUM('How tax is applied.', ['exclusive', 'inclusive', 'none']), + duration=NUM('Billing duration for recurring products.'), + duration_unit=ENUM('Unit for duration.', ['hourly', 'daily', 'weekly', 'monthly', 'yearly']), + product_variation_id=INT('Product variation id, when the product has variations.'), + comments=STR('Free-text comment shown on the deal.'), + enabled_flag=BOOL('Whether the product is active on the deal (default true).'), + extra=EXTRA(), + ), + description='Attach a product to a deal.', + ) + def deal_product_add(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_product_add') + require_id(args, 'product_id', 'deal_product_add') + body = body_from( + args, + ( + 'product_id', + 'item_price', + 'quantity', + 'discount', + 'discount_type', + 'tax', + 'tax_method', + 'duration', + 'duration_unit', + 'product_variation_id', + 'comments', + 'enabled_flag', + ), + ) + return self._write('POST', f'/deals/{deal_id}/products', passthrough, body=body) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'product_attachment_id'], + deal_id=INT('Deal id.'), + product_attachment_id=INT('Attachment id from deal_products_list (not the product id).'), + item_price=NUM('New price per unit.'), + quantity=NUM('New number of units.'), + discount=NUM('New discount.'), + discount_type=ENUM('How discount is interpreted.', ['percentage', 'amount']), + tax=NUM('New tax.'), + tax_method=ENUM('How tax is applied.', ['exclusive', 'inclusive', 'none']), + duration=NUM('Billing duration for recurring products.'), + duration_unit=ENUM('Unit for duration.', ['hourly', 'daily', 'weekly', 'monthly', 'yearly']), + product_variation_id=INT('Product variation id.'), + comments=STR('Free-text comment.'), + enabled_flag=BOOL('Whether the product is active on the deal.'), + extra=EXTRA(), + ), + description='Update a product already attached to a deal.', + ) + def deal_product_update(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_product_update') + attachment_id = require_id(args, 'product_attachment_id', 'deal_product_update') + body = body_from( + args, + ( + 'item_price', + 'quantity', + 'discount', + 'discount_type', + 'tax', + 'tax_method', + 'duration', + 'duration_unit', + 'product_variation_id', + 'comments', + 'enabled_flag', + ), + ) + return self._write('PUT', f'/deals/{deal_id}/products/{attachment_id}', passthrough, body=body) + + @pipedrive_tool( + group='deals', + input_schema=schema( + required=['deal_id', 'product_attachment_id'], + deal_id=INT('Deal id.'), + product_attachment_id=INT('Attachment id from deal_products_list.'), + ), + description='Detach a product from a deal.', + ) + def deal_product_delete(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_product_delete') + attachment_id = require_id(args, 'product_attachment_id', 'deal_product_delete') + return self._delete(f'/deals/{deal_id}/products/{attachment_id}') + + # -- misc ------------------------------------------------------------- + + @pipedrive_tool( + group='deals', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), + description='List users who follow a deal, resolved to full user records.', + ) + def deal_followers_users_list(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'deal_followers_users_list') + return self._list(f'/deals/{deal_id}/followers', args, clean_user) + + @pipedrive_tool( + group='deals', + input_schema=schema( + ids=ARR('Deal ids to delete.', 'integer'), + extra=OBJ('Additional query parameters passed straight through.'), + ), + description='Delete multiple deals in one call.', + ) + def deal_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('deal_delete_bulk: "ids" must be a non-empty array of deal ids') + params = {'ids': ','.join(str(int(i)) for i in ids)} + if isinstance(args.get('extra'), dict): + params.update(args['extra']) + self._require_write() + return {'deleted': True, 'data': self._call('DELETE', '/deals', params=params)} diff --git a/nodes/src/nodes/tool_pipedrive/tools/fields.py b/nodes/src/nodes/tool_pipedrive/tools/fields.py new file mode 100644 index 000000000..25363aea3 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/fields.py @@ -0,0 +1,170 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Custom field tools for deals, persons, organizations and products. + +The four Pipedrive field endpoints are identical apart from their path, so these +tools take an ``entity`` parameter instead of shipping the same five operations +four times over. Activity and note fields live with their own resources +(``activity_field_list``, ``note_field_list``). +""" + +from __future__ import annotations + +from ..pipedrive_client import clean_field +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + require_id, + require_text, + schema, +) + +_FIELD_PATHS = { + 'deal': '/dealFields', + 'person': '/personFields', + 'organization': '/organizationFields', + 'product': '/productFields', +} + +_ENTITY = ENUM('Which record type the field belongs to.', sorted(_FIELD_PATHS)) + +_FIELD_TYPE_DESC = ( + 'Field type. One of: varchar (text up to 255), varchar_auto (autocomplete text), text (long text), ' + 'double (number), monetary, date, daterange, time, timerange, user, org, people, phone, address, ' + 'enum (single option), set (multiple options), visible_to, int, address.' +) + + +def _path(args: dict, tool: str) -> str: + entity = require_text(args, 'entity', tool) + try: + return _FIELD_PATHS[entity] + except KeyError: + raise ValueError(f'{tool}: "entity" must be one of {", ".join(sorted(_FIELD_PATHS))}') from None + + +class FieldsMixin(PipedriveToolsBase): + """Tools for the ``fields`` group.""" + + @pipedrive_tool( + group='fields', + input_schema=schema(required=['entity'], entity=_ENTITY, **PAGING()), + description='List the fields of deals, persons, organizations or products, including custom fields and the 40-character keys used to read and write them.', + ) + def field_list(self, args): + args = args_of(args) + return self._list(_path(args, 'field_list'), args, clean_field) + + @pipedrive_tool( + group='fields', + input_schema=schema(required=['entity', 'field_id'], entity=_ENTITY, field_id=INT('Field id.')), + description='Get a single field definition, including its options for enum and set fields.', + ) + def field_get(self, args): + args = args_of(args) + base = _path(args, 'field_get') + field_id = require_id(args, 'field_id', 'field_get') + return self._get(f'{base}/{field_id}', clean_field) + + @pipedrive_tool( + group='fields', + input_schema=schema( + required=['entity', 'name', 'field_type'], + entity=_ENTITY, + name=STR('Display name of the field.'), + field_type=STR(_FIELD_TYPE_DESC), + options=ARR('Options for enum and set fields, e.g. [{"label": "Gold"}, {"label": "Silver"}].', 'object'), + add_visible_flag=BOOL('Whether the field appears in the "add new" dialog (default true).'), + extra=EXTRA(), + ), + description='Create a custom field on deals, persons, organizations or products.', + ) + def field_create(self, args): + args = args_of(args) + base = _path(args, 'field_create') + require_text(args, 'name', 'field_create') + require_text(args, 'field_type', 'field_create') + body = body_from(args, ('name', 'field_type', 'options', 'add_visible_flag')) + return self._write('POST', base, clean_field, body=body) + + @pipedrive_tool( + group='fields', + input_schema=schema( + required=['entity', 'field_id'], + entity=_ENTITY, + field_id=INT('Field id to update.'), + name=STR('New display name.'), + options=ARR( + 'Full option list for enum and set fields. Include existing options with their ids, or they are removed.', + 'object', + ), + add_visible_flag=BOOL('Whether the field appears in the "add new" dialog.'), + extra=EXTRA(), + ), + description='Update a custom field. Field type cannot be changed after creation.', + ) + def field_update(self, args): + args = args_of(args) + base = _path(args, 'field_update') + field_id = require_id(args, 'field_id', 'field_update') + body = body_from(args, ('name', 'options', 'add_visible_flag')) + return self._write('PUT', f'{base}/{field_id}', clean_field, body=body) + + @pipedrive_tool( + group='fields', + input_schema=schema(required=['entity', 'field_id'], entity=_ENTITY, field_id=INT('Field id to delete.')), + description='Delete a custom field. The data stored in it is lost.', + ) + def field_delete(self, args): + args = args_of(args) + base = _path(args, 'field_delete') + field_id = require_id(args, 'field_id', 'field_delete') + return self._delete(f'{base}/{field_id}') + + @pipedrive_tool( + group='fields', + input_schema=schema(required=['entity', 'ids'], entity=_ENTITY, ids=ARR('Field ids to delete.', 'integer')), + description='Delete multiple custom fields in one call.', + ) + def field_delete_bulk(self, args): + args = args_of(args) + base = _path(args, 'field_delete_bulk') + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('field_delete_bulk: "ids" must be a non-empty array of field ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', base, params=params)} diff --git a/nodes/src/nodes/tool_pipedrive/tools/files.py b/nodes/src/nodes/tool_pipedrive/tools/files.py new file mode 100644 index 000000000..affc32e58 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/files.py @@ -0,0 +1,190 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""File tools: upload, link, download and manage attachments.""" + +from __future__ import annotations + +import base64 + +from ..pipedrive_client import clean_file +from ..tool_groups import pipedrive_tool +from ._base import ( + ENUM, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + require_id, + require_text, + schema, +) + +_ATTACH_KEYS = ('deal_id', 'person_id', 'org_id', 'product_id', 'activity_id', 'lead_id') + +_ATTACH_PROPS = { + 'deal_id': INT('Attach the file to this deal.'), + 'person_id': INT('Attach the file to this person.'), + 'org_id': INT('Attach the file to this organization.'), + 'product_id': INT('Attach the file to this product.'), + 'activity_id': INT('Attach the file to this activity.'), + 'lead_id': STR('Attach the file to this lead uuid.'), +} + + +class FilesMixin(PipedriveToolsBase): + """Tools for the ``files`` group.""" + + @pipedrive_tool( + group='files', + input_schema=schema(sort=STR('Sort clause, e.g. "update_time DESC".'), **PAGING()), + description='List files stored in Pipedrive.', + ) + def file_list(self, args): + args = args_of(args) + return self._list('/files', args, clean_file, extra=params_from(args, ('sort',))) + + @pipedrive_tool( + group='files', + input_schema=schema(required=['file_id'], file_id=INT('File id.')), + description='Get the metadata of a single file.', + ) + def file_get(self, args): + args = args_of(args) + return self._get(f'/files/{require_id(args, "file_id", "file_get")}', clean_file) + + @pipedrive_tool( + group='files', + input_schema=schema( + required=['file_name', 'content_base64'], + file_name=STR('Name to store the file under, including the extension.'), + content_base64=STR('File contents, base64-encoded.'), + **_ATTACH_PROPS, + ), + description='Upload a file and attach it to a deal, person, organization, product, activity or lead.', + ) + def file_create(self, args): + args = args_of(args) + self._require_write() + file_name = require_text(args, 'file_name', 'file_create') + content_b64 = require_text(args, 'content_base64', 'file_create') + try: + content = base64.b64decode(content_b64, validate=True) + except Exception as exc: + raise ValueError('file_create: "content_base64" is not valid base64') from exc + form = {k: args[k] for k in _ATTACH_KEYS if args.get(k) is not None} + files = {'file': (file_name, content)} + return clean_file(self._call('POST', '/files', form=form, files=files)) + + @pipedrive_tool( + group='files', + input_schema=schema( + required=['file_type', 'title', 'item_type', 'item_id', 'remote_location'], + file_type=ENUM('Type of remote document to create.', ['gdoc', 'gslides', 'gsheet', 'gform', 'gdraw']), + title=STR('Title of the new remote document.'), + item_type=ENUM('Record the file is attached to.', ['deal', 'organization', 'person']), + item_id=INT('Id of the record the file is attached to.'), + remote_location=ENUM('Remote storage provider.', ['googledrive']), + ), + description='Create a new empty remote document (Google Drive) and attach it to a record.', + ) + def file_create_remote(self, args): + args = args_of(args) + self._require_write() + for key in ('file_type', 'title', 'item_type', 'remote_location'): + require_text(args, key, 'file_create_remote') + require_id(args, 'item_id', 'file_create_remote') + form = body_from(args, ('file_type', 'title', 'item_type', 'item_id', 'remote_location')) + return clean_file(self._call('POST', '/files/remote', form=form)) + + @pipedrive_tool( + group='files', + input_schema=schema( + required=['item_type', 'item_id', 'remote_id', 'remote_location'], + item_type=ENUM('Record the file is attached to.', ['deal', 'organization', 'person']), + item_id=INT('Id of the record the file is attached to.'), + remote_id=STR('Id of the existing remote file.'), + remote_location=ENUM('Remote storage provider.', ['googledrive']), + ), + description='Link an existing remote file (Google Drive) to a record.', + ) + def file_link_remote(self, args): + args = args_of(args) + self._require_write() + for key in ('item_type', 'remote_id', 'remote_location'): + require_text(args, key, 'file_link_remote') + require_id(args, 'item_id', 'file_link_remote') + form = body_from(args, ('item_type', 'item_id', 'remote_id', 'remote_location')) + return clean_file(self._call('POST', '/files/remoteLink', form=form)) + + @pipedrive_tool( + group='files', + input_schema=schema( + required=['file_id'], + file_id=INT('File id to update.'), + name=STR('New file name.'), + description=STR('New description.'), + ), + description='Rename a file or change its description.', + ) + def file_update(self, args): + args = args_of(args) + file_id = require_id(args, 'file_id', 'file_update') + return self._write('PUT', f'/files/{file_id}', clean_file, body=body_from(args, ('name', 'description'))) + + @pipedrive_tool( + group='files', + input_schema=schema(required=['file_id'], file_id=INT('File id to delete.')), + description='Delete a file.', + ) + def file_delete(self, args): + args = args_of(args) + return self._delete(f'/files/{require_id(args, "file_id", "file_delete")}') + + @pipedrive_tool( + group='files', + input_schema=schema( + required=['file_id'], + file_id=INT('File id to download.'), + as_text=STR('Set to "1" to decode the file as UTF-8 text instead of returning base64.'), + ), + description='Download a file. Returns base64 content by default, or decoded text with as_text="1".', + ) + def file_download(self, args): + args = args_of(args) + file_id = require_id(args, 'file_id', 'file_download') + content = self._call('GET', f'/files/{file_id}/download', raw=True) + if not isinstance(content, (bytes, bytearray)): + return {'file_id': file_id, 'content': content} + if str(args.get('as_text', '')) == '1': + return {'file_id': file_id, 'text': content.decode('utf-8', errors='replace')} + return { + 'file_id': file_id, + 'size': len(content), + 'content_base64': base64.b64encode(content).decode('ascii'), + } diff --git a/nodes/src/nodes/tool_pipedrive/tools/filters.py b/nodes/src/nodes/tool_pipedrive/tools/filters.py new file mode 100644 index 000000000..6a969856f --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/filters.py @@ -0,0 +1,143 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Saved-filter tools. Filter ids feed the filter_id parameter of the list tools.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_filter +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + ENUM, + INT, + OBJ, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_FILTER_TYPES = ['deals', 'leads', 'org', 'people', 'products', 'activity', 'projects'] + +_CONDITIONS_DESC = ( + 'Filter condition tree. Pipedrive expects a nested AND/OR structure, e.g. ' + '{"glue": "and", "conditions": [{"glue": "and", "conditions": [{"object": "deal", ' + '"field_id": "1", "operator": "=", "value": "open"}]}]}. Call filter_helpers_get for the ' + 'field ids and operators this account accepts.' +) + + +class FiltersMixin(PipedriveToolsBase): + """Tools for the ``filters`` group.""" + + @pipedrive_tool( + group='filters', + input_schema=schema(type=ENUM('Only filters of this type.', _FILTER_TYPES)), + description='List saved filters. Pass a returned filter id as filter_id to the list tools to reuse a filter the user already built in the UI.', + ) + def filter_list(self, args): + args = args_of(args) + data = self._call('GET', '/filters', params=params_from(args, ('type',))) + return {'items': [clean_filter(f) for f in (data or [])]} + + @pipedrive_tool( + group='filters', + input_schema=schema(required=['filter_id'], filter_id=INT('Filter id.')), + description='Get a single filter, including its condition tree.', + ) + def filter_get(self, args): + args = args_of(args) + return self._get(f'/filters/{require_id(args, "filter_id", "filter_get")}', passthrough) + + @pipedrive_tool( + group='filters', + input_schema=schema( + required=['name', 'conditions', 'type'], + name=STR('Filter name.'), + conditions=OBJ(_CONDITIONS_DESC), + type=ENUM('What the filter applies to.', _FILTER_TYPES), + ), + description='Create a saved filter.', + ) + def filter_create(self, args): + args = args_of(args) + require_text(args, 'name', 'filter_create') + require_text(args, 'type', 'filter_create') + if not isinstance(args.get('conditions'), dict): + raise ValueError('filter_create: "conditions" is required and must be an object') + return self._write('POST', '/filters', passthrough, body=body_from(args, ('name', 'conditions', 'type'))) + + @pipedrive_tool( + group='filters', + input_schema=schema( + required=['filter_id'], + filter_id=INT('Filter id to update.'), + name=STR('New filter name.'), + conditions=OBJ(_CONDITIONS_DESC), + ), + description='Update a saved filter.', + ) + def filter_update(self, args): + args = args_of(args) + filter_id = require_id(args, 'filter_id', 'filter_update') + return self._write('PUT', f'/filters/{filter_id}', passthrough, body=body_from(args, ('name', 'conditions'))) + + @pipedrive_tool( + group='filters', + input_schema=schema(required=['filter_id'], filter_id=INT('Filter id to delete.')), + description='Delete a saved filter.', + ) + def filter_delete(self, args): + args = args_of(args) + return self._delete(f'/filters/{require_id(args, "filter_id", "filter_delete")}') + + @pipedrive_tool( + group='filters', + input_schema=schema(ids=ARR('Filter ids to delete.', 'integer')), + description='Delete multiple saved filters in one call.', + ) + def filter_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('filter_delete_bulk: "ids" must be a non-empty array of filter ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/filters', params=params)} + + @pipedrive_tool( + group='filters', + input_schema=schema(), + description='Get the field ids, operators and value formats that filter conditions accept. Call this before building a filter.', + ) + def filter_helpers_get(self, args): + args_of(args) + return self._call('GET', '/filters/helpers') diff --git a/nodes/src/nodes/tool_pipedrive/tools/goals.py b/nodes/src/nodes/tool_pipedrive/tools/goals.py new file mode 100644 index 000000000..63e900e13 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/goals.py @@ -0,0 +1,151 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Goal tools: sales targets and their progress.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_goal +from ..tool_groups import pipedrive_tool +from ._base import ( + BOOL, + ENUM, + INT, + OBJ, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + require_text, + schema, +) + +_GOAL_WRITE_KEYS = ('title', 'assignee', 'type', 'expected_outcome', 'duration', 'interval') + +_GOAL_WRITE_PROPS = { + 'title': STR('Goal title.'), + 'assignee': OBJ('Who the goal is for, e.g. {"id": 1, "type": "person"}. Type is person, company or team.'), + 'type': OBJ( + 'Goal type, e.g. {"name": "deals_won", "params": {"pipeline_id": [1]}}. Names: deals_won, deals_progressed, ' + 'activities_completed, activities_added, deals_started.' + ), + 'expected_outcome': OBJ( + 'Target, e.g. {"target": 50, "tracking_metric": "quantity"} or {"target": 100000, "tracking_metric": "sum", "currency_id": 1}.' + ), + 'duration': OBJ('Goal period, e.g. {"start": "2026-01-01", "end": "2026-03-31"}.'), + 'interval': ENUM('How often the goal repeats.', ['weekly', 'monthly', 'quarterly', 'yearly']), +} + + +class GoalsMixin(PipedriveToolsBase): + """Tools for the ``goals`` group.""" + + @pipedrive_tool( + group='goals', + input_schema=schema( + title=STR('Only goals whose title contains this text.'), + is_active=BOOL('Only active (true) or only finished (false) goals. Defaults to active.'), + assignee_id=INT('Only goals assigned to this id (paired with assignee_type).'), + assignee_type=ENUM('What assignee_id refers to.', ['person', 'company', 'team']), + type_name=ENUM( + 'Only goals of this type.', + ['deals_won', 'deals_progressed', 'activities_completed', 'activities_added', 'deals_started'], + ), + expected_outcome_target=INT('Only goals with this target value.'), + expected_outcome_tracking_metric=ENUM('Only goals tracked this way.', ['quantity', 'sum']), + period_start=STR('Only goals whose period starts on or after this date, YYYY-MM-DD.'), + period_end=STR('Only goals whose period ends on or before this date, YYYY-MM-DD.'), + ), + description='Find goals by title, assignee, type or period.', + ) + def goal_find(self, args): + args = args_of(args) + params = params_from(args, ('title', 'is_active', 'assignee_id')) + # Pipedrive expects these as dotted query keys. + dotted = { + 'assignee.id': args.get('assignee_id'), + 'assignee.type': args.get('assignee_type'), + 'type.name': args.get('type_name'), + 'expected_outcome.target': args.get('expected_outcome_target'), + 'expected_outcome.tracking_metric': args.get('expected_outcome_tracking_metric'), + 'period.start': args.get('period_start'), + 'period.end': args.get('period_end'), + } + params.pop('assignee_id', None) + params.update({k: v for k, v in dotted.items() if v is not None}) + data = self._call('GET', '/goals/find', params=params) + goals = (data or {}).get('goals') if isinstance(data, dict) else data + return {'items': [clean_goal(g) for g in (goals or [])]} + + @pipedrive_tool( + group='goals', + input_schema=schema(required=['assignee', 'type', 'expected_outcome', 'duration'], **_GOAL_WRITE_PROPS), + description='Create a goal.', + ) + def goal_create(self, args): + args = args_of(args) + for key in ('assignee', 'type', 'expected_outcome', 'duration'): + if not isinstance(args.get(key), dict): + raise ValueError(f'goal_create: "{key}" is required and must be an object') + return self._write('POST', '/goals', clean_goal, body=body_from(args, _GOAL_WRITE_KEYS)) + + @pipedrive_tool( + group='goals', + input_schema=schema(required=['goal_id'], goal_id=STR('Goal id.'), **_GOAL_WRITE_PROPS), + description='Update a goal.', + ) + def goal_update(self, args): + args = args_of(args) + goal_id = require_text(args, 'goal_id', 'goal_update') + return self._write('PUT', f'/goals/{goal_id}', clean_goal, body=body_from(args, _GOAL_WRITE_KEYS)) + + @pipedrive_tool( + group='goals', + input_schema=schema(required=['goal_id'], goal_id=STR('Goal id to delete.')), + description='Delete a goal.', + ) + def goal_delete(self, args): + args = args_of(args) + return self._delete(f'/goals/{require_text(args, "goal_id", "goal_delete")}') + + @pipedrive_tool( + group='goals', + input_schema=schema( + required=['goal_id', 'period_start', 'period_end'], + goal_id=STR('Goal id.'), + period_start=STR('Period start, YYYY-MM-DD. Must match the goal duration interval.'), + period_end=STR('Period end, YYYY-MM-DD.'), + ), + description='Get the progress of a goal over a period: how much of the target has been reached.', + ) + def goal_results_get(self, args): + args = args_of(args) + goal_id = require_text(args, 'goal_id', 'goal_results_get') + params = { + 'period.start': require_text(args, 'period_start', 'goal_results_get'), + 'period.end': require_text(args, 'period_end', 'goal_results_get'), + } + return self._call('GET', f'/goals/{goal_id}/results', params=params) diff --git a/nodes/src/nodes/tool_pipedrive/tools/leads.py b/nodes/src/nodes/tool_pipedrive/tools/leads.py new file mode 100644 index 000000000..b5029b285 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/leads.py @@ -0,0 +1,252 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Lead tools: the lead inbox, its labels and its sources.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_lead, clean_search_item, paginated +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + OBJ, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + paging_params, + params_from, + passthrough, + require_text, + schema, +) + +_LEAD_WRITE_KEYS = ( + 'title', + 'owner_id', + 'label_ids', + 'person_id', + 'organization_id', + 'value', + 'expected_close_date', + 'visible_to', + 'was_seen', + 'origin_id', + 'channel', + 'channel_id', +) + +_LEAD_WRITE_PROPS = { + 'title': STR('Lead title.'), + 'owner_id': INT('Owner user id. Defaults to the authenticated user.'), + 'label_ids': ARR('Lead label uuids to apply (see lead_label_list).'), + 'person_id': INT('Linked person id. A lead must be linked to a person or an organization.'), + 'organization_id': INT('Linked organization id.'), + 'value': OBJ('Lead value, e.g. {"amount": 5000, "currency": "USD"}.'), + 'expected_close_date': STR('Expected close date, YYYY-MM-DD.'), + 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'was_seen': BOOL('Whether the lead has been opened by a user.'), + 'channel': INT('Marketing channel id the lead came from.'), + 'channel_id': STR('Optional identifier within the marketing channel.'), + 'extra': EXTRA(), +} + + +class LeadsMixin(PipedriveToolsBase): + """Tools for the ``leads`` group.""" + + @pipedrive_tool( + group='leads', + input_schema=schema( + **PAGING(), + archived_status=ENUM('Which leads to include (default all).', ['archived', 'not_archived', 'all']), + owner_id=INT('Only leads owned by this user id.'), + person_id=INT('Only leads linked to this person.'), + organization_id=INT('Only leads linked to this organization.'), + filter_id=INT('Apply a saved filter by id.'), + sort=STR( + 'Sort clause: id, title, owner_id, creator_id, was_seen, expected_close_date, next_activity_id, add_time or update_time, with ASC/DESC.' + ), + ), + description='List leads from the Leads Inbox.', + ) + def lead_list(self, args): + args = args_of(args) + return self._list( + '/leads', + args, + clean_lead, + extra=params_from( + args, ('archived_status', 'owner_id', 'person_id', 'organization_id', 'filter_id', 'sort') + ), + ) + + @pipedrive_tool( + group='leads', + input_schema=schema(required=['lead_id'], lead_id=STR('Lead uuid.')), + description='Get a single lead by its uuid.', + ) + def lead_get(self, args): + args = args_of(args) + return self._get(f'/leads/{require_text(args, "lead_id", "lead_get")}', clean_lead) + + @pipedrive_tool( + group='leads', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + fields=STR('Comma-separated fields to search in: custom_fields, notes, title.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + person_id=INT('Only leads linked to this person.'), + organization_id=INT('Only leads linked to this organization.'), + include_fields=STR('Extra fields to include, e.g. "lead.was_seen".'), + **PAGING(), + ), + description='Search leads by title, notes or custom field values.', + ) + def lead_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'lead_search') + params.update(params_from(args, ('fields', 'exact_match', 'person_id', 'organization_id', 'include_fields'))) + envelope = self._call_envelope('GET', '/leads/search', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + return paginated(envelope, [clean_search_item(i) for i in items]) + + @pipedrive_tool( + group='leads', + input_schema=schema(required=['title'], **_LEAD_WRITE_PROPS), + description='Create a lead. Link it to a person or an organization (at least one is required).', + ) + def lead_create(self, args): + args = args_of(args) + require_text(args, 'title', 'lead_create') + return self._write('POST', '/leads', clean_lead, body=body_from(args, _LEAD_WRITE_KEYS)) + + @pipedrive_tool( + group='leads', + input_schema=schema( + required=['lead_id'], + lead_id=STR('Lead uuid to update.'), + is_archived=BOOL('Archive or unarchive the lead.'), + **_LEAD_WRITE_PROPS, + ), + description='Update a lead. Only the fields you pass are changed.', + ) + def lead_update(self, args): + args = args_of(args) + lead_id = require_text(args, 'lead_id', 'lead_update') + body = body_from(args, (*_LEAD_WRITE_KEYS, 'is_archived')) + return self._write('PATCH', f'/leads/{lead_id}', clean_lead, body=body) + + @pipedrive_tool( + group='leads', + input_schema=schema(required=['lead_id'], lead_id=STR('Lead uuid to delete.')), + description='Delete a lead.', + ) + def lead_delete(self, args): + args = args_of(args) + return self._delete(f'/leads/{require_text(args, "lead_id", "lead_delete")}') + + @pipedrive_tool( + group='leads', + input_schema=schema(required=['lead_id'], lead_id=STR('Lead uuid.')), + description='List users who have permission to see or edit a lead.', + ) + def lead_permitted_users_list(self, args): + args = args_of(args) + lead_id = require_text(args, 'lead_id', 'lead_permitted_users_list') + return {'user_ids': self._call('GET', f'/leads/{lead_id}/permittedUsers')} + + # -- labels ----------------------------------------------------------- + + @pipedrive_tool( + group='leads', + input_schema=schema(), + description='List the lead labels configured in this account, with the uuids to pass as label_ids.', + ) + def lead_label_list(self, args): + args_of(args) + data = self._call('GET', '/leadLabels') + return {'items': list(data or [])} + + @pipedrive_tool( + group='leads', + input_schema=schema( + required=['name', 'color'], + name=STR('Label name.'), + color=ENUM( + 'Label colour.', + ['green', 'blue', 'red', 'yellow', 'purple', 'gray'], + ), + ), + description='Create a lead label.', + ) + def lead_label_create(self, args): + args = args_of(args) + require_text(args, 'name', 'lead_label_create') + require_text(args, 'color', 'lead_label_create') + return self._write('POST', '/leadLabels', passthrough, body=body_from(args, ('name', 'color'))) + + @pipedrive_tool( + group='leads', + input_schema=schema( + required=['label_id'], + label_id=STR('Lead label uuid to update.'), + name=STR('New label name.'), + color=ENUM('New label colour.', ['green', 'blue', 'red', 'yellow', 'purple', 'gray']), + ), + description='Update a lead label.', + ) + def lead_label_update(self, args): + args = args_of(args) + label_id = require_text(args, 'label_id', 'lead_label_update') + return self._write('PATCH', f'/leadLabels/{label_id}', passthrough, body=body_from(args, ('name', 'color'))) + + @pipedrive_tool( + group='leads', + input_schema=schema(required=['label_id'], label_id=STR('Lead label uuid to delete.')), + description='Delete a lead label.', + ) + def lead_label_delete(self, args): + args = args_of(args) + return self._delete(f'/leadLabels/{require_text(args, "label_id", "lead_label_delete")}') + + # -- sources ---------------------------------------------------------- + + @pipedrive_tool( + group='leads', + input_schema=schema(), + description='List the lead sources available in this account.', + ) + def lead_source_list(self, args): + args_of(args) + data = self._call('GET', '/leadSources') + return {'items': list(data or [])} diff --git a/nodes/src/nodes/tool_pipedrive/tools/mailbox.py b/nodes/src/nodes/tool_pipedrive/tools/mailbox.py new file mode 100644 index 000000000..02f9a2470 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/mailbox.py @@ -0,0 +1,124 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Mailbox tools: mail threads and messages synced into Pipedrive.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_mail_message, clean_mail_thread +from ..tool_groups import pipedrive_tool +from ._base import ( + ENUM, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + require_id, + schema, +) + + +class MailboxMixin(PipedriveToolsBase): + """Tools for the ``mailbox`` group.""" + + @pipedrive_tool( + group='mailbox', + input_schema=schema( + folder=ENUM('Mailbox folder to read (default inbox).', ['inbox', 'drafts', 'sent', 'archive']), + **PAGING(), + ), + description='List mail threads in a mailbox folder.', + ) + def mail_thread_list(self, args): + args = args_of(args) + return self._list('/mailbox/mailThreads', args, clean_mail_thread, extra=params_from(args, ('folder',))) + + @pipedrive_tool( + group='mailbox', + input_schema=schema(required=['thread_id'], thread_id=INT('Mail thread id.')), + description='Get a single mail thread.', + ) + def mail_thread_get(self, args): + args = args_of(args) + thread_id = require_id(args, 'thread_id', 'mail_thread_get') + return self._get(f'/mailbox/mailThreads/{thread_id}', clean_mail_thread) + + @pipedrive_tool( + group='mailbox', + input_schema=schema(required=['thread_id'], thread_id=INT('Mail thread id.'), **PAGING()), + description='List the messages in a mail thread.', + ) + def mail_thread_messages_list(self, args): + args = args_of(args) + thread_id = require_id(args, 'thread_id', 'mail_thread_messages_list') + return self._list(f'/mailbox/mailThreads/{thread_id}/mailMessages', args, clean_mail_message) + + @pipedrive_tool( + group='mailbox', + input_schema=schema( + required=['thread_id'], + thread_id=INT('Mail thread id to update.'), + deal_id=INT('Link the thread to this deal.'), + lead_id=STR('Link the thread to this lead uuid.'), + shared_flag=INT('1 to share the thread with the whole company, 0 to keep it private.'), + read_flag=INT('1 to mark the thread as read, 0 as unread.'), + archived_flag=INT('1 to archive the thread, 0 to unarchive it.'), + ), + description='Link a mail thread to a deal or lead, or change its shared, read and archived flags.', + ) + def mail_thread_update(self, args): + args = args_of(args) + thread_id = require_id(args, 'thread_id', 'mail_thread_update') + body = body_from(args, ('deal_id', 'lead_id', 'shared_flag', 'read_flag', 'archived_flag')) + return self._write('PUT', f'/mailbox/mailThreads/{thread_id}', clean_mail_thread, body=body) + + @pipedrive_tool( + group='mailbox', + input_schema=schema(required=['thread_id'], thread_id=INT('Mail thread id to delete.')), + description='Delete a mail thread.', + ) + def mail_thread_delete(self, args): + args = args_of(args) + thread_id = require_id(args, 'thread_id', 'mail_thread_delete') + return self._delete(f'/mailbox/mailThreads/{thread_id}') + + @pipedrive_tool( + group='mailbox', + input_schema=schema( + required=['message_id'], + message_id=INT('Mail message id.'), + include_body=INT('Set to 1 to include the full message body.'), + ), + description='Get a single mail message, optionally with its body.', + ) + def mail_message_get(self, args): + args = args_of(args) + message_id = require_id(args, 'message_id', 'mail_message_get') + return self._get( + f'/mailbox/mailMessages/{message_id}', clean_mail_message, params=params_from(args, ('include_body',)) + ) diff --git a/nodes/src/nodes/tool_pipedrive/tools/misc.py b/nodes/src/nodes/tool_pipedrive/tools/misc.py new file mode 100644 index 000000000..f66e4fd39 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/misc.py @@ -0,0 +1,179 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Account-level odds and ends: currencies, billing add-ons, meeting links and channels.""" + +from __future__ import annotations + +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + INT, + OBJ, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_text, + schema, +) + + +class MiscMixin(PipedriveToolsBase): + """Tools for the ``misc`` group.""" + + @pipedrive_tool( + group='misc', + input_schema=schema(term=STR('Only currencies whose code or name matches this text.')), + description='List the currencies supported by the account, with their ids and decimal precision.', + ) + def currency_list(self, args): + args = args_of(args) + data = self._call('GET', '/currencies', params=params_from(args, ('term',))) + return {'items': list(data or [])} + + @pipedrive_tool( + group='misc', + input_schema=schema(), + description='List the billing add-ons the company has subscribed to.', + ) + def billing_addons_list(self, args): + args_of(args) + data = self._call('GET', '/billing/subscriptions/addons') + return {'items': list(data or [])} + + @pipedrive_tool( + group='misc', + input_schema=schema( + required=['user_provider_id', 'user_id', 'company_id', 'marketplace_client_id'], + user_provider_id=STR('Uuid of the link between the user and the video-call provider.'), + user_id=INT('Pipedrive user id.'), + company_id=INT('Pipedrive company id.'), + marketplace_client_id=STR('Client id issued to the video-call app in the marketplace.'), + ), + description='Link a Pipedrive user to a video-calling provider so meeting links can be generated.', + ) + def meeting_link_create(self, args): + args = args_of(args) + for key in ('user_provider_id', 'marketplace_client_id'): + require_text(args, key, 'meeting_link_create') + body = body_from(args, ('user_provider_id', 'user_id', 'company_id', 'marketplace_client_id')) + return self._write('POST', '/meetings/userProviderLinks', passthrough, body=body) + + @pipedrive_tool( + group='misc', + input_schema=schema(required=['link_id'], link_id=STR('User provider link uuid to delete.')), + description='Remove the link between a Pipedrive user and a video-calling provider.', + ) + def meeting_link_delete(self, args): + args = args_of(args) + link_id = require_text(args, 'link_id', 'meeting_link_delete') + return self._delete(f'/meetings/userProviderLinks/{link_id}') + + @pipedrive_tool( + group='misc', + input_schema=schema( + required=['name', 'provider_channel_id'], + name=STR('Channel name shown in Pipedrive.'), + provider_channel_id=STR('Id of the channel in the external messaging provider.'), + avatar_url=STR('Channel avatar image URL.'), + template_support=BOOL('Whether the provider supports message templates.'), + provider_type=STR('Provider type, e.g. "whatsapp" or "other".'), + ), + description='Register a messaging channel so an external inbox can appear in Pipedrive.', + ) + def channel_create(self, args): + args = args_of(args) + require_text(args, 'name', 'channel_create') + require_text(args, 'provider_channel_id', 'channel_create') + body = body_from(args, ('name', 'provider_channel_id', 'avatar_url', 'template_support', 'provider_type')) + return self._write('POST', '/channels', passthrough, body=body) + + @pipedrive_tool( + group='misc', + input_schema=schema(required=['channel_id'], channel_id=STR('Channel id to delete.')), + description='Delete a messaging channel.', + ) + def channel_delete(self, args): + args = args_of(args) + channel_id = require_text(args, 'channel_id', 'channel_delete') + return self._delete(f'/channels/{channel_id}') + + @pipedrive_tool( + group='misc', + input_schema=schema( + required=['id', 'channel_id', 'sender_id', 'conversation_id', 'message', 'status', 'created_at'], + id=STR('Message id in the provider.'), + channel_id=STR('Channel the message belongs to.'), + sender_id=STR('Sender id in the provider.'), + conversation_id=STR('Conversation the message belongs to.'), + message=STR('Message body.'), + status=STR('Message status: sent, delivered, read or failed.'), + created_at=STR('Message timestamp, RFC3339 UTC.'), + reply_by=STR('Deadline for a reply, RFC3339 UTC.'), + conversation_link=STR('Deep link back to the conversation in the provider.'), + attachments=ARR('Attachments, e.g. [{"id": "1", "type": "image/png", "url": "..."}].', 'object'), + extra=OBJ('Any additional message fields to send verbatim.'), + ), + description='Deliver an inbound message from an external messaging provider into a Pipedrive channel.', + ) + def channel_message_receive(self, args): + args = args_of(args) + for key in ('id', 'channel_id', 'sender_id', 'conversation_id', 'message', 'status', 'created_at'): + require_text(args, key, 'channel_message_receive') + body = body_from( + args, + ( + 'id', + 'channel_id', + 'sender_id', + 'conversation_id', + 'message', + 'status', + 'created_at', + 'reply_by', + 'conversation_link', + 'attachments', + ), + ) + return self._write('POST', '/channels/messages/receive', passthrough, body=body) + + @pipedrive_tool( + group='misc', + input_schema=schema( + required=['channel_id', 'conversation_id'], + channel_id=STR('Channel id.'), + conversation_id=STR('Conversation id to delete.'), + ), + description='Delete a conversation from a messaging channel.', + ) + def channel_conversation_delete(self, args): + args = args_of(args) + channel_id = require_text(args, 'channel_id', 'channel_conversation_delete') + conversation_id = require_text(args, 'conversation_id', 'channel_conversation_delete') + return self._delete(f'/channels/{channel_id}/conversations/{conversation_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/notes.py b/nodes/src/nodes/tool_pipedrive/tools/notes.py new file mode 100644 index 000000000..4235c11f1 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/notes.py @@ -0,0 +1,221 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Note tools: notes on deals, persons, organizations and leads, plus their comments.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_field, clean_note +from ..tool_groups import pipedrive_tool +from ._base import ( + ENUM, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_NOTE_WRITE_KEYS = ( + 'content', + 'deal_id', + 'person_id', + 'org_id', + 'lead_id', + 'project_id', + 'user_id', + 'add_time', + 'pinned_to_deal_flag', + 'pinned_to_person_flag', + 'pinned_to_organization_flag', + 'pinned_to_lead_flag', +) + +_NOTE_WRITE_PROPS = { + 'content': STR('Note body. HTML is accepted.'), + 'deal_id': INT('Deal the note is attached to.'), + 'person_id': INT('Person the note is attached to.'), + 'org_id': INT('Organization the note is attached to.'), + 'lead_id': STR('Lead uuid the note is attached to.'), + 'project_id': INT('Project the note is attached to.'), + 'user_id': INT('Author user id. Defaults to the authenticated user.'), + 'pinned_to_deal_flag': ENUM('Pin the note to the deal.', ['0', '1']), + 'pinned_to_person_flag': ENUM('Pin the note to the person.', ['0', '1']), + 'pinned_to_organization_flag': ENUM('Pin the note to the organization.', ['0', '1']), + 'pinned_to_lead_flag': ENUM('Pin the note to the lead.', ['0', '1']), +} + + +class NotesMixin(PipedriveToolsBase): + """Tools for the ``notes`` group.""" + + @pipedrive_tool( + group='notes', + input_schema=schema( + **PAGING(), + user_id=INT('Only notes written by this user id.'), + deal_id=INT('Only notes attached to this deal.'), + person_id=INT('Only notes attached to this person.'), + org_id=INT('Only notes attached to this organization.'), + lead_id=STR('Only notes attached to this lead uuid.'), + start_date=STR('Only notes added on or after this date, YYYY-MM-DD.'), + end_date=STR('Only notes added on or before this date, YYYY-MM-DD.'), + sort=STR('Sort clause, e.g. "add_time DESC".'), + ), + description='List notes, optionally filtered by the record they are attached to or by date.', + ) + def note_list(self, args): + args = args_of(args) + return self._list( + '/notes', + args, + clean_note, + extra=params_from( + args, ('user_id', 'deal_id', 'person_id', 'org_id', 'lead_id', 'start_date', 'end_date', 'sort') + ), + ) + + @pipedrive_tool( + group='notes', + input_schema=schema(required=['note_id'], note_id=INT('Note id.')), + description='Get a single note by id.', + ) + def note_get(self, args): + args = args_of(args) + return self._get(f'/notes/{require_id(args, "note_id", "note_get")}', clean_note) + + @pipedrive_tool( + group='notes', + input_schema=schema(required=['content'], **_NOTE_WRITE_PROPS), + description='Create a note. Attach it by passing at least one of deal_id, person_id, org_id, lead_id or project_id.', + ) + def note_create(self, args): + args = args_of(args) + require_text(args, 'content', 'note_create') + return self._write('POST', '/notes', clean_note, body=body_from(args, _NOTE_WRITE_KEYS)) + + @pipedrive_tool( + group='notes', + input_schema=schema(required=['note_id'], note_id=INT('Note id to update.'), **_NOTE_WRITE_PROPS), + description='Update a note.', + ) + def note_update(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_update') + return self._write('PUT', f'/notes/{note_id}', clean_note, body=body_from(args, _NOTE_WRITE_KEYS)) + + @pipedrive_tool( + group='notes', + input_schema=schema(required=['note_id'], note_id=INT('Note id to delete.')), + description='Delete a note.', + ) + def note_delete(self, args): + args = args_of(args) + return self._delete(f'/notes/{require_id(args, "note_id", "note_delete")}') + + # -- comments --------------------------------------------------------- + + @pipedrive_tool( + group='notes', + input_schema=schema(required=['note_id'], note_id=INT('Note id.'), **PAGING()), + description='List comments on a note.', + ) + def note_comment_list(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_comment_list') + return self._list(f'/notes/{note_id}/comments', args, passthrough) + + @pipedrive_tool( + group='notes', + input_schema=schema( + required=['note_id', 'comment_id'], note_id=INT('Note id.'), comment_id=STR('Comment uuid.') + ), + description='Get a single comment on a note.', + ) + def note_comment_get(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_comment_get') + comment_id = require_text(args, 'comment_id', 'note_comment_get') + return self._call('GET', f'/notes/{note_id}/comments/{comment_id}') + + @pipedrive_tool( + group='notes', + input_schema=schema( + required=['note_id', 'content'], note_id=INT('Note id.'), content=STR('Comment body. HTML is accepted.') + ), + description='Add a comment to a note.', + ) + def note_comment_create(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_comment_create') + content = require_text(args, 'content', 'note_comment_create') + return self._write('POST', f'/notes/{note_id}/comments', passthrough, body={'content': content}) + + @pipedrive_tool( + group='notes', + input_schema=schema( + required=['note_id', 'comment_id', 'content'], + note_id=INT('Note id.'), + comment_id=STR('Comment uuid.'), + content=STR('New comment body.'), + ), + description='Update a comment on a note.', + ) + def note_comment_update(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_comment_update') + comment_id = require_text(args, 'comment_id', 'note_comment_update') + content = require_text(args, 'content', 'note_comment_update') + return self._write('PUT', f'/notes/{note_id}/comments/{comment_id}', passthrough, body={'content': content}) + + @pipedrive_tool( + group='notes', + input_schema=schema( + required=['note_id', 'comment_id'], note_id=INT('Note id.'), comment_id=STR('Comment uuid to delete.') + ), + description='Delete a comment from a note.', + ) + def note_comment_delete(self, args): + args = args_of(args) + note_id = require_id(args, 'note_id', 'note_comment_delete') + comment_id = require_text(args, 'comment_id', 'note_comment_delete') + return self._delete(f'/notes/{note_id}/comments/{comment_id}') + + # -- fields ----------------------------------------------------------- + + @pipedrive_tool( + group='notes', + input_schema=schema(**PAGING()), + description='List the note fields available in this account.', + ) + def note_field_list(self, args): + args = args_of(args) + return self._list('/noteFields', args, clean_field) diff --git a/nodes/src/nodes/tool_pipedrive/tools/organizations.py b/nodes/src/nodes/tool_pipedrive/tools/organizations.py new file mode 100644 index 000000000..21f8d4461 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/organizations.py @@ -0,0 +1,403 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Organization tools, plus the organization relationship graph.""" + +from __future__ import annotations + +from ..pipedrive_client import ( + clean_activity, + clean_deal, + clean_file, + clean_mail_message, + clean_organization, + clean_person, + clean_search_item, + paginated, +) +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + paging_params, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_ORG_WRITE_KEYS = ('name', 'owner_id', 'address', 'label', 'visible_to', 'add_time') + +_ORG_WRITE_PROPS = { + 'name': STR('Organization name.'), + 'owner_id': INT('Owner user id. Defaults to the authenticated user.'), + 'address': STR('Postal address as a single line.'), + 'label': INT('Organization label id.'), + 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'extra': EXTRA(), +} + + +class OrganizationsMixin(PipedriveToolsBase): + """Tools for the ``organizations`` and ``org_relationships`` groups.""" + + @pipedrive_tool( + group='organizations', + input_schema=schema( + **PAGING(), + user_id=INT('Only organizations owned by this user id.'), + filter_id=INT('Apply a saved filter by id (see filter_list).'), + first_char=STR('Only organizations whose name starts with this letter.'), + sort=STR('Sort clause, e.g. "update_time DESC, id ASC".'), + ), + description='List organizations (companies), optionally filtered by owner, saved filter or first letter.', + ) + def organization_list(self, args): + args = args_of(args) + return self._list( + '/organizations', + args, + clean_organization, + extra=params_from(args, ('user_id', 'filter_id', 'first_char', 'sort')), + ) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.')), + description='Get a single organization by id, including its custom fields.', + ) + def organization_get(self, args): + args = args_of(args) + return self._get(f'/organizations/{require_id(args, "org_id", "organization_get")}', clean_organization) + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + fields=STR('Comma-separated fields to search in: address, custom_fields, notes, name.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + **PAGING(), + ), + description='Search organizations by name, address, notes or custom field values.', + ) + def organization_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'organization_search') + params.update(params_from(args, ('fields', 'exact_match'))) + envelope = self._call_envelope('GET', '/organizations/search', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + return paginated(envelope, [clean_search_item(i) for i in items]) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['name'], **_ORG_WRITE_PROPS), + description='Create an organization.', + ) + def organization_create(self, args): + args = args_of(args) + require_text(args, 'name', 'organization_create') + return self._write('POST', '/organizations', clean_organization, body=body_from(args, _ORG_WRITE_KEYS)) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id to update.'), **_ORG_WRITE_PROPS), + description='Update an organization. Only the fields you pass are changed.', + ) + def organization_update(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_update') + return self._write('PUT', f'/organizations/{org_id}', clean_organization, body=body_from(args, _ORG_WRITE_KEYS)) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id to delete.')), + description='Delete an organization.', + ) + def organization_delete(self, args): + args = args_of(args) + return self._delete(f'/organizations/{require_id(args, "org_id", "organization_delete")}') + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id', 'merge_with_id'], + org_id=INT('Organization id that will be merged away.'), + merge_with_id=INT('Organization id that survives the merge.'), + ), + description='Merge one organization into another. The source organization is removed.', + ) + def organization_merge(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_merge') + merge_with_id = require_id(args, 'merge_with_id', 'organization_merge') + return self._write( + 'PUT', f'/organizations/{org_id}/merge', clean_organization, body={'merge_with_id': merge_with_id} + ) + + @pipedrive_tool( + group='organizations', + input_schema=schema(ids=ARR('Organization ids to delete.', 'integer')), + description='Delete multiple organizations in one call.', + ) + def organization_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('organization_delete_bulk: "ids" must be a non-empty array of organization ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/organizations', params=params)} + + # -- related records -------------------------------------------------- + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id'], + org_id=INT('Organization id.'), + status=ENUM( + 'Only deals with this status (default all_not_deleted).', + ['open', 'won', 'lost', 'deleted', 'all_not_deleted'], + ), + sort=STR('Sort clause, e.g. "add_time DESC".'), + only_primary_association=INT('Set to 1 to exclude deals linked only through a person.'), + **PAGING(), + ), + description='List deals associated with an organization.', + ) + def organization_deals_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_deals_list') + return self._list( + f'/organizations/{org_id}/deals', + args, + clean_deal, + extra=params_from(args, ('status', 'sort', 'only_primary_association')), + ) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.'), **PAGING()), + description='List persons that belong to an organization.', + ) + def organization_persons_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_persons_list') + return self._list(f'/organizations/{org_id}/persons', args, clean_person) + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id'], + org_id=INT('Organization id.'), + done=INT('0 for pending activities, 1 for completed. Omit for both.'), + exclude=STR('Comma-separated activity ids to leave out.'), + **PAGING(), + ), + description='List activities associated with an organization.', + ) + def organization_activities_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_activities_list') + return self._list( + f'/organizations/{org_id}/activities', args, clean_activity, extra=params_from(args, ('done', 'exclude')) + ) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.'), sort=STR('Sort clause.'), **PAGING()), + description='List files attached to an organization.', + ) + def organization_files_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_files_list') + return self._list(f'/organizations/{org_id}/files', args, clean_file, extra=params_from(args, ('sort',))) + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id'], + org_id=INT('Organization id.'), + all_changes=STR('Set to "1" to include changes from automations and integrations.'), + items=STR('Comma-separated update types to include.'), + **PAGING(), + ), + description='Get the update history (flow) of an organization.', + ) + def organization_updates_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_updates_list') + params = paging_params(args) + params.update(params_from(args, ('all_changes', 'items'))) + envelope = self._call_envelope('GET', f'/organizations/{org_id}/flow', params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + return paginated(envelope, list(data or [])) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.'), **PAGING()), + description='List email messages associated with an organization.', + ) + def organization_mail_messages_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_mail_messages_list') + return self._list(f'/organizations/{org_id}/mailMessages', args, clean_mail_message) + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.')), + description='List users who have permission to see or edit an organization.', + ) + def organization_permitted_users_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_permitted_users_list') + return {'user_ids': self._call('GET', f'/organizations/{org_id}/permittedUsers')} + + # -- followers -------------------------------------------------------- + + @pipedrive_tool( + group='organizations', + input_schema=schema(required=['org_id'], org_id=INT('Organization id.'), **PAGING()), + description='List followers of an organization.', + ) + def organization_followers_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_followers_list') + return self._list(f'/organizations/{org_id}/followers', args, passthrough) + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id', 'user_id'], + org_id=INT('Organization id.'), + user_id=INT('User id to add as a follower.'), + ), + description='Add a follower to an organization.', + ) + def organization_follower_add(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_follower_add') + user_id = require_id(args, 'user_id', 'organization_follower_add') + return self._write('POST', f'/organizations/{org_id}/followers', passthrough, body={'user_id': user_id}) + + @pipedrive_tool( + group='organizations', + input_schema=schema( + required=['org_id', 'follower_id'], + org_id=INT('Organization id.'), + follower_id=INT('Follower id (from organization_followers_list, not the user id).'), + ), + description='Remove a follower from an organization.', + ) + def organization_follower_delete(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'organization_follower_delete') + follower_id = require_id(args, 'follower_id', 'organization_follower_delete') + return self._delete(f'/organizations/{org_id}/followers/{follower_id}') + + # -- relationships (org_relationships group) -------------------------- + + @pipedrive_tool( + group='org_relationships', + input_schema=schema(required=['org_id'], org_id=INT('Organization id whose relationships to list.')), + description='List parent/child relationships of an organization.', + ) + def org_relationship_list(self, args): + args = args_of(args) + org_id = require_id(args, 'org_id', 'org_relationship_list') + data = self._call('GET', '/organizationRelationships', params={'org_id': org_id}) + return {'items': list(data or [])} + + @pipedrive_tool( + group='org_relationships', + input_schema=schema( + required=['relationship_id'], + relationship_id=INT('Relationship id.'), + org_id=INT('Organization id used to decide which side is "linked".'), + ), + description='Get a single organization relationship.', + ) + def org_relationship_get(self, args): + args = args_of(args) + rel_id = require_id(args, 'relationship_id', 'org_relationship_get') + return self._call('GET', f'/organizationRelationships/{rel_id}', params=params_from(args, ('org_id',))) + + @pipedrive_tool( + group='org_relationships', + input_schema=schema( + required=['type', 'rel_owner_org_id', 'rel_linked_org_id'], + type=ENUM('Relationship type.', ['parent', 'related']), + rel_owner_org_id=INT('Organization id that owns the relationship (the parent, for type "parent").'), + rel_linked_org_id=INT('Organization id being linked (the daughter, for type "parent").'), + org_id=INT('Organization id used to decide which side is "linked" in the response.'), + ), + description='Create a relationship between two organizations.', + ) + def org_relationship_create(self, args): + args = args_of(args) + require_text(args, 'type', 'org_relationship_create') + require_id(args, 'rel_owner_org_id', 'org_relationship_create') + require_id(args, 'rel_linked_org_id', 'org_relationship_create') + body = body_from(args, ('type', 'rel_owner_org_id', 'rel_linked_org_id', 'org_id')) + return self._write('POST', '/organizationRelationships', passthrough, body=body) + + @pipedrive_tool( + group='org_relationships', + input_schema=schema( + required=['relationship_id'], + relationship_id=INT('Relationship id to update.'), + type=ENUM('Relationship type.', ['parent', 'related']), + rel_owner_org_id=INT('New owner organization id.'), + rel_linked_org_id=INT('New linked organization id.'), + org_id=INT('Organization id used to decide which side is "linked" in the response.'), + ), + description='Update an organization relationship.', + ) + def org_relationship_update(self, args): + args = args_of(args) + rel_id = require_id(args, 'relationship_id', 'org_relationship_update') + body = body_from(args, ('type', 'rel_owner_org_id', 'rel_linked_org_id', 'org_id')) + return self._write('PUT', f'/organizationRelationships/{rel_id}', passthrough, body=body) + + @pipedrive_tool( + group='org_relationships', + input_schema=schema(required=['relationship_id'], relationship_id=INT('Relationship id to delete.')), + description='Delete an organization relationship.', + ) + def org_relationship_delete(self, args): + args = args_of(args) + rel_id = require_id(args, 'relationship_id', 'org_relationship_delete') + return self._delete(f'/organizationRelationships/{rel_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/persons.py b/nodes/src/nodes/tool_pipedrive/tools/persons.py new file mode 100644 index 000000000..f3c326a59 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/persons.py @@ -0,0 +1,350 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Person tools: contacts, their deals, activities, files and followers.""" + +from __future__ import annotations + +from ..pipedrive_client import ( + clean_activity, + clean_deal, + clean_file, + clean_mail_message, + clean_person, + clean_search_item, + paginated, +) +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + paging_params, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_PERSON_WRITE_KEYS = ( + 'name', + 'owner_id', + 'org_id', + 'email', + 'phone', + 'label', + 'visible_to', + 'marketing_status', + 'add_time', +) + +_CONTACT_ARRAY_DESC = ( + 'Array of contact entries. Each entry is an object like ' + '{"value": "a@example.com", "primary": true, "label": "work"}. ' + 'A plain array of strings is also accepted and the first entry becomes primary.' +) + +_PERSON_WRITE_PROPS = { + 'name': STR('Full name of the person.'), + 'owner_id': INT('Owner user id. Defaults to the authenticated user.'), + 'org_id': INT('Organization id this person belongs to.'), + 'email': {'type': 'array', 'items': {'type': 'object'}, 'description': _CONTACT_ARRAY_DESC}, + 'phone': {'type': 'array', 'items': {'type': 'object'}, 'description': _CONTACT_ARRAY_DESC}, + 'label': INT('Person label id.'), + 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'marketing_status': ENUM( + 'Consent status for marketing emails.', ['no_consent', 'unsubscribed', 'subscribed', 'archived'] + ), + 'extra': EXTRA(), +} + + +def _normalize_contacts(args: dict) -> None: + """Accept a plain list of strings for email/phone and expand it to Pipedrive's shape.""" + for key in ('email', 'phone'): + value = args.get(key) + if isinstance(value, str): + value = [value] + if isinstance(value, list) and value and all(isinstance(v, str) for v in value): + args[key] = [{'value': v, 'primary': i == 0} for i, v in enumerate(value)] + + +class PersonsMixin(PipedriveToolsBase): + """Tools for the ``persons`` group.""" + + @pipedrive_tool( + group='persons', + input_schema=schema( + **PAGING(), + user_id=INT('Only persons owned by this user id.'), + filter_id=INT('Apply a saved filter by id (see filter_list).'), + first_char=STR('Only persons whose name starts with this letter (case-insensitive).'), + sort=STR('Sort clause, e.g. "update_time DESC, id ASC".'), + ), + description='List persons (contacts), optionally filtered by owner, saved filter or first letter.', + ) + def person_list(self, args): + args = args_of(args) + return self._list( + '/persons', args, clean_person, extra=params_from(args, ('user_id', 'filter_id', 'first_char', 'sort')) + ) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.')), + description='Get a single person by id, including emails, phones and custom fields.', + ) + def person_get(self, args): + args = args_of(args) + return self._get(f'/persons/{require_id(args, "person_id", "person_get")}', clean_person) + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + fields=STR('Comma-separated fields to search in: custom_fields, email, notes, phone, name.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + organization_id=INT('Only persons in this organization.'), + include_fields=STR('Extra fields to include, e.g. "person.picture".'), + **PAGING(), + ), + description='Search persons by name, email, phone, notes or custom field values.', + ) + def person_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'person_search') + params.update(params_from(args, ('fields', 'exact_match', 'organization_id', 'include_fields'))) + envelope = self._call_envelope('GET', '/persons/search', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + return paginated(envelope, [clean_search_item(i) for i in items]) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['name'], **_PERSON_WRITE_PROPS), + description='Create a person (contact).', + ) + def person_create(self, args): + args = args_of(args) + require_text(args, 'name', 'person_create') + _normalize_contacts(args) + return self._write('POST', '/persons', clean_person, body=body_from(args, _PERSON_WRITE_KEYS)) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id to update.'), **_PERSON_WRITE_PROPS), + description='Update a person. Only the fields you pass are changed; passing email or phone replaces the whole list.', + ) + def person_update(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_update') + _normalize_contacts(args) + return self._write('PUT', f'/persons/{person_id}', clean_person, body=body_from(args, _PERSON_WRITE_KEYS)) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id to delete.')), + description='Delete a person.', + ) + def person_delete(self, args): + args = args_of(args) + return self._delete(f'/persons/{require_id(args, "person_id", "person_delete")}') + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id', 'merge_with_id'], + person_id=INT('Person id that will be merged away.'), + merge_with_id=INT('Person id that survives the merge.'), + ), + description='Merge one person into another. The source person is removed.', + ) + def person_merge(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_merge') + merge_with_id = require_id(args, 'merge_with_id', 'person_merge') + return self._write('PUT', f'/persons/{person_id}/merge', clean_person, body={'merge_with_id': merge_with_id}) + + @pipedrive_tool( + group='persons', + input_schema=schema(ids=ARR('Person ids to delete.', 'integer')), + description='Delete multiple persons in one call.', + ) + def person_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('person_delete_bulk: "ids" must be a non-empty array of person ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/persons', params=params)} + + # -- related records -------------------------------------------------- + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id'], + person_id=INT('Person id.'), + status=ENUM( + 'Only deals with this status (default all_not_deleted).', + ['open', 'won', 'lost', 'deleted', 'all_not_deleted'], + ), + sort=STR('Sort clause, e.g. "add_time DESC".'), + **PAGING(), + ), + description='List deals associated with a person.', + ) + def person_deals_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_deals_list') + return self._list(f'/persons/{person_id}/deals', args, clean_deal, extra=params_from(args, ('status', 'sort'))) + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id'], + person_id=INT('Person id.'), + done=INT('0 for pending activities, 1 for completed. Omit for both.'), + exclude=STR('Comma-separated activity ids to leave out.'), + **PAGING(), + ), + description='List activities associated with a person.', + ) + def person_activities_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_activities_list') + return self._list( + f'/persons/{person_id}/activities', args, clean_activity, extra=params_from(args, ('done', 'exclude')) + ) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.'), sort=STR('Sort clause.'), **PAGING()), + description='List files attached to a person.', + ) + def person_files_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_files_list') + return self._list(f'/persons/{person_id}/files', args, clean_file, extra=params_from(args, ('sort',))) + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id'], + person_id=INT('Person id.'), + all_changes=STR('Set to "1" to include changes from automations and integrations.'), + items=STR('Comma-separated update types to include.'), + **PAGING(), + ), + description='Get the update history (flow) of a person.', + ) + def person_updates_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_updates_list') + params = paging_params(args) + params.update(params_from(args, ('all_changes', 'items'))) + envelope = self._call_envelope('GET', f'/persons/{person_id}/flow', params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + return paginated(envelope, list(data or [])) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.'), **PAGING()), + description='List email messages associated with a person.', + ) + def person_mail_messages_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_mail_messages_list') + return self._list(f'/persons/{person_id}/mailMessages', args, clean_mail_message) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.'), **PAGING()), + description='List products associated with a person through their deals.', + ) + def person_products_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_products_list') + return self._list(f'/persons/{person_id}/products', args, passthrough) + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.')), + description='List users who have permission to see or edit a person.', + ) + def person_permitted_users_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_permitted_users_list') + return {'user_ids': self._call('GET', f'/persons/{person_id}/permittedUsers')} + + # -- followers -------------------------------------------------------- + + @pipedrive_tool( + group='persons', + input_schema=schema(required=['person_id'], person_id=INT('Person id.'), **PAGING()), + description='List followers of a person.', + ) + def person_followers_list(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_followers_list') + return self._list(f'/persons/{person_id}/followers', args, passthrough) + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id', 'user_id'], person_id=INT('Person id.'), user_id=INT('User id to add as follower.') + ), + description='Add a follower to a person.', + ) + def person_follower_add(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_follower_add') + user_id = require_id(args, 'user_id', 'person_follower_add') + return self._write('POST', f'/persons/{person_id}/followers', passthrough, body={'user_id': user_id}) + + @pipedrive_tool( + group='persons', + input_schema=schema( + required=['person_id', 'follower_id'], + person_id=INT('Person id.'), + follower_id=INT('Follower id (from person_followers_list, not the user id).'), + ), + description='Remove a follower from a person.', + ) + def person_follower_delete(self, args): + args = args_of(args) + person_id = require_id(args, 'person_id', 'person_follower_delete') + follower_id = require_id(args, 'follower_id', 'person_follower_delete') + return self._delete(f'/persons/{person_id}/followers/{follower_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/pipelines.py b/nodes/src/nodes/tool_pipedrive/tools/pipelines.py new file mode 100644 index 000000000..73f5fcfa5 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/pipelines.py @@ -0,0 +1,306 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Pipeline and stage tools, including the conversion and movement reports.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_deal, clean_pipeline, clean_stage +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + INT, + NUM, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + require_id, + require_text, + schema, +) + + +class PipelinesMixin(PipedriveToolsBase): + """Tools for the ``pipelines`` and ``stages`` groups.""" + + # -- pipelines -------------------------------------------------------- + + @pipedrive_tool( + group='pipelines', + input_schema=schema(), + description='List all pipelines. Start here to find the pipeline_id and stage layout of the account.', + ) + def pipeline_list(self, args): + args_of(args) + data = self._call('GET', '/pipelines') + return {'items': [clean_pipeline(p) for p in (data or [])]} + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['pipeline_id'], + pipeline_id=INT('Pipeline id.'), + totals_convert_currency=STR('Convert deal totals to this 3-letter currency code, or "default_currency".'), + ), + description='Get a single pipeline, including per-stage deal totals.', + ) + def pipeline_get(self, args): + args = args_of(args) + pipeline_id = require_id(args, 'pipeline_id', 'pipeline_get') + return self._get( + f'/pipelines/{pipeline_id}', clean_pipeline, params=params_from(args, ('totals_convert_currency',)) + ) + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['name'], + name=STR('Pipeline name.'), + deal_probability=BOOL('Whether deal probability is enabled for this pipeline.'), + order_nr=INT('Display position of the pipeline.'), + active=BOOL('Whether the pipeline is active.'), + ), + description='Create a pipeline.', + ) + def pipeline_create(self, args): + args = args_of(args) + require_text(args, 'name', 'pipeline_create') + return self._write( + 'POST', + '/pipelines', + clean_pipeline, + body=body_from(args, ('name', 'deal_probability', 'order_nr', 'active')), + ) + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['pipeline_id'], + pipeline_id=INT('Pipeline id to update.'), + name=STR('New pipeline name.'), + deal_probability=BOOL('Whether deal probability is enabled.'), + order_nr=INT('New display position.'), + active=BOOL('Whether the pipeline is active.'), + ), + description='Update a pipeline.', + ) + def pipeline_update(self, args): + args = args_of(args) + pipeline_id = require_id(args, 'pipeline_id', 'pipeline_update') + return self._write( + 'PUT', + f'/pipelines/{pipeline_id}', + clean_pipeline, + body=body_from(args, ('name', 'deal_probability', 'order_nr', 'active')), + ) + + @pipedrive_tool( + group='pipelines', + input_schema=schema(required=['pipeline_id'], pipeline_id=INT('Pipeline id to delete.')), + description='Delete a pipeline.', + ) + def pipeline_delete(self, args): + args = args_of(args) + return self._delete(f'/pipelines/{require_id(args, "pipeline_id", "pipeline_delete")}') + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['pipeline_id'], + pipeline_id=INT('Pipeline id.'), + filter_id=INT('Apply a saved filter by id.'), + user_id=INT('Only deals owned by this user id.'), + stage_id=INT('Only deals in this stage.'), + everyone=INT('Set to 1 to include deals owned by everyone, not just the authenticated user.'), + get_summary=INT('Set to 1 to include per-stage summary totals.'), + totals_convert_currency=STR('Convert totals to this 3-letter currency code, or "default_currency".'), + **PAGING(), + ), + description='List deals in a pipeline.', + ) + def pipeline_deals_list(self, args): + args = args_of(args) + pipeline_id = require_id(args, 'pipeline_id', 'pipeline_deals_list') + return self._list( + f'/pipelines/{pipeline_id}/deals', + args, + clean_deal, + extra=params_from( + args, ('filter_id', 'user_id', 'stage_id', 'everyone', 'get_summary', 'totals_convert_currency') + ), + ) + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['pipeline_id', 'start_date', 'end_date'], + pipeline_id=INT('Pipeline id.'), + start_date=STR('Period start, YYYY-MM-DD.'), + end_date=STR('Period end, YYYY-MM-DD.'), + user_id=INT('Only deals owned by this user id.'), + ), + description='Get stage-to-stage conversion rates and win/lost rates for a pipeline over a date range.', + ) + def pipeline_conversion_stats(self, args): + args = args_of(args) + pipeline_id = require_id(args, 'pipeline_id', 'pipeline_conversion_stats') + params = { + 'start_date': require_text(args, 'start_date', 'pipeline_conversion_stats'), + 'end_date': require_text(args, 'end_date', 'pipeline_conversion_stats'), + } + params.update(params_from(args, ('user_id',))) + return self._call('GET', f'/pipelines/{pipeline_id}/conversion_statistics', params=params) + + @pipedrive_tool( + group='pipelines', + input_schema=schema( + required=['pipeline_id', 'start_date', 'end_date'], + pipeline_id=INT('Pipeline id.'), + start_date=STR('Period start, YYYY-MM-DD.'), + end_date=STR('Period end, YYYY-MM-DD.'), + user_id=INT('Only deals owned by this user id.'), + ), + description='Get how deals moved into, through and out of each stage of a pipeline over a date range.', + ) + def pipeline_movement_stats(self, args): + args = args_of(args) + pipeline_id = require_id(args, 'pipeline_id', 'pipeline_movement_stats') + params = { + 'start_date': require_text(args, 'start_date', 'pipeline_movement_stats'), + 'end_date': require_text(args, 'end_date', 'pipeline_movement_stats'), + } + params.update(params_from(args, ('user_id',))) + return self._call('GET', f'/pipelines/{pipeline_id}/movement_statistics', params=params) + + # -- stages ----------------------------------------------------------- + + @pipedrive_tool( + group='stages', + input_schema=schema(pipeline_id=INT('Only stages of this pipeline.'), **PAGING()), + description='List stages, optionally restricted to a single pipeline.', + ) + def stage_list(self, args): + args = args_of(args) + return self._list('/stages', args, clean_stage, extra=params_from(args, ('pipeline_id',))) + + @pipedrive_tool( + group='stages', + input_schema=schema( + required=['stage_id'], + stage_id=INT('Stage id.'), + everyone=INT('Set to 1 to include deal counts for all users.'), + ), + description='Get a single stage.', + ) + def stage_get(self, args): + args = args_of(args) + stage_id = require_id(args, 'stage_id', 'stage_get') + return self._get(f'/stages/{stage_id}', clean_stage, params=params_from(args, ('everyone',))) + + @pipedrive_tool( + group='stages', + input_schema=schema( + required=['name', 'pipeline_id'], + name=STR('Stage name.'), + pipeline_id=INT('Pipeline the stage belongs to.'), + deal_probability=NUM('Success probability percentage for deals in this stage.'), + rotten_flag=BOOL('Whether deals in this stage can go rotten.'), + rotten_days=INT('Days of inactivity before a deal is marked rotten.'), + order_nr=INT('Display position of the stage in the pipeline.'), + ), + description='Create a stage in a pipeline.', + ) + def stage_create(self, args): + args = args_of(args) + require_text(args, 'name', 'stage_create') + require_id(args, 'pipeline_id', 'stage_create') + body = body_from(args, ('name', 'pipeline_id', 'deal_probability', 'rotten_flag', 'rotten_days', 'order_nr')) + return self._write('POST', '/stages', clean_stage, body=body) + + @pipedrive_tool( + group='stages', + input_schema=schema( + required=['stage_id'], + stage_id=INT('Stage id to update.'), + name=STR('New stage name.'), + pipeline_id=INT('Move the stage to this pipeline.'), + deal_probability=NUM('New success probability percentage.'), + rotten_flag=BOOL('Whether deals in this stage can go rotten.'), + rotten_days=INT('Days of inactivity before a deal is marked rotten.'), + order_nr=INT('New display position.'), + ), + description='Update a stage.', + ) + def stage_update(self, args): + args = args_of(args) + stage_id = require_id(args, 'stage_id', 'stage_update') + body = body_from(args, ('name', 'pipeline_id', 'deal_probability', 'rotten_flag', 'rotten_days', 'order_nr')) + return self._write('PUT', f'/stages/{stage_id}', clean_stage, body=body) + + @pipedrive_tool( + group='stages', + input_schema=schema(required=['stage_id'], stage_id=INT('Stage id to delete.')), + description='Delete a stage.', + ) + def stage_delete(self, args): + args = args_of(args) + return self._delete(f'/stages/{require_id(args, "stage_id", "stage_delete")}') + + @pipedrive_tool( + group='stages', + input_schema=schema(ids=ARR('Stage ids to delete.', 'integer')), + description='Delete multiple stages in one call.', + ) + def stage_delete_bulk(self, args): + args = args_of(args) + ids = args.get('ids') or [] + if not isinstance(ids, list) or not ids: + raise ValueError('stage_delete_bulk: "ids" must be a non-empty array of stage ids') + self._require_write() + params = {'ids': ','.join(str(int(i)) for i in ids)} + return {'deleted': True, 'data': self._call('DELETE', '/stages', params=params)} + + @pipedrive_tool( + group='stages', + input_schema=schema( + required=['stage_id'], + stage_id=INT('Stage id.'), + filter_id=INT('Apply a saved filter by id.'), + user_id=INT('Only deals owned by this user id.'), + everyone=INT('Set to 1 to include deals owned by everyone.'), + **PAGING(), + ), + description='List deals sitting in a stage.', + ) + def stage_deals_list(self, args): + args = args_of(args) + stage_id = require_id(args, 'stage_id', 'stage_deals_list') + return self._list( + f'/stages/{stage_id}/deals', args, clean_deal, extra=params_from(args, ('filter_id', 'user_id', 'everyone')) + ) diff --git a/nodes/src/nodes/tool_pipedrive/tools/products.py b/nodes/src/nodes/tool_pipedrive/tools/products.py new file mode 100644 index 000000000..e7c4b62c8 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/products.py @@ -0,0 +1,325 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Product catalogue tools, including variations and per-product followers.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_deal, clean_file, clean_product, clean_search_item, paginated +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + NUM, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + paging_params, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_PRODUCT_WRITE_KEYS = ( + 'name', + 'code', + 'description', + 'unit', + 'tax', + 'category', + 'active_flag', + 'selectable', + 'visible_to', + 'owner_id', + 'prices', + 'billing_frequency', + 'billing_frequency_cycles', +) + +_PRODUCT_WRITE_PROPS = { + 'name': STR('Product name.'), + 'code': STR('Product code / SKU.'), + 'description': STR('Product description.'), + 'unit': STR('Unit the product is sold in, e.g. "licence" or "hour".'), + 'tax': NUM('Default tax percentage.'), + 'category': STR('Product category.'), + 'active_flag': BOOL('Whether the product can be added to deals.'), + 'selectable': BOOL('Whether the product is visible in the product picker.'), + 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'owner_id': INT('Owner user id.'), + 'prices': { + 'type': 'array', + 'items': {'type': 'object'}, + 'description': 'Prices per currency, e.g. [{"currency": "USD", "price": 100, "cost": 40}].', + }, + 'billing_frequency': ENUM( + 'Billing cadence for recurring products.', + ['one-time', 'annually', 'semi-annually', 'quarterly', 'monthly', 'weekly'], + ), + 'billing_frequency_cycles': INT('How many billing cycles the product runs for.'), + 'extra': EXTRA(), +} + + +class ProductsMixin(PipedriveToolsBase): + """Tools for the ``products`` group.""" + + @pipedrive_tool( + group='products', + input_schema=schema( + **PAGING(), + user_id=INT('Only products owned by this user id.'), + filter_id=INT('Apply a saved filter by id.'), + ids=ARR('Only these product ids.', 'integer'), + first_char=STR('Only products whose name starts with this letter.'), + get_summary=BOOL('Include the total product count in the response.'), + ), + description='List products from the catalogue.', + ) + def product_list(self, args): + args = args_of(args) + extra = params_from(args, ('user_id', 'filter_id', 'first_char', 'get_summary')) + if isinstance(args.get('ids'), list) and args['ids']: + extra['ids'] = ','.join(str(int(i)) for i in args['ids']) + return self._list('/products', args, clean_product, extra=extra) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id.')), + description='Get a single product by id, including its prices.', + ) + def product_get(self, args): + args = args_of(args) + return self._get(f'/products/{require_id(args, "product_id", "product_get")}', clean_product) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + fields=STR('Comma-separated fields to search in: code, custom_fields, name.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + include_fields=STR('Extra fields to include, e.g. "product.price".'), + **PAGING(), + ), + description='Search products by name, code or custom field values.', + ) + def product_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'product_search') + params.update(params_from(args, ('fields', 'exact_match', 'include_fields'))) + envelope = self._call_envelope('GET', '/products/search', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + return paginated(envelope, [clean_search_item(i) for i in items]) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['name'], **_PRODUCT_WRITE_PROPS), + description='Create a product in the catalogue.', + ) + def product_create(self, args): + args = args_of(args) + require_text(args, 'name', 'product_create') + return self._write('POST', '/products', clean_product, body=body_from(args, _PRODUCT_WRITE_KEYS)) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id to update.'), **_PRODUCT_WRITE_PROPS), + description='Update a product.', + ) + def product_update(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_update') + return self._write('PUT', f'/products/{product_id}', clean_product, body=body_from(args, _PRODUCT_WRITE_KEYS)) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id to delete.')), + description='Delete a product from the catalogue.', + ) + def product_delete(self, args): + args = args_of(args) + return self._delete(f'/products/{require_id(args, "product_id", "product_delete")}') + + # -- related records -------------------------------------------------- + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id'], + product_id=INT('Product id.'), + status=ENUM( + 'Only deals with this status (default all_not_deleted).', + ['open', 'won', 'lost', 'deleted', 'all_not_deleted'], + ), + **PAGING(), + ), + description='List deals a product is attached to.', + ) + def product_deals_list(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_deals_list') + return self._list(f'/products/{product_id}/deals', args, clean_deal, extra=params_from(args, ('status',))) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id.'), **PAGING()), + description='List files attached to a product.', + ) + def product_files_list(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_files_list') + return self._list(f'/products/{product_id}/files', args, clean_file) + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id.')), + description='List users who have permission to see or edit a product.', + ) + def product_permitted_users_list(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_permitted_users_list') + return {'user_ids': self._call('GET', f'/products/{product_id}/permittedUsers')} + + # -- followers -------------------------------------------------------- + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id.'), **PAGING()), + description='List followers of a product.', + ) + def product_followers_list(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_followers_list') + return self._list(f'/products/{product_id}/followers', args, passthrough) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id', 'user_id'], + product_id=INT('Product id.'), + user_id=INT('User id to add as a follower.'), + ), + description='Add a follower to a product.', + ) + def product_follower_add(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_follower_add') + user_id = require_id(args, 'user_id', 'product_follower_add') + return self._write('POST', f'/products/{product_id}/followers', passthrough, body={'user_id': user_id}) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id', 'follower_id'], + product_id=INT('Product id.'), + follower_id=INT('Follower id (from product_followers_list, not the user id).'), + ), + description='Remove a follower from a product.', + ) + def product_follower_delete(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_follower_delete') + follower_id = require_id(args, 'follower_id', 'product_follower_delete') + return self._delete(f'/products/{product_id}/followers/{follower_id}') + + # -- variations ------------------------------------------------------- + + @pipedrive_tool( + group='products', + input_schema=schema(required=['product_id'], product_id=INT('Product id.'), **PAGING()), + description='List the variations of a product.', + ) + def product_variation_list(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_variation_list') + return self._list(f'/products/{product_id}/variations', args, passthrough) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id', 'name'], + product_id=INT('Product id.'), + name=STR('Variation name.'), + prices={ + 'type': 'array', + 'items': {'type': 'object'}, + 'description': 'Prices per currency, e.g. [{"currency": "USD", "price": 120}].', + }, + ), + description='Create a product variation.', + ) + def product_variation_create(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_variation_create') + require_text(args, 'name', 'product_variation_create') + return self._write( + 'POST', f'/products/{product_id}/variations', passthrough, body=body_from(args, ('name', 'prices')) + ) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id', 'variation_id'], + product_id=INT('Product id.'), + variation_id=INT('Variation id.'), + name=STR('New variation name.'), + prices={'type': 'array', 'items': {'type': 'object'}, 'description': 'New prices per currency.'}, + ), + description='Update a product variation.', + ) + def product_variation_update(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_variation_update') + variation_id = require_id(args, 'variation_id', 'product_variation_update') + return self._write( + 'PUT', + f'/products/{product_id}/variations/{variation_id}', + dict, + body=body_from(args, ('name', 'prices')), + ) + + @pipedrive_tool( + group='products', + input_schema=schema( + required=['product_id', 'variation_id'], + product_id=INT('Product id.'), + variation_id=INT('Variation id to delete.'), + ), + description='Delete a product variation.', + ) + def product_variation_delete(self, args): + args = args_of(args) + product_id = require_id(args, 'product_id', 'product_variation_delete') + variation_id = require_id(args, 'variation_id', 'product_variation_delete') + return self._delete(f'/products/{product_id}/variations/{variation_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/projects.py b/nodes/src/nodes/tool_pipedrive/tools/projects.py new file mode 100644 index 000000000..eb63d8b39 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/projects.py @@ -0,0 +1,395 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +""" +Project tools: projects, their plans, tasks, phases, boards and templates. + +The projects endpoints use cursor pagination rather than the ``start``/``limit`` +offsets the rest of v1 uses, so these tools take a ``cursor`` and return +``next_cursor``. +""" + +from __future__ import annotations + +from ..pipedrive_client import MAX_LIMIT, clean_activity, clean_project, clean_task +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + INT, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +_CURSOR_PROPS = { + 'cursor': STR('Pagination cursor from a previous call. Omit for the first page.'), + 'limit': INT(f'Number of records to return (1-{MAX_LIMIT}, default 100).'), +} + +_PROJECT_WRITE_KEYS = ( + 'title', + 'board_id', + 'phase_id', + 'description', + 'status', + 'owner_id', + 'start_date', + 'end_date', + 'deal_ids', + 'org_id', + 'person_id', + 'labels', + 'template_id', +) + +_PROJECT_WRITE_PROPS = { + 'title': STR('Project title.'), + 'board_id': INT('Board the project belongs to.'), + 'phase_id': INT('Phase within the board.'), + 'description': STR('Project description.'), + 'status': ENUM('Project status.', ['open', 'completed', 'canceled', 'deleted']), + 'owner_id': INT('Owner user id.'), + 'start_date': STR('Start date, YYYY-MM-DD.'), + 'end_date': STR('End date, YYYY-MM-DD.'), + 'deal_ids': ARR('Deal ids linked to the project.', 'integer'), + 'org_id': INT('Linked organization id.'), + 'person_id': INT('Linked person id.'), + 'labels': ARR('Project label ids.', 'integer'), + 'template_id': INT('Project template to create from.'), + 'extra': EXTRA(), +} + +_TASK_WRITE_KEYS = ( + 'title', + 'project_id', + 'description', + 'parent_task_id', + 'assignee_id', + 'done', + 'due_date', +) + +_TASK_WRITE_PROPS = { + 'title': STR('Task title.'), + 'project_id': INT('Project the task belongs to.'), + 'description': STR('Task description.'), + 'parent_task_id': INT('Parent task id, for subtasks.'), + 'assignee_id': INT('User assigned to the task.'), + 'done': INT('0 for open, 1 for done.'), + 'due_date': STR('Due date, YYYY-MM-DD.'), + 'extra': EXTRA(), +} + + +class ProjectsMixin(PipedriveToolsBase): + """Tools for the ``projects`` group.""" + + def _cursor_list(self, path: str, args: dict, cleaner, *, extra: dict | None = None) -> dict: + params: dict = {} + if args.get('cursor'): + params['cursor'] = args['cursor'] + if args.get('limit') is not None: + params['limit'] = max(1, min(int(args['limit']), MAX_LIMIT)) + if extra: + params.update(extra) + envelope = self._call_envelope('GET', path, params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + items = [cleaner(item) for item in (data or [])] + additional = (envelope.get('additional_data') or {}) if isinstance(envelope, dict) else {} + return { + 'items': items, + 'count': len(items), + 'next_cursor': additional.get('next_cursor'), + } + + # -- projects --------------------------------------------------------- + + @pipedrive_tool( + group='projects', + input_schema=schema( + **_CURSOR_PROPS, + filter_id=INT('Apply a saved filter by id.'), + status=STR('Comma-separated statuses to include: open, completed, canceled, deleted.'), + phase_id=INT('Only projects in this phase.'), + include_archived=BOOL('Include archived projects.'), + ), + description='List projects.', + ) + def project_list(self, args): + args = args_of(args) + return self._cursor_list( + '/projects', + args, + clean_project, + extra=params_from(args, ('filter_id', 'status', 'phase_id', 'include_archived')), + ) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id.')), + description='Get a single project.', + ) + def project_get(self, args): + args = args_of(args) + return self._get(f'/projects/{require_id(args, "project_id", "project_get")}', clean_project) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['title', 'board_id', 'phase_id'], **_PROJECT_WRITE_PROPS), + description='Create a project.', + ) + def project_create(self, args): + args = args_of(args) + require_text(args, 'title', 'project_create') + require_id(args, 'board_id', 'project_create') + require_id(args, 'phase_id', 'project_create') + return self._write('POST', '/projects', clean_project, body=body_from(args, _PROJECT_WRITE_KEYS)) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id to update.'), **_PROJECT_WRITE_PROPS), + description='Update a project.', + ) + def project_update(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_update') + return self._write('PUT', f'/projects/{project_id}', clean_project, body=body_from(args, _PROJECT_WRITE_KEYS)) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id to delete.')), + description='Delete a project.', + ) + def project_delete(self, args): + args = args_of(args) + return self._delete(f'/projects/{require_id(args, "project_id", "project_delete")}') + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id to archive.')), + description='Archive a project.', + ) + def project_archive(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_archive') + return self._write('POST', f'/projects/{project_id}/archive', clean_project) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id.')), + description='Get the plan of a project: its tasks and activities with their scheduled dates.', + ) + def project_plan_get(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_plan_get') + data = self._call('GET', f'/projects/{project_id}/plan') + return {'items': list(data or [])} + + @pipedrive_tool( + group='projects', + input_schema=schema( + required=['project_id', 'activity_id'], + project_id=INT('Project id.'), + activity_id=INT('Activity id in the plan.'), + phase_id=INT('Move the activity to this phase.'), + group_id=INT('Move the activity to this group.'), + ), + description='Move an activity to another phase or group within a project plan.', + ) + def project_plan_activity_update(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_plan_activity_update') + activity_id = require_id(args, 'activity_id', 'project_plan_activity_update') + return self._write( + 'PUT', + f'/projects/{project_id}/plan/activities/{activity_id}', + dict, + body=body_from(args, ('phase_id', 'group_id')), + ) + + @pipedrive_tool( + group='projects', + input_schema=schema( + required=['project_id', 'task_id'], + project_id=INT('Project id.'), + task_id=INT('Task id in the plan.'), + phase_id=INT('Move the task to this phase.'), + group_id=INT('Move the task to this group.'), + ), + description='Move a task to another phase or group within a project plan.', + ) + def project_plan_task_update(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_plan_task_update') + task_id = require_id(args, 'task_id', 'project_plan_task_update') + return self._write( + 'PUT', + f'/projects/{project_id}/plan/tasks/{task_id}', + dict, + body=body_from(args, ('phase_id', 'group_id')), + ) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id.')), + description='List the groups of a project.', + ) + def project_groups_list(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_groups_list') + data = self._call('GET', f'/projects/{project_id}/groups') + return {'items': list(data or [])} + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['project_id'], project_id=INT('Project id.')), + description='List the activities of a project.', + ) + def project_activities_list(self, args): + args = args_of(args) + project_id = require_id(args, 'project_id', 'project_activities_list') + data = self._call('GET', f'/projects/{project_id}/activities') + return {'items': [clean_activity(a) for a in (data or [])]} + + # -- tasks ------------------------------------------------------------ + + @pipedrive_tool( + group='projects', + input_schema=schema(**_CURSOR_PROPS, project_id=INT('Only tasks of this project.')), + description='List project tasks.', + ) + def project_task_list(self, args): + args = args_of(args) + return self._cursor_list('/tasks', args, clean_task, extra=params_from(args, ('project_id',))) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['task_id'], task_id=INT('Task id.')), + description='Get a single project task.', + ) + def project_task_get(self, args): + args = args_of(args) + return self._get(f'/tasks/{require_id(args, "task_id", "project_task_get")}', clean_task) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['title', 'project_id'], **_TASK_WRITE_PROPS), + description='Create a project task.', + ) + def project_task_create(self, args): + args = args_of(args) + require_text(args, 'title', 'project_task_create') + require_id(args, 'project_id', 'project_task_create') + return self._write('POST', '/tasks', clean_task, body=body_from(args, _TASK_WRITE_KEYS)) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['task_id'], task_id=INT('Task id to update.'), **_TASK_WRITE_PROPS), + description='Update a project task. Pass done=1 to complete it.', + ) + def project_task_update(self, args): + args = args_of(args) + task_id = require_id(args, 'task_id', 'project_task_update') + return self._write('PUT', f'/tasks/{task_id}', clean_task, body=body_from(args, _TASK_WRITE_KEYS)) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['task_id'], task_id=INT('Task id to delete.')), + description='Delete a project task.', + ) + def project_task_delete(self, args): + args = args_of(args) + return self._delete(f'/tasks/{require_id(args, "task_id", "project_task_delete")}') + + # -- boards, phases and templates ------------------------------------- + + @pipedrive_tool( + group='projects', + input_schema=schema(), + description='List project boards. Board ids are needed to create a project.', + ) + def project_board_list(self, args): + args_of(args) + data = self._call('GET', '/projects/boards') + return {'items': list(data or [])} + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['board_id'], board_id=INT('Board id.')), + description='Get a single project board.', + ) + def project_board_get(self, args): + args = args_of(args) + board_id = require_id(args, 'board_id', 'project_board_get') + return self._call('GET', f'/projects/boards/{board_id}') + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['board_id'], board_id=INT('Board id whose phases to list.')), + description='List the phases of a project board. Phase ids are needed to create a project.', + ) + def project_phase_list(self, args): + args = args_of(args) + board_id = require_id(args, 'board_id', 'project_phase_list') + data = self._call('GET', '/projects/phases', params={'board_id': board_id}) + return {'items': list(data or [])} + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['phase_id'], phase_id=INT('Phase id.')), + description='Get a single project phase.', + ) + def project_phase_get(self, args): + args = args_of(args) + phase_id = require_id(args, 'phase_id', 'project_phase_get') + return self._call('GET', f'/projects/phases/{phase_id}') + + @pipedrive_tool( + group='projects', + input_schema=schema(**_CURSOR_PROPS), + description='List project templates.', + ) + def project_template_list(self, args): + args = args_of(args) + return self._cursor_list('/projectTemplates', args, passthrough) + + @pipedrive_tool( + group='projects', + input_schema=schema(required=['template_id'], template_id=INT('Template id.')), + description='Get a single project template.', + ) + def project_template_get(self, args): + args = args_of(args) + template_id = require_id(args, 'template_id', 'project_template_get') + return self._call('GET', f'/projectTemplates/{template_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/roles.py b/nodes/src/nodes/tool_pipedrive/tools/roles.py new file mode 100644 index 000000000..5685ff9fc --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/roles.py @@ -0,0 +1,247 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Role and permission-set tools (admin surface).""" + +from __future__ import annotations + +from ..pipedrive_client import clean_role, clean_user +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + + +class RolesMixin(PipedriveToolsBase): + """Tools for the ``roles`` and ``permission_sets`` groups.""" + + # -- roles ------------------------------------------------------------ + + @pipedrive_tool( + group='roles', + input_schema=schema(**PAGING()), + description='List the roles configured in the account.', + ) + def role_list(self, args): + args = args_of(args) + return self._list('/roles', args, clean_role) + + @pipedrive_tool( + group='roles', + input_schema=schema(required=['role_id'], role_id=INT('Role id.')), + description='Get a single role.', + ) + def role_get(self, args): + args = args_of(args) + return self._get(f'/roles/{require_id(args, "role_id", "role_get")}', clean_role) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['name'], name=STR('Role name.'), parent_role_id=INT('Parent role id, for nested roles.') + ), + description='Create a role.', + ) + def role_create(self, args): + args = args_of(args) + require_text(args, 'name', 'role_create') + return self._write('POST', '/roles', clean_role, body=body_from(args, ('name', 'parent_role_id'))) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id'], + role_id=INT('Role id to update.'), + name=STR('New role name.'), + parent_role_id=INT('New parent role id.'), + ), + description='Update a role.', + ) + def role_update(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_update') + return self._write('PUT', f'/roles/{role_id}', clean_role, body=body_from(args, ('name', 'parent_role_id'))) + + @pipedrive_tool( + group='roles', + input_schema=schema(required=['role_id'], role_id=INT('Role id to delete.')), + description='Delete a role.', + ) + def role_delete(self, args): + args = args_of(args) + return self._delete(f'/roles/{require_id(args, "role_id", "role_delete")}') + + @pipedrive_tool( + group='roles', + input_schema=schema(required=['role_id'], role_id=INT('Role id.'), **PAGING()), + description='List the sub-roles of a role.', + ) + def role_sub_roles_list(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_sub_roles_list') + return self._list(f'/roles/{role_id}/roles', args, clean_role) + + @pipedrive_tool( + group='roles', + input_schema=schema(required=['role_id'], role_id=INT('Role id.'), **PAGING()), + description='List the users assigned to a role.', + ) + def role_assignments_list(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_assignments_list') + return self._list(f'/roles/{role_id}/assignments', args, clean_user) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id', 'user_id'], role_id=INT('Role id.'), user_id=INT('User id to assign to the role.') + ), + description='Assign a user to a role.', + ) + def role_assignment_add(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_assignment_add') + user_id = require_id(args, 'user_id', 'role_assignment_add') + return self._write('POST', f'/roles/{role_id}/assignments', passthrough, body={'user_id': user_id}) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id', 'user_id'], role_id=INT('Role id.'), user_id=INT('User id to remove from the role.') + ), + description='Remove a user from a role.', + ) + def role_assignment_delete(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_assignment_delete') + user_id = require_id(args, 'user_id', 'role_assignment_delete') + self._require_write() + # Pipedrive reads the payload of this DELETE as form data, not JSON. + data = self._call('DELETE', f'/roles/{role_id}/assignments', form={'user_id': user_id}) + return {'deleted': True, 'data': data} + + @pipedrive_tool( + group='roles', + input_schema=schema(required=['role_id'], role_id=INT('Role id.')), + description='Get the visibility settings of a role.', + ) + def role_settings_get(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_settings_get') + return self._call('GET', f'/roles/{role_id}/settings') + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id', 'setting_key', 'value'], + role_id=INT('Role id.'), + setting_key=STR( + 'Setting to change, e.g. "deal_default_visibility", "org_default_visibility", ' + '"person_default_visibility", "product_default_visibility", "deal_access_level".' + ), + value=INT('New value for the setting.'), + ), + description='Change one visibility setting of a role.', + ) + def role_setting_set(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_setting_set') + key = require_text(args, 'setting_key', 'role_setting_set') + value = require_id(args, 'value', 'role_setting_set') + return self._write('POST', f'/roles/{role_id}/settings', passthrough, body={'setting_key': key, 'value': value}) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id'], + role_id=INT('Role id.'), + visible=INT('1 for pipelines visible to the role, 0 for hidden ones.'), + ), + description='List which pipelines a role can see.', + ) + def role_pipelines_list(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_pipelines_list') + return self._call('GET', f'/roles/{role_id}/pipelines', params=params_from(args, ('visible',))) + + @pipedrive_tool( + group='roles', + input_schema=schema( + required=['role_id', 'visible_pipeline_ids'], + role_id=INT('Role id.'), + visible_pipeline_ids=ARR('Pipeline ids the role may see.', 'integer'), + ), + description='Set which pipelines a role can see.', + ) + def role_pipelines_set(self, args): + args = args_of(args) + role_id = require_id(args, 'role_id', 'role_pipelines_set') + ids = args.get('visible_pipeline_ids') + if not isinstance(ids, list): + raise ValueError('role_pipelines_set: "visible_pipeline_ids" must be an array of pipeline ids') + return self._write('PUT', f'/roles/{role_id}/pipelines', passthrough, body={'visible_pipeline_ids': ids}) + + # -- permission sets -------------------------------------------------- + + @pipedrive_tool( + group='permission_sets', + input_schema=schema(app=STR('Only permission sets for this app: sales, projects, campaigns or global.')), + description='List the permission sets available in the account.', + ) + def permission_set_list(self, args): + args = args_of(args) + data = self._call('GET', '/permissionSets', params=params_from(args, ('app',))) + return {'items': list(data or [])} + + @pipedrive_tool( + group='permission_sets', + input_schema=schema(required=['permission_set_id'], permission_set_id=STR('Permission set id.')), + description='Get a single permission set.', + ) + def permission_set_get(self, args): + args = args_of(args) + set_id = require_text(args, 'permission_set_id', 'permission_set_get') + return self._call('GET', f'/permissionSets/{set_id}') + + @pipedrive_tool( + group='permission_sets', + input_schema=schema(required=['permission_set_id'], permission_set_id=STR('Permission set id.'), **PAGING()), + description='List the users assigned to a permission set.', + ) + def permission_set_assignments_list(self, args): + args = args_of(args) + set_id = require_text(args, 'permission_set_id', 'permission_set_assignments_list') + return self._list(f'/permissionSets/{set_id}/assignments', args, clean_user) diff --git a/nodes/src/nodes/tool_pipedrive/tools/search.py b/nodes/src/nodes/tool_pipedrive/tools/search.py new file mode 100644 index 000000000..963ab97a4 --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/search.py @@ -0,0 +1,171 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Global search tools that span every Pipedrive item type.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_search_item, paginated +from ..tool_groups import pipedrive_tool +from ._base import ( + BOOL, + ENUM, + INT, + PAGING, + STR, + PipedriveToolsBase, + args_of, + paging_params, + params_from, + require_text, + schema, +) + + +class SearchMixin(PipedriveToolsBase): + """Tools for the ``search`` group.""" + + @pipedrive_tool( + group='search', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters (1 when exact_match is true).'), + item_types=STR( + 'Comma-separated item types to search: deal, person, organization, product, lead, file, ' + 'mail_attachment, project. Omit to search everything.' + ), + fields=STR( + 'Comma-separated fields to search in, e.g. "name,title,notes,custom_fields". ' + 'Must be valid for the chosen item_types.' + ), + search_for_related_items=BOOL('Also return the deals and persons related to each match.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + include_fields=STR('Extra fields to include in the results, e.g. "deal.cc_email".'), + **PAGING(), + ), + description='Search across every Pipedrive item type at once — deals, persons, organizations, products, leads, files and projects. Use this when you do not know which record type holds the answer.', + ) + def item_search(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'item_search') + params.update( + params_from(args, ('item_types', 'fields', 'search_for_related_items', 'exact_match', 'include_fields')) + ) + envelope = self._call_envelope('GET', '/itemSearch', params=params) + data = envelope.get('data') or {} + items = data.get('items') or [] + result = paginated(envelope, [clean_search_item(i) for i in items]) + if data.get('related_items'): + result['related_items'] = [clean_search_item(i) for i in data['related_items']] + return result + + @pipedrive_tool( + group='search', + input_schema=schema( + required=['term', 'field_type', 'field_key'], + term=STR('Value to look for in the field.'), + field_type=ENUM( + 'Which entity the field belongs to.', + [ + 'dealField', + 'leadField', + 'personField', + 'organizationField', + 'productField', + 'projectField', + ], + ), + field_key=STR('Key of the field to search, e.g. "title" or a 40-character custom field key.'), + exact_match=BOOL('Require an exact, case-sensitive match.'), + return_item_ids=BOOL('Return matching item ids instead of distinct field values.'), + **PAGING(), + ), + description='Search a single field for a value — for example find every deal whose custom "Contract number" field starts with a string. Returns distinct field values by default.', + ) + def item_search_by_field(self, args): + args = args_of(args) + params = paging_params(args) + params['term'] = require_text(args, 'term', 'item_search_by_field') + params['field_type'] = require_text(args, 'field_type', 'item_search_by_field') + params['field_key'] = require_text(args, 'field_key', 'item_search_by_field') + params.update(params_from(args, ('exact_match', 'return_item_ids'))) + envelope = self._call_envelope('GET', '/itemSearch/field', params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + return paginated(envelope, list(data or [])) + + @pipedrive_tool( + group='search', + input_schema=schema( + required=['since_timestamp'], + since_timestamp=STR('Return everything changed after this UTC timestamp, "YYYY-MM-DD HH:MM:SS".'), + items=STR( + 'Comma-separated item types to include: activity, activityType, deal, file, filter, note, ' + 'person, organization, pipeline, product, stage, user.' + ), + **PAGING(), + ), + description='List every record created or changed since a timestamp. Use this to sync a CRM snapshot incrementally instead of re-listing everything.', + ) + def recents_list(self, args): + args = args_of(args) + params = paging_params(args) + params['since_timestamp'] = require_text(args, 'since_timestamp', 'recents_list') + params.update(params_from(args, ('items',))) + envelope = self._call_envelope('GET', '/recents', params=params) + data = envelope.get('data') if isinstance(envelope, dict) else None + return paginated(envelope, list(data or [])) + + @pipedrive_tool( + group='search', + input_schema=schema( + required=['term'], + term=STR('Search term, at least 2 characters.'), + item_types=STR('Comma-separated item types to search. Omit to search everything.'), + limit=INT('Maximum number of matches to return (default 10).'), + ), + description='Quick global lookup that returns just the id, type and name of each match. Cheaper than item_search when you only need to resolve a name to an id.', + ) + def lookup(self, args): + args = args_of(args) + params = { + 'term': require_text(args, 'term', 'lookup'), + 'limit': max(1, min(int(args.get('limit') or 10), 100)), + } + params.update(params_from(args, ('item_types',))) + envelope = self._call_envelope('GET', '/itemSearch', params=params) + items = ((envelope.get('data') or {}).get('items')) or [] + out = [] + for entry in items: + inner = entry.get('item') if isinstance(entry, dict) else None + if isinstance(inner, dict): + out.append( + { + 'id': inner.get('id'), + 'type': inner.get('type'), + 'name': inner.get('name') or inner.get('title'), + } + ) + return {'items': out, 'count': len(out)} diff --git a/nodes/src/nodes/tool_pipedrive/tools/subscriptions.py b/nodes/src/nodes/tool_pipedrive/tools/subscriptions.py new file mode 100644 index 000000000..680b02f2e --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/subscriptions.py @@ -0,0 +1,207 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Revenue subscription tools: recurring and installment payment plans on deals.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_subscription +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + BOOL, + ENUM, + INT, + NUM, + STR, + PipedriveToolsBase, + args_of, + body_from, + require_id, + require_text, + schema, +) + +_PAYMENTS_DESC = 'Installment payments, e.g. [{"amount": 100, "description": "Deposit", "due_at": "2026-03-01"}].' + + +class SubscriptionsMixin(PipedriveToolsBase): + """Tools for the ``subscriptions`` group.""" + + @pipedrive_tool( + group='subscriptions', + input_schema=schema(required=['subscription_id'], subscription_id=INT('Subscription id.')), + description='Get a single revenue subscription.', + ) + def subscription_get(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_get') + return self._get(f'/subscriptions/{sub_id}', clean_subscription) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.')), + description='Find the subscription attached to a deal.', + ) + def subscription_find_by_deal(self, args): + args = args_of(args) + deal_id = require_id(args, 'deal_id', 'subscription_find_by_deal') + return self._get(f'/subscriptions/find/{deal_id}', clean_subscription) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema(required=['subscription_id'], subscription_id=INT('Subscription id.')), + description='List the payments of a subscription.', + ) + def subscription_payments_list(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_payments_list') + data = self._call('GET', f'/subscriptions/{sub_id}/payments') + return {'items': list(data or [])} + + @pipedrive_tool( + group='subscriptions', + input_schema=schema( + required=['deal_id', 'currency', 'cadence_type', 'cycle_amount', 'start_date'], + deal_id=INT('Deal the subscription belongs to.'), + currency=STR('3-letter currency code.'), + description=STR('Subscription description.'), + cadence_type=ENUM('Billing cadence.', ['weekly', 'monthly', 'quarterly', 'yearly']), + cycles_count=INT('How many billing cycles to run. Omit together with infinite=true.'), + cycle_amount=NUM('Amount charged per cycle.'), + start_date=STR('Date of the first payment, YYYY-MM-DD.'), + infinite=BOOL('Whether the subscription runs forever.'), + update_deal_value=BOOL('Update the deal value to match the subscription total.'), + ), + description='Create a recurring revenue subscription on a deal.', + ) + def subscription_recurring_create(self, args): + args = args_of(args) + require_id(args, 'deal_id', 'subscription_recurring_create') + for key in ('currency', 'cadence_type', 'start_date'): + require_text(args, key, 'subscription_recurring_create') + body = body_from( + args, + ( + 'deal_id', + 'currency', + 'description', + 'cadence_type', + 'cycles_count', + 'cycle_amount', + 'start_date', + 'infinite', + 'update_deal_value', + ), + ) + return self._write('POST', '/subscriptions/recurring', clean_subscription, body=body) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema( + required=['deal_id', 'currency', 'payments'], + deal_id=INT('Deal the subscription belongs to.'), + currency=STR('3-letter currency code.'), + payments=ARR(_PAYMENTS_DESC, 'object'), + update_deal_value=BOOL('Update the deal value to match the payment total.'), + ), + description='Create an installment subscription on a deal.', + ) + def subscription_installment_create(self, args): + args = args_of(args) + require_id(args, 'deal_id', 'subscription_installment_create') + require_text(args, 'currency', 'subscription_installment_create') + if not isinstance(args.get('payments'), list) or not args['payments']: + raise ValueError('subscription_installment_create: "payments" must be a non-empty array') + body = body_from(args, ('deal_id', 'currency', 'payments', 'update_deal_value')) + return self._write('POST', '/subscriptions/installment', clean_subscription, body=body) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema( + required=['subscription_id', 'effective_date'], + subscription_id=INT('Recurring subscription id.'), + effective_date=STR('Date the change takes effect, YYYY-MM-DD.'), + cycle_amount=NUM('New amount per cycle.'), + cycles_count=INT('New number of cycles.'), + description=STR('New description.'), + payments=ARR('Replacement payment schedule.', 'object'), + update_deal_value=BOOL('Update the deal value to match.'), + ), + description='Update a recurring subscription from a given date onward.', + ) + def subscription_recurring_update(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_recurring_update') + require_text(args, 'effective_date', 'subscription_recurring_update') + body = body_from( + args, + ('description', 'cycle_amount', 'cycles_count', 'payments', 'update_deal_value', 'effective_date'), + ) + return self._write('PUT', f'/subscriptions/recurring/{sub_id}', clean_subscription, body=body) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema( + required=['subscription_id', 'payments'], + subscription_id=INT('Installment subscription id.'), + payments=ARR(_PAYMENTS_DESC, 'object'), + update_deal_value=BOOL('Update the deal value to match the payment total.'), + ), + description='Replace the payment schedule of an installment subscription.', + ) + def subscription_installment_update(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_installment_update') + if not isinstance(args.get('payments'), list) or not args['payments']: + raise ValueError('subscription_installment_update: "payments" must be a non-empty array') + body = body_from(args, ('payments', 'update_deal_value')) + return self._write('PUT', f'/subscriptions/installment/{sub_id}', clean_subscription, body=body) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema( + required=['subscription_id'], + subscription_id=INT('Recurring subscription id to cancel.'), + end_date=STR('Date the subscription ends, YYYY-MM-DD. Defaults to today.'), + ), + description='Cancel a recurring subscription.', + ) + def subscription_recurring_cancel(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_recurring_cancel') + return self._write( + 'PUT', f'/subscriptions/recurring/{sub_id}/cancel', clean_subscription, body=body_from(args, ('end_date',)) + ) + + @pipedrive_tool( + group='subscriptions', + input_schema=schema(required=['subscription_id'], subscription_id=INT('Subscription id to delete.')), + description='Delete a subscription and detach it from its deal.', + ) + def subscription_delete(self, args): + args = args_of(args) + sub_id = require_id(args, 'subscription_id', 'subscription_delete') + return self._delete(f'/subscriptions/{sub_id}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/teams.py b/nodes/src/nodes/tool_pipedrive/tools/teams.py new file mode 100644 index 000000000..f0e87443a --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/teams.py @@ -0,0 +1,173 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Team tools (legacy v1 teams surface).""" + +from __future__ import annotations + +from ..pipedrive_client import clean_team +from ..tool_groups import pipedrive_tool +from ._base import ( + ARR, + INT, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + + +class TeamsMixin(PipedriveToolsBase): + """Tools for the ``teams`` group.""" + + @pipedrive_tool( + group='teams', + input_schema=schema( + order_by=STR('Field to order by: id, name, manager_id or active_flag.'), + skip_users=INT('Set to 1 to leave the user id list out of the response.'), + ), + description='List all teams.', + ) + def team_list(self, args): + args = args_of(args) + data = self._call('GET', '/teams', params=params_from(args, ('order_by', 'skip_users'))) + return {'items': [clean_team(t) for t in (data or [])]} + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['team_id'], + team_id=INT('Team id.'), + skip_users=INT('Set to 1 to leave the user id list out of the response.'), + ), + description='Get a single team.', + ) + def team_get(self, args): + args = args_of(args) + team_id = require_id(args, 'team_id', 'team_get') + return self._get(f'/teams/{team_id}', clean_team, params=params_from(args, ('skip_users',))) + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['name', 'manager_id'], + name=STR('Team name.'), + manager_id=INT('User id of the team manager.'), + description=STR('Team description.'), + users=ARR('User ids that belong to the team.', 'integer'), + ), + description='Create a team.', + ) + def team_create(self, args): + args = args_of(args) + require_text(args, 'name', 'team_create') + require_id(args, 'manager_id', 'team_create') + return self._write( + 'POST', '/teams', clean_team, body=body_from(args, ('name', 'manager_id', 'description', 'users')) + ) + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['team_id'], + team_id=INT('Team id to update.'), + name=STR('New team name.'), + manager_id=INT('New manager user id.'), + description=STR('New description.'), + users=ARR('Replacement list of user ids.', 'integer'), + active_flag=INT('1 to activate the team, 0 to deactivate it.'), + deleted_flag=INT('1 to mark the team deleted.'), + ), + description='Update a team.', + ) + def team_update(self, args): + args = args_of(args) + team_id = require_id(args, 'team_id', 'team_update') + body = body_from(args, ('name', 'manager_id', 'description', 'users', 'active_flag', 'deleted_flag')) + return self._write('PUT', f'/teams/{team_id}', clean_team, body=body) + + @pipedrive_tool( + group='teams', + input_schema=schema(required=['team_id'], team_id=INT('Team id.')), + description='List the user ids that belong to a team.', + ) + def team_users_list(self, args): + args = args_of(args) + team_id = require_id(args, 'team_id', 'team_users_list') + return {'user_ids': self._call('GET', f'/teams/{team_id}/users')} + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['team_id', 'users'], team_id=INT('Team id.'), users=ARR('User ids to add.', 'integer') + ), + description='Add users to a team.', + ) + def team_user_add(self, args): + args = args_of(args) + team_id = require_id(args, 'team_id', 'team_user_add') + users = args.get('users') + if not isinstance(users, list) or not users: + raise ValueError('team_user_add: "users" must be a non-empty array of user ids') + return self._write('POST', f'/teams/{team_id}/users', passthrough, body={'users': users}) + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['team_id', 'users'], team_id=INT('Team id.'), users=ARR('User ids to remove.', 'integer') + ), + description='Remove users from a team.', + ) + def team_user_delete(self, args): + args = args_of(args) + team_id = require_id(args, 'team_id', 'team_user_delete') + users = args.get('users') + if not isinstance(users, list) or not users: + raise ValueError('team_user_delete: "users" must be a non-empty array of user ids') + self._require_write() + # Pipedrive reads the payload of this DELETE as form data, not JSON. + data = self._call('DELETE', f'/teams/{team_id}/users', form={'users': users}) + return {'deleted': True, 'data': data} + + @pipedrive_tool( + group='teams', + input_schema=schema( + required=['user_id'], + user_id=INT('User id.'), + order_by=STR('Field to order by: id, name, manager_id or active_flag.'), + skip_users=INT('Set to 1 to leave the user id lists out of the response.'), + ), + description='List the teams a user belongs to.', + ) + def team_list_for_user(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'team_list_for_user') + data = self._call('GET', f'/teams/user/{user_id}', params=params_from(args, ('order_by', 'skip_users'))) + return {'items': [clean_team(t) for t in (data or [])]} diff --git a/nodes/src/nodes/tool_pipedrive/tools/users.py b/nodes/src/nodes/tool_pipedrive/tools/users.py new file mode 100644 index 000000000..2f1a2471f --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/users.py @@ -0,0 +1,187 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""User tools: the account's users, their settings and their permissions.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_user +from ..tool_groups import pipedrive_tool +from ._base import ( + BOOL, + INT, + STR, + PipedriveToolsBase, + args_of, + body_from, + params_from, + require_id, + require_text, + schema, +) + + +class UsersMixin(PipedriveToolsBase): + """Tools for the ``users`` group.""" + + @pipedrive_tool( + group='users', + input_schema=schema(), + description='List all users in the Pipedrive account. Use this to resolve a user name to the user_id needed by owner filters.', + ) + def user_list(self, args): + args_of(args) + data = self._call('GET', '/users') + return {'items': [clean_user(u) for u in (data or [])]} + + @pipedrive_tool( + group='users', + input_schema=schema(required=['user_id'], user_id=INT('User id.')), + description='Get a single user by id.', + ) + def user_get(self, args): + args = args_of(args) + return self._get(f'/users/{require_id(args, "user_id", "user_get")}', clean_user) + + @pipedrive_tool( + group='users', + input_schema=schema(), + description='Get the user the API token belongs to, including the company id, domain and default currency.', + ) + def user_me(self, args): + args_of(args) + return self._call('GET', '/users/me') + + @pipedrive_tool( + group='users', + input_schema=schema( + required=['term'], + term=STR('Name or email to look for.'), + search_by_email=INT('Set to 1 to match the term against email addresses instead of names.'), + ), + description='Find users by name or email.', + ) + def user_find(self, args): + args = args_of(args) + params = {'term': require_text(args, 'term', 'user_find')} + params.update(params_from(args, ('search_by_email',))) + data = self._call('GET', '/users/find', params=params) + return {'items': [clean_user(u) for u in (data or [])]} + + @pipedrive_tool( + group='users', + input_schema=schema( + required=['email', 'access'], + email=STR('Email address to invite.'), + access={ + 'type': 'array', + 'items': {'type': 'object'}, + 'description': 'Access permissions, e.g. [{"app": "sales", "admin": false}]. Apps: sales, projects, campaigns, global.', + }, + active_flag=BOOL('Whether the new user is active (default true).'), + ), + description='Invite a new user to the Pipedrive account.', + ) + def user_create(self, args): + args = args_of(args) + require_text(args, 'email', 'user_create') + if not isinstance(args.get('access'), list) or not args['access']: + raise ValueError('user_create: "access" must be a non-empty array of access objects') + return self._write('POST', '/users', clean_user, body=body_from(args, ('email', 'access', 'active_flag'))) + + @pipedrive_tool( + group='users', + input_schema=schema( + required=['user_id', 'active_flag'], + user_id=INT('User id to update.'), + active_flag=BOOL('Whether the user is active. Set false to deactivate.'), + ), + description='Activate or deactivate a user.', + ) + def user_update(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'user_update') + if 'active_flag' not in args: + raise ValueError('user_update: "active_flag" is required') + return self._write('PUT', f'/users/{user_id}', clean_user, body={'active_flag': bool(args['active_flag'])}) + + @pipedrive_tool( + group='users', + input_schema=schema(required=['user_id'], user_id=INT('User id.')), + description='List the followers of a user.', + ) + def user_followers_list(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'user_followers_list') + return {'user_ids': self._call('GET', f'/users/{user_id}/followers')} + + @pipedrive_tool( + group='users', + input_schema=schema(required=['user_id'], user_id=INT('User id.')), + description='List the effective permissions of a user (what they can see, add, edit and delete).', + ) + def user_permissions_get(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'user_permissions_get') + return self._call('GET', f'/users/{user_id}/permissions') + + @pipedrive_tool( + group='users', + input_schema=schema(required=['user_id'], user_id=INT('User id.')), + description='List the role assignments of a user.', + ) + def user_role_assignments_list(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'user_role_assignments_list') + data = self._call('GET', f'/users/{user_id}/roleAssignments') + return {'items': list(data or [])} + + @pipedrive_tool( + group='users', + input_schema=schema(required=['user_id'], user_id=INT('User id.')), + description='Get the role settings that apply to a user.', + ) + def user_role_settings_get(self, args): + args = args_of(args) + user_id = require_id(args, 'user_id', 'user_role_settings_get') + return self._call('GET', f'/users/{user_id}/roleSettings') + + @pipedrive_tool( + group='users', + input_schema=schema(), + description='Get the authenticated user settings: timezone, currency, date format and feature flags.', + ) + def user_settings_get(self, args): + args_of(args) + return self._call('GET', '/userSettings') + + @pipedrive_tool( + group='users', + input_schema=schema(), + description='List the third-party accounts (Google, Microsoft, ...) connected to the authenticated user.', + ) + def user_connections_list(self, args): + args_of(args) + return self._call('GET', '/userConnections') diff --git a/nodes/src/nodes/tool_pipedrive/tools/webhooks.py b/nodes/src/nodes/tool_pipedrive/tools/webhooks.py new file mode 100644 index 000000000..76ee9cb2e --- /dev/null +++ b/nodes/src/nodes/tool_pipedrive/tools/webhooks.py @@ -0,0 +1,115 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# ============================================================================= + +"""Webhook tools: subscribe an external URL to Pipedrive change events.""" + +from __future__ import annotations + +from ..pipedrive_client import clean_webhook +from ..tool_groups import pipedrive_tool +from ._base import ( + ENUM, + INT, + STR, + PipedriveToolsBase, + args_of, + body_from, + require_id, + require_text, + schema, +) + + +class WebhooksMixin(PipedriveToolsBase): + """Tools for the ``webhooks`` group.""" + + @pipedrive_tool( + group='webhooks', + input_schema=schema(), + description='List the webhooks registered by this API token.', + ) + def webhook_list(self, args): + args_of(args) + data = self._call('GET', '/webhooks') + return {'items': [clean_webhook(w) for w in (data or [])]} + + @pipedrive_tool( + group='webhooks', + input_schema=schema( + required=['subscription_url', 'event_action', 'event_object'], + subscription_url=STR('HTTPS endpoint Pipedrive will POST events to. Must answer 2xx within 10 seconds.'), + event_action=ENUM( + 'Which action to subscribe to. Use "*" for all.', ['added', 'updated', 'merged', 'deleted', '*'] + ), + event_object=ENUM( + 'Which object to subscribe to. Use "*" for all.', + [ + 'activity', + 'activityType', + 'deal', + 'note', + 'organization', + 'person', + 'pipeline', + 'product', + 'stage', + 'user', + '*', + ], + ), + user_id=INT('Run the webhook as this user (defaults to the token owner).'), + http_auth_user=STR('Basic-auth username for the subscription URL.'), + http_auth_password=STR('Basic-auth password for the subscription URL.'), + version=ENUM('Webhook payload version (default 1.0).', ['1.0', '2.0']), + ), + description='Create a webhook subscription.', + ) + def webhook_create(self, args): + args = args_of(args) + require_text(args, 'subscription_url', 'webhook_create') + require_text(args, 'event_action', 'webhook_create') + require_text(args, 'event_object', 'webhook_create') + body = body_from( + args, + ( + 'subscription_url', + 'event_action', + 'event_object', + 'user_id', + 'http_auth_user', + 'http_auth_password', + 'version', + ), + ) + return self._write('POST', '/webhooks', clean_webhook, body=body) + + @pipedrive_tool( + group='webhooks', + input_schema=schema(required=['webhook_id'], webhook_id=INT('Webhook id to delete.')), + description='Delete a webhook subscription.', + ) + def webhook_delete(self, args): + args = args_of(args) + return self._delete(f'/webhooks/{require_id(args, "webhook_id", "webhook_delete")}') diff --git a/nodes/test/tool_pipedrive/__init__.py b/nodes/test/tool_pipedrive/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py new file mode 100644 index 000000000..5b65841b1 --- /dev/null +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -0,0 +1,747 @@ +""" +Unit tests for tool_pipedrive. + +Covers the HTTP client (auth, envelope handling, error mapping, rate-limit +retries), the tool-group publication filter, and read-only enforcement. No +credentials and no network access are needed — see test_tools.py for the +env-gated live suite. +""" + +from __future__ import annotations + +import sys +import types +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import requests + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'src' / 'nodes')) + +_STUB_MODULE_NAMES = ('rocketlib', 'ai', 'ai.common', 'ai.common.config', 'ai.common.utils') + +# Obviously fake. A real personal token is 40 hex characters, but a literal one +# here trips secret scanners, so use a placeholder of the same shape class: +# short, dot-free and under 64 characters, which is what selects api_token auth. +TEST_TOKEN = 'pipedrive-test-not-a-real-token' +PERSONAL_TOKEN = 'pipedrive-personal-token-placeholder' + + +def _install_stubs() -> None: + mod_rl = types.ModuleType('rocketlib') + + def mock_tool_function(*args, **kwargs): + def decorator(fn): + fn.__tool_meta__ = kwargs + return fn + + return decorator + + mod_rl.tool_function = mock_tool_function + + class IInstanceBase: + def _collect_tool_methods(self): + methods = {} + for name in dir(type(self)): + attr = getattr(type(self), name, None) + if attr is not None and hasattr(attr, '__tool_meta__'): + methods[name] = getattr(self, name) + return methods + + class IGlobalBase: + pass + + mod_rl.IInstanceBase = IInstanceBase + mod_rl.IGlobalBase = IGlobalBase + mod_rl.OPEN_MODE = Mock() + mod_rl.warning = Mock() + sys.modules['rocketlib'] = mod_rl + + sys.modules['ai'] = types.ModuleType('ai') + sys.modules['ai.common'] = types.ModuleType('ai.common') + + mod_config = types.ModuleType('ai.common.config') + + class Config: + pass + + mod_config.Config = Config + sys.modules['ai.common.config'] = mod_config + + mod_utils = types.ModuleType('ai.common.utils') + + def normalize_tool_input(value, **kwargs): + return value if isinstance(value, dict) else {} + + def require_str(args, key, **kwargs): + value = args.get(key) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f'{key} is required') + return value.strip() + + def require_int(args, key, **kwargs): + value = args.get(key) + if isinstance(value, bool) or value is None: + raise ValueError(f'{key} is required') + return int(value) + + mod_utils.normalize_tool_input = normalize_tool_input + mod_utils.require_str = require_str + mod_utils.require_int = require_int + sys.modules['ai.common.utils'] = mod_utils + + +@contextmanager +def _scoped_stubs() -> Iterator[None]: + original = {name: sys.modules.get(name) for name in _STUB_MODULE_NAMES} + _install_stubs() + try: + yield + finally: + for name, module in original.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +with _scoped_stubs(): + from tool_pipedrive.IInstance import IInstance + from tool_pipedrive.pipedrive_client import ( + BASE_URL, + MAX_LIMIT, + PipedriveAPIError, + _auth, + _use_bearer, + base_url_for, + call, + call_envelope, + clean_deal, + clean_person, + paginated, + split_custom_fields, + ) + from tool_pipedrive.tool_groups import ALL_GROUPS, DEFAULT_GROUPS, normalize_groups, pipedrive_tool + from tool_pipedrive.tools._base import body_from, paging_params + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resp(status=200, *, json_data=None, headers=None, text='', content=b'{}', ok=None): + resp = Mock(spec=requests.Response) + resp.status_code = status + resp.ok = (200 <= status < 300) if ok is None else ok + resp.headers = headers or {} + resp.text = text + resp.content = content + resp.reason = 'reason' + if json_data is None: + resp.json.side_effect = ValueError('no json') + else: + resp.json.return_value = json_data + return resp + + +def _ok(data, *, additional=None): + payload = {'success': True, 'data': data} + if additional is not None: + payload['additional_data'] = additional + return _resp(200, json_data=payload, content=b'{"success": true}') + + +def _instance(**overrides): + """Build an IInstance without running the engine lifecycle.""" + inst = IInstance.__new__(IInstance) + glob = Mock() + glob.token = TEST_TOKEN + glob.base_url = BASE_URL + glob.read_only = False + glob.tool_groups = DEFAULT_GROUPS + glob.allow_raw_request = True + for key, value in overrides.items(): + setattr(glob, key, value) + inst.IGlobal = glob + return inst + + +# --------------------------------------------------------------------------- +# Base URL and auth +# --------------------------------------------------------------------------- + + +class TestBaseUrl: + def test_default(self): + assert base_url_for('') == BASE_URL + assert base_url_for(None) == BASE_URL + + @pytest.mark.parametrize( + 'domain', + ['acme', 'acme.pipedrive.com', 'https://acme.pipedrive.com', 'https://acme.pipedrive.com/', 'ACME'.lower()], + ) + def test_company_domain_forms(self, domain): + assert base_url_for(domain) == 'https://acme.pipedrive.com/api/v1' + + def test_garbage_domain_falls_back(self): + assert base_url_for('https://') == BASE_URL + + +class TestAuth: + def test_personal_token_uses_query_param(self): + headers, params = _auth(PERSONAL_TOKEN) + assert headers == {} + assert params == {'api_token': PERSONAL_TOKEN} + assert _use_bearer(PERSONAL_TOKEN) is False + + def test_jwt_uses_bearer_header(self): + token = 'header.payload.signature' + headers, params = _auth(token) + assert headers == {'Authorization': f'Bearer {token}'} + assert params == {} + + def test_explicit_bearer_prefix_is_stripped(self): + headers, params = _auth('Bearer abc123') + assert headers == {'Authorization': 'Bearer abc123'} + assert params == {} + + def test_long_opaque_token_uses_bearer(self): + token = 'x' * 70 + assert _use_bearer(token) is True + + +# --------------------------------------------------------------------------- +# Envelope handling +# --------------------------------------------------------------------------- + + +class TestEnvelope: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_call_unwraps_data(self, mock_request): + mock_request.return_value = _ok({'id': 7, 'title': 'Deal'}) + assert call(TEST_TOKEN, 'GET', '/deals/7') == {'id': 7, 'title': 'Deal'} + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_null_data_becomes_empty_dict(self, mock_request): + mock_request.return_value = _ok(None) + assert call(TEST_TOKEN, 'DELETE', '/deals/7') == {} + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_204_returns_success_envelope(self, mock_request): + mock_request.return_value = _resp(204, content=b'') + assert call_envelope(TEST_TOKEN, 'DELETE', '/deals/7') == {'success': True, 'data': {}} + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_raw_returns_bytes(self, mock_request): + mock_request.return_value = _resp(200, json_data={'success': True}, content=b'binary-bytes') + assert call(TEST_TOKEN, 'GET', '/files/1/download', raw=True) == b'binary-bytes' + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_path_and_params_are_built(self, mock_request): + mock_request.return_value = _ok([]) + call(TEST_TOKEN, 'GET', 'deals', params={'limit': 10, 'start': None}) + _, kwargs = mock_request.call_args + assert mock_request.call_args[0] == ('GET', f'{BASE_URL}/deals') + assert kwargs['params'] == {'limit': 10, 'api_token': TEST_TOKEN} + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_custom_base_url_is_used(self, mock_request): + mock_request.return_value = _ok([]) + call(TEST_TOKEN, 'GET', '/deals', base_url='https://acme.pipedrive.com/api/v1') + assert mock_request.call_args[0][1] == 'https://acme.pipedrive.com/api/v1/deals' + + def test_paginated_wraps_items(self): + envelope = { + 'data': [], + 'additional_data': {'pagination': {'more_items_in_collection': True, 'next_start': 100}}, + } + assert paginated(envelope, [{'id': 1}]) == { + 'items': [{'id': 1}], + 'count': 1, + 'more_items_in_collection': True, + 'next_start': 100, + } + + def test_paginated_without_pagination_block(self): + assert paginated({'data': []}, [])['more_items_in_collection'] is False + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class TestErrors: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_error_message_includes_info_and_code(self, mock_request): + mock_request.return_value = _resp( + 403, + json_data={ + 'success': False, + 'error': 'Deal limit reached', + 'error_info': 'Upgrade your plan', + 'code': 'feature_capping_deals_limit', + }, + text='Deal limit reached', + ) + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'POST', '/deals', body={'title': 'x'}) + assert exc.value.status_code == 403 + assert 'Deal limit reached' in exc.value.message + assert 'Upgrade your plan' in exc.value.message + assert exc.value.code == 'feature_capping_deals_limit' + assert mock_request.call_count == 1 # a plain 403 is not retried + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_success_false_on_200_is_an_error(self, mock_request): + mock_request.return_value = _resp( + 200, json_data={'success': False, 'error': 'Invalid filter'}, text='Invalid filter' + ) + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'GET', '/deals') + assert 'Invalid filter' in exc.value.message + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_non_json_error_falls_back_to_text(self, mock_request): + mock_request.return_value = _resp(500, text='boom') + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'GET', '/deals') + assert 'boom' in exc.value.message + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_transport_failure_raises_value_error(self, mock_request): + mock_request.side_effect = requests.ConnectionError('dns failure') + with pytest.raises(ValueError, match='Pipedrive request failed'): + call(TEST_TOKEN, 'GET', '/deals') + + +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- + + +class TestRateLimit: + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_429_retry_after_is_honoured(self, mock_request, mock_sleep): + limited = _resp( + 429, json_data={'success': False, 'error': 'rate limit'}, headers={'Retry-After': '5'}, text='rate limit' + ) + mock_request.side_effect = [limited, limited, _ok({'id': 1})] + + assert call(TEST_TOKEN, 'GET', '/deals/1') == {'id': 1} + assert mock_request.call_count == 3 + assert mock_sleep.call_count == 2 + mock_sleep.assert_called_with(5.0) + + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_ratelimit_reset_is_seconds_remaining_not_epoch(self, mock_request, mock_sleep): + """Pipedrive's x-ratelimit-reset counts down; it is not a GitHub-style epoch.""" + limited = _resp( + 429, + json_data={'success': False, 'error': 'rate limit'}, + headers={'x-ratelimit-reset': '10', 'x-ratelimit-remaining': '0'}, + text='rate limit', + ) + mock_request.side_effect = [limited, _ok({'id': 1})] + + assert call(TEST_TOKEN, 'GET', '/deals/1') == {'id': 1} + mock_sleep.assert_called_once_with(10.0) + + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_403_with_rate_limit_markers_is_retried(self, mock_request, mock_sleep): + limited = _resp( + 403, + json_data={'success': False, 'error': 'rate limit exceeded'}, + headers={'x-ratelimit-remaining': '0', 'x-ratelimit-reset': '2'}, + text='rate limit exceeded', + ) + mock_request.side_effect = [limited, limited, limited] + + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'GET', '/deals') + assert exc.value.status_code == 403 + assert mock_request.call_count == 3 + assert mock_sleep.call_count == 2 + + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + @pytest.mark.parametrize('bad_value', ['malformed', '-5', 'NaN', 'Inf']) + def test_malformed_retry_after_falls_back_to_backoff(self, mock_request, mock_sleep, bad_value): + limited = _resp( + 429, + json_data={'success': False, 'error': 'rate limit'}, + headers={'Retry-After': bad_value}, + text='rate limit', + ) + mock_request.side_effect = [limited, _ok({'id': 1})] + + assert call(TEST_TOKEN, 'GET', '/deals/1') == {'id': 1} + assert mock_sleep.call_count == 1 + assert mock_sleep.call_args[0][0] >= 2.0 + + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_excessive_wait_fails_fast_with_hint(self, mock_request, mock_sleep): + limited = _resp( + 429, + json_data={'success': False, 'error': 'rate limit'}, + headers={'Retry-After': '3600'}, + text='rate limit', + ) + mock_request.return_value = limited + + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'GET', '/deals') + assert exc.value.status_code == 429 + assert 'retry after 3600s' in exc.value.message + assert mock_request.call_count == 1 + assert mock_sleep.call_count == 0 + + @patch('time.sleep') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_daily_budget_hint(self, mock_request, mock_sleep): + limited = _resp( + 429, + json_data={'success': False, 'error': 'rate limit'}, + headers={'x-ratelimit-reset': '99999', 'x-daily-requests-left': '0'}, + text='rate limit', + ) + mock_request.return_value = limited + + with pytest.raises(PipedriveAPIError) as exc: + call(TEST_TOKEN, 'GET', '/deals') + assert 'daily API token budget exhausted' in exc.value.message + assert mock_sleep.call_count == 0 + + +# --------------------------------------------------------------------------- +# Cleaners +# --------------------------------------------------------------------------- + + +class TestCleaners: + def test_clean_deal_flattens_references_and_splits_custom_fields(self): + custom_key = 'a' * 40 + cleaned = clean_deal( + { + 'id': 1, + 'title': 'Big deal', + 'value': 1000, + 'person_id': {'value': 5, 'name': 'Ada'}, + 'org_id': {'value': 9, 'name': 'Acme'}, + 'user_id': {'id': 3, 'name': 'Rep'}, + 'creator_user_id': {'id': 3, 'name': 'Rep'}, + custom_key: 'custom value', + 'noise_field': 'dropped', + } + ) + assert cleaned['id'] == 1 + assert cleaned['person'] == {'id': 5, 'name': 'Ada'} + assert cleaned['organization'] == {'id': 9, 'name': 'Acme'} + assert cleaned['custom_fields'] == {custom_key: 'custom value'} + assert 'noise_field' not in cleaned + + def test_clean_person_flattens_contacts(self): + cleaned = clean_person( + { + 'id': 2, + 'name': 'Ada', + 'email': [{'value': 'ada@example.com', 'primary': True}], + 'phone': [{'value': '+123', 'primary': True}], + 'org_id': {'value': 9, 'name': 'Acme'}, + } + ) + assert cleaned['emails'] == ['ada@example.com'] + assert cleaned['phones'] == ['+123'] + assert cleaned['organization'] == {'id': 9, 'name': 'Acme'} + + def test_split_custom_fields_only_matches_hex_keys(self): + assert split_custom_fields({'a' * 40: 1, 'title': 2, 'z' * 40: 3}) == {'a' * 40: 1} + + +# --------------------------------------------------------------------------- +# Argument helpers +# --------------------------------------------------------------------------- + + +class TestArgHelpers: + def test_paging_clamps_limit(self): + assert paging_params({'limit': 10_000})['limit'] == MAX_LIMIT + assert paging_params({'limit': 0})['limit'] == 1 + assert paging_params({'start': -5})['start'] == 0 + assert paging_params({}) == {} + + def test_body_from_omits_missing_and_merges_extra(self): + custom_key = 'b' * 40 + body = body_from({'title': 'x', 'value': None, 'extra': {custom_key: 'v'}}, ('title', 'value', 'currency')) + assert body == {'title': 'x', custom_key: 'v'} + + +# --------------------------------------------------------------------------- +# Tool groups +# --------------------------------------------------------------------------- + + +class TestToolGroups: + def test_defaults_when_empty_or_invalid(self): + assert normalize_groups(None) == DEFAULT_GROUPS + assert normalize_groups([]) == DEFAULT_GROUPS + assert normalize_groups(['nonsense']) == DEFAULT_GROUPS + assert normalize_groups(42) == DEFAULT_GROUPS + + def test_all_sentinel(self): + assert normalize_groups(['all']) == ALL_GROUPS + assert normalize_groups('all') == ALL_GROUPS + + def test_comma_separated_string(self): + assert normalize_groups('deals, leads') == frozenset({'deals', 'leads'}) + + def test_unknown_names_are_dropped(self): + assert normalize_groups(['deals', 'nope']) == frozenset({'deals'}) + + def test_pipedrive_tool_rejects_unknown_group(self): + with pytest.raises(ValueError, match='unknown group'): + pipedrive_tool(group='not_a_group') + + +class TestToolPublication: + def test_defaults_publish_core_groups_only(self): + published = _instance()._collect_tool_methods() + assert 'deal_list' in published + assert 'person_search' in published + assert 'lead_list' not in published + assert 'project_task_create' not in published + + def test_all_groups_publish_everything(self): + published = _instance(tool_groups=ALL_GROUPS)._collect_tool_methods() + assert 'lead_list' in published + assert 'webhook_create' in published + assert 'currency_list' in published + + def test_request_tool_follows_its_own_switch(self): + assert 'request' in _instance()._collect_tool_methods() + assert 'request' not in _instance(allow_raw_request=False)._collect_tool_methods() + + def test_every_tool_belongs_to_a_known_group(self): + untagged = [] + for name in dir(IInstance): + attr = getattr(IInstance, name, None) + if attr is None or not hasattr(attr, '__tool_meta__') or name == 'request': + continue + group = getattr(attr, '__pipedrive_group__', None) + if group not in ALL_GROUPS: + untagged.append(name) + assert untagged == [] + + def test_full_surface_is_large(self): + """Guards against a mixin silently dropping out of the composition.""" + published = _instance(tool_groups=ALL_GROUPS)._collect_tool_methods() + assert len(published) > 150 + + +# --------------------------------------------------------------------------- +# Read-only mode +# --------------------------------------------------------------------------- + + +class TestReadOnly: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_reads_are_allowed(self, mock_request): + mock_request.return_value = _ok({'id': 1, 'title': 'Deal'}) + assert _instance(read_only=True).deal_get({'deal_id': 1})['id'] == 1 + + @pytest.mark.parametrize( + 'tool,args', + [ + ('deal_create', {'title': 'x'}), + ('deal_update', {'deal_id': 1, 'title': 'x'}), + ('deal_delete', {'deal_id': 1}), + ('person_create', {'name': 'Ada'}), + ('organization_delete', {'org_id': 1}), + ('activity_create', {'subject': 'call'}), + ('note_create', {'content': 'hi'}), + ('lead_create', {'title': 'x'}), + ('product_create', {'name': 'x'}), + ('webhook_delete', {'webhook_id': 1}), + ('field_delete', {'entity': 'deal', 'field_id': 1}), + ('project_task_delete', {'task_id': 1}), + ], + ) + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_writes_are_blocked(self, mock_request, tool, args): + inst = _instance(read_only=True) + with pytest.raises(ValueError, match='read-only mode'): + getattr(inst, tool)(args) + mock_request.assert_not_called() + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_bulk_delete_is_blocked(self, mock_request): + with pytest.raises(ValueError, match='read-only mode'): + _instance(read_only=True).deal_delete_bulk({'ids': [1, 2]}) + mock_request.assert_not_called() + + +# --------------------------------------------------------------------------- +# The raw request escape hatch +# --------------------------------------------------------------------------- + + +class TestRawRequest: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_get_passes_through_and_returns_envelope(self, mock_request): + mock_request.return_value = _ok([{'id': 1}], additional={'pagination': {'next_start': 10}}) + result = _instance().request({'method': 'get', 'path': '/goals', 'params': {'limit': 1}}) + assert result['data'] == [{'id': 1}] + assert result['additional_data']['pagination']['next_start'] == 10 + assert mock_request.call_args[0] == ('GET', f'{BASE_URL}/goals') + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_write_blocked_in_read_only(self, mock_request): + with pytest.raises(ValueError, match='read-only mode'): + _instance(read_only=True).request({'method': 'POST', 'path': '/goals', 'body': {}}) + mock_request.assert_not_called() + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_get_allowed_in_read_only(self, mock_request): + mock_request.return_value = _ok({'ok': True}) + assert _instance(read_only=True).request({'method': 'GET', 'path': '/currencies'})['data'] == {'ok': True} + + def test_rejects_full_url(self): + with pytest.raises(ValueError, match='must be a path'): + _instance().request({'method': 'GET', 'path': 'https://api.pipedrive.com/api/v1/deals'}) + + def test_rejects_unknown_method(self): + with pytest.raises(ValueError, match='method must be one of'): + _instance().request({'method': 'TRACE', 'path': '/deals'}) + + def test_rejects_non_object_body(self): + with pytest.raises(ValueError, match='"body" must be an object'): + _instance().request({'method': 'POST', 'path': '/deals', 'body': 'oops'}) + + +# --------------------------------------------------------------------------- +# A few representative request shapes +# --------------------------------------------------------------------------- + + +class TestRequestShapes: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_deal_list_sends_filters_and_paging(self, mock_request): + mock_request.return_value = _ok( + [{'id': 1, 'title': 'Deal'}], + additional={'pagination': {'more_items_in_collection': True, 'next_start': 50}}, + ) + result = _instance().deal_list({'status': 'open', 'limit': 50, 'start': 0, 'user_id': 3}) + + params = mock_request.call_args[1]['params'] + assert params['status'] == 'open' + assert params['limit'] == 50 + assert params['start'] == 0 + assert params['user_id'] == 3 + assert result['items'][0]['title'] == 'Deal' + assert result['next_start'] == 50 + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_person_create_expands_plain_email_strings(self, mock_request): + mock_request.return_value = _ok({'id': 2, 'name': 'Ada'}) + _instance().person_create({'name': 'Ada', 'email': ['ada@example.com', 'a2@example.com']}) + + body = mock_request.call_args[1]['json'] + assert body['email'] == [ + {'value': 'ada@example.com', 'primary': True}, + {'value': 'a2@example.com', 'primary': False}, + ] + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_search_unwraps_items(self, mock_request): + mock_request.return_value = _ok({'items': [{'result_score': 0.9, 'item': {'id': 4, 'title': 'Acme deal'}}]}) + result = _instance().deal_search({'term': 'acme'}) + assert result['items'] == [{'id': 4, 'title': 'Acme deal', 'result_score': 0.9}] + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_field_entity_selects_the_endpoint(self, mock_request): + mock_request.return_value = _ok([]) + _instance(tool_groups=ALL_GROUPS).field_list({'entity': 'organization'}) + assert mock_request.call_args[0][1] == f'{BASE_URL}/organizationFields' + + def test_field_entity_is_validated(self): + with pytest.raises(ValueError, match='must be one of'): + _instance(tool_groups=ALL_GROUPS).field_list({'entity': 'spaceship'}) + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_goal_find_uses_dotted_query_keys(self, mock_request): + mock_request.return_value = _ok({'goals': []}) + _instance(tool_groups=ALL_GROUPS).goal_find({'assignee_id': 1, 'assignee_type': 'person'}) + params = mock_request.call_args[1]['params'] + assert params['assignee.id'] == 1 + assert params['assignee.type'] == 'person' + assert 'assignee_id' not in params + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_project_list_uses_cursor_pagination(self, mock_request): + mock_request.return_value = _ok([{'id': 1, 'title': 'P'}], additional={'next_cursor': 'abc'}) + result = _instance(tool_groups=ALL_GROUPS).project_list({'limit': 10}) + assert result['next_cursor'] == 'abc' + assert mock_request.call_args[1]['params']['limit'] == 10 + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_file_download_returns_base64(self, mock_request): + mock_request.return_value = _resp(200, json_data={'success': True}, content=b'hello') + result = _instance(tool_groups=ALL_GROUPS).file_download({'file_id': 1}) + assert result['content_base64'] == 'aGVsbG8=' + assert result['size'] == 5 + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_file_create_rejects_bad_base64(self, mock_request): + with pytest.raises(ValueError, match='not valid base64'): + _instance(tool_groups=ALL_GROUPS).file_create({'file_name': 'a.txt', 'content_base64': 'not base64!!'}) + mock_request.assert_not_called() + + +class TestNonDictPayloads: + """Some endpoints answer with a bare list; the cleaner must not assume a dict.""" + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_team_user_add_accepts_list_payload(self, mock_request): + mock_request.return_value = _ok([1, 2, 3]) + result = _instance(tool_groups=ALL_GROUPS).team_user_add({'team_id': 1, 'users': [2]}) + assert result == [1, 2, 3] + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_note_comment_create_accepts_list_payload(self, mock_request): + mock_request.return_value = _ok([{'uuid': 'x'}]) + assert _instance().note_comment_create({'note_id': 1, 'content': 'hi'}) == [{'uuid': 'x'}] + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_team_user_delete_sends_form_not_json(self, mock_request): + mock_request.return_value = _ok(True) + _instance(tool_groups=ALL_GROUPS).team_user_delete({'team_id': 1, 'users': [2]}) + kwargs = mock_request.call_args[1] + assert kwargs['data'] == {'users': [2]} + assert kwargs['json'] is None + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_role_assignment_delete_sends_form_not_json(self, mock_request): + mock_request.return_value = _ok(True) + _instance(tool_groups=ALL_GROUPS).role_assignment_delete({'role_id': 1, 'user_id': 2}) + kwargs = mock_request.call_args[1] + assert kwargs['data'] == {'user_id': 2} + assert kwargs['json'] is None + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_file_create_sends_multipart(self, mock_request): + mock_request.return_value = _ok({'id': 5, 'name': 'a.txt'}) + _instance(tool_groups=ALL_GROUPS).file_create( + {'file_name': 'a.txt', 'content_base64': 'aGVsbG8=', 'deal_id': 3} + ) + kwargs = mock_request.call_args[1] + assert kwargs['files']['file'][0] == 'a.txt' + assert kwargs['files']['file'][1] == b'hello' + assert kwargs['data'] == {'deal_id': 3} diff --git a/nodes/test/tool_pipedrive/test_tools.py b/nodes/test/tool_pipedrive/test_tools.py new file mode 100644 index 000000000..9cc0a504d --- /dev/null +++ b/nodes/test/tool_pipedrive/test_tools.py @@ -0,0 +1,485 @@ +""" +Live integration tests for tool_pipedrive. + +Calls every Pipedrive API v1 endpoint group the node exposes. Everything the +tests create is deleted again, but they still write to a real account — point +them at a sandbox, never at production data. + + export PIPEDRIVE_API_TOKEN= + export PIPEDRIVE_COMPANY_DOMAIN= # optional + export PIPEDRIVE_ALLOW_WRITES=1 # opt in to create/delete tests + pytest nodes/test/tool_pipedrive/test_tools.py -v + +Without PIPEDRIVE_API_TOKEN the whole module is skipped. Read-only endpoints run +with just the token; the create/update/delete tests additionally require +PIPEDRIVE_ALLOW_WRITES=1. +""" + +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'tool_pipedrive')) +from pipedrive_client import base_url_for, call, call_envelope # noqa: E402 + +TOKEN = os.getenv('PIPEDRIVE_API_TOKEN', '') +DOMAIN = os.getenv('PIPEDRIVE_COMPANY_DOMAIN', '') +ALLOW_WRITES = os.getenv('PIPEDRIVE_ALLOW_WRITES', '') == '1' +BASE = base_url_for(DOMAIN) + +pytestmark = pytest.mark.skipif(not TOKEN, reason='PIPEDRIVE_API_TOKEN must be set') + +writes = pytest.mark.skipif( + not ALLOW_WRITES, reason='PIPEDRIVE_ALLOW_WRITES=1 must be set to run tests that create records' +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def c(method, path, *, params=None, body=None): + return call(TOKEN, method, path, base_url=BASE, params=params, body=body) + + +def envelope(method, path, *, params=None): + return call_envelope(TOKEN, method, path, base_url=BASE, params=params) + + +def uid(prefix): + return f'{prefix}-rocketride-test-{uuid.uuid4().hex[:8]}' + + +@pytest.fixture(scope='module') +def person(): + """A throwaway person, deleted at the end of the module.""" + created = c('POST', '/persons', body={'name': uid('person')}) + yield created + c('DELETE', f'/persons/{created["id"]}') + + +@pytest.fixture(scope='module') +def organization(): + created = c('POST', '/organizations', body={'name': uid('org')}) + yield created + c('DELETE', f'/organizations/{created["id"]}') + + +@pytest.fixture(scope='module') +def deal(person): + created = c('POST', '/deals', body={'title': uid('deal'), 'person_id': person['id']}) + yield created + c('DELETE', f'/deals/{created["id"]}') + + +# --------------------------------------------------------------------------- +# Account +# --------------------------------------------------------------------------- + + +class TestAccount: + def test_users_me(self): + me = c('GET', '/users/me') + assert me['id'] + + def test_user_list(self): + assert isinstance(c('GET', '/users'), list) + + def test_user_settings(self): + assert isinstance(c('GET', '/userSettings'), dict) + + def test_currencies(self): + assert any(cur.get('code') for cur in c('GET', '/currencies')) + + def test_pagination_envelope(self): + env = envelope('GET', '/deals', params={'limit': 1}) + assert env['success'] is True + assert 'additional_data' in env + + +# --------------------------------------------------------------------------- +# Read-only listings — every group's list endpoint +# --------------------------------------------------------------------------- + + +class TestListings: + @pytest.mark.parametrize( + 'path', + [ + '/deals', + '/persons', + '/organizations', + '/activities', + '/activityTypes', + '/activityFields', + '/pipelines', + '/stages', + '/notes', + '/noteFields', + '/leads', + '/leadLabels', + '/leadSources', + '/products', + '/productFields', + '/dealFields', + '/personFields', + '/organizationFields', + '/files', + '/filters', + '/webhooks', + '/roles', + '/permissionSets', + '/teams', + '/callLogs', + '/projects', + '/projects/boards', + '/projectTemplates', + '/tasks', + ], + ) + def test_list_endpoint_answers(self, path): + data = c('GET', path, params={'limit': 1} if path not in ('/pipelines', '/activityTypes') else None) + assert data is not None + + def test_mail_threads(self): + # Mailbox is only populated when email sync is on; a 4xx here is a config + # signal, not a client bug, so only assert the call is well-formed. + try: + data = c('GET', '/mailbox/mailThreads', params={'folder': 'inbox', 'limit': 1}) + except ValueError as exc: + pytest.skip(f'mailbox not available on this account: {exc}') + assert data is not None + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + + +class TestSearch: + def test_item_search(self): + env = envelope('GET', '/itemSearch', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_item_search_by_field(self): + data = c( + 'GET', + '/itemSearch/field', + params={'term': 'a', 'field_type': 'dealField', 'field_key': 'title', 'limit': 1}, + ) + assert data is not None + + def test_deal_search(self): + env = envelope('GET', '/deals/search', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_person_search(self): + env = envelope('GET', '/persons/search', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_organization_search(self): + env = envelope('GET', '/organizations/search', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_recents(self): + data = c('GET', '/recents', params={'since_timestamp': '2020-01-01 00:00:00', 'limit': 1}) + assert data is not None + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +class TestReporting: + def test_deal_summary(self): + assert 'total_count' in c('GET', '/deals/summary') + + def test_deal_timeline(self): + data = c( + 'GET', + '/deals/timeline', + params={'start_date': '2026-01-01', 'interval': 'month', 'amount': 1, 'field_key': 'add_time'}, + ) + assert isinstance(data, list) + + def test_pipeline_conversion_statistics(self): + pipelines = c('GET', '/pipelines') + assert pipelines, 'account has no pipelines' + stats = c( + 'GET', + f'/pipelines/{pipelines[0]["id"]}/conversion_statistics', + params={'start_date': '2026-01-01', 'end_date': '2026-12-31'}, + ) + assert 'stage_conversions' in stats + + def test_pipeline_movement_statistics(self): + pipelines = c('GET', '/pipelines') + stats = c( + 'GET', + f'/pipelines/{pipelines[0]["id"]}/movement_statistics', + params={'start_date': '2026-01-01', 'end_date': '2026-12-31'}, + ) + assert stats is not None + + +# --------------------------------------------------------------------------- +# Write paths +# --------------------------------------------------------------------------- + + +@writes +class TestPersonLifecycle: + def test_create_update_delete(self): + created = c('POST', '/persons', body={'name': uid('person'), 'email': [{'value': 'a@example.com'}]}) + try: + assert created['id'] + updated = c('PUT', f'/persons/{created["id"]}', body={'name': 'renamed'}) + assert updated['name'] == 'renamed' + fetched = c('GET', f'/persons/{created["id"]}') + assert fetched['id'] == created['id'] + finally: + c('DELETE', f'/persons/{created["id"]}') + + +@writes +class TestDealLifecycle: + def test_create_update_delete(self, person): + created = c('POST', '/deals', body={'title': uid('deal'), 'person_id': person['id'], 'value': 100}) + try: + assert created['id'] + updated = c('PUT', f'/deals/{created["id"]}', body={'value': 250}) + assert float(updated['value']) == 250 + finally: + c('DELETE', f'/deals/{created["id"]}') + + def test_related_collections(self, deal): + for suffix in ('activities', 'files', 'flow', 'participants', 'followers', 'persons', 'permittedUsers'): + assert c('GET', f'/deals/{deal["id"]}/{suffix}') is not None + + def test_participants_and_followers(self, deal, person): + me = c('GET', '/users/me') + participant = c('POST', f'/deals/{deal["id"]}/participants', body={'person_id': person['id']}) + follower = c('POST', f'/deals/{deal["id"]}/followers', body={'user_id': me['id']}) + try: + assert participant is not None + assert follower['id'] + finally: + c('DELETE', f'/deals/{deal["id"]}/followers/{follower["id"]}') + + +@writes +class TestOrganization: + def test_create_and_relate(self, organization, person): + c('PUT', f'/persons/{person["id"]}', body={'org_id': organization['id']}) + persons = c('GET', f'/organizations/{organization["id"]}/persons') + assert any(p['id'] == person['id'] for p in persons or []) + + +@writes +class TestActivities: + def test_create_complete_delete(self, deal): + created = c( + 'POST', + '/activities', + body={'subject': uid('activity'), 'type': 'call', 'deal_id': deal['id'], 'due_date': '2026-12-31'}, + ) + try: + done = c('PUT', f'/activities/{created["id"]}', body={'done': 1}) + assert done['done'] is True + finally: + c('DELETE', f'/activities/{created["id"]}') + + +@writes +class TestNotes: + def test_create_comment_delete(self, deal): + note = c('POST', '/notes', body={'content': 'rocketride test note', 'deal_id': deal['id']}) + try: + assert note['id'] + comment = c('POST', f'/notes/{note["id"]}/comments', body={'content': 'a comment'}) + assert comment['uuid'] or comment['id'] + assert c('GET', f'/notes/{note["id"]}/comments') is not None + finally: + c('DELETE', f'/notes/{note["id"]}') + + +@writes +class TestLeads: + def test_create_update_delete(self, person): + lead = c('POST', '/leads', body={'title': uid('lead'), 'person_id': person['id']}) + try: + assert lead['id'] + updated = c('PATCH', f'/leads/{lead["id"]}', body={'title': 'renamed lead'}) + assert updated['title'] == 'renamed lead' + finally: + c('DELETE', f'/leads/{lead["id"]}') + + +@writes +class TestProducts: + def test_create_attach_delete(self, deal): + product = c('POST', '/products', body={'name': uid('product'), 'prices': [{'currency': 'USD', 'price': 10}]}) + attachment = None + try: + attachment = c( + 'POST', + f'/deals/{deal["id"]}/products', + body={'product_id': product['id'], 'item_price': 10, 'quantity': 2}, + ) + assert attachment['id'] + attached = c('GET', f'/deals/{deal["id"]}/products') + assert any(row['product_id'] == product['id'] for row in attached or []) + finally: + if attachment: + c('DELETE', f'/deals/{deal["id"]}/products/{attachment["id"]}') + c('DELETE', f'/products/{product["id"]}') + + +@writes +class TestFiles: + def test_upload_download_delete(self, deal): + import base64 + import io + + import requests + + name = f'{uid("file")}.txt' + payload = b'rocketride pipedrive test file' + response = requests.post( + f'{BASE}/files', + params={'api_token': TOKEN}, + data={'deal_id': deal['id']}, + files={'file': (name, io.BytesIO(payload))}, + timeout=30, + ) + response.raise_for_status() + file_id = response.json()['data']['id'] + try: + downloaded = call(TOKEN, 'GET', f'/files/{file_id}/download', base_url=BASE, raw=True) + assert downloaded == payload + assert base64.b64encode(downloaded) + finally: + c('DELETE', f'/files/{file_id}') + + +@writes +class TestFilters: + def test_create_and_delete(self): + conditions = { + 'glue': 'and', + 'conditions': [ + { + 'glue': 'and', + 'conditions': [{'object': 'deal', 'field_id': '1', 'operator': '=', 'value': 'open'}], + }, + {'glue': 'or', 'conditions': []}, + ], + } + created = c('POST', '/filters', body={'name': uid('filter'), 'type': 'deals', 'conditions': conditions}) + try: + assert created['id'] + assert c('GET', f'/filters/{created["id"]}')['id'] == created['id'] + finally: + c('DELETE', f'/filters/{created["id"]}') + + def test_helpers(self): + assert c('GET', '/filters/helpers') is not None + + +@writes +class TestWebhooks: + def test_create_and_delete(self): + created = c( + 'POST', + '/webhooks', + body={ + 'subscription_url': 'https://example.com/rocketride-test-hook', + 'event_action': 'updated', + 'event_object': 'deal', + }, + ) + try: + assert created['id'] + assert any(w['id'] == created['id'] for w in c('GET', '/webhooks')) + finally: + c('DELETE', f'/webhooks/{created["id"]}') + + +@writes +class TestCustomFields: + def test_create_and_delete_deal_field(self): + created = c('POST', '/dealFields', body={'name': uid('field'), 'field_type': 'varchar'}) + try: + assert len(created['key']) == 40 + finally: + c('DELETE', f'/dealFields/{created["id"]}') + + +@writes +class TestCallLogs: + def test_create_and_delete(self, person): + created = c( + 'POST', + '/callLogs', + body={ + 'outcome': 'connected', + 'to_phone_number': '+15550000000', + 'start_time': '2026-07-26T10:00:00Z', + 'end_time': '2026-07-26T10:01:00Z', + 'person_id': person['id'], + }, + ) + try: + assert created['id'] + finally: + c('DELETE', f'/callLogs/{created["id"]}') + + +@writes +class TestProjects: + def test_create_and_delete(self): + boards = c('GET', '/projects/boards') + if not boards: + pytest.skip('account has no project boards') + phases = c('GET', '/projects/phases', params={'board_id': boards[0]['id']}) + if not phases: + pytest.skip('board has no phases') + created = c( + 'POST', + '/projects', + body={'title': uid('project'), 'board_id': boards[0]['id'], 'phase_id': phases[0]['id']}, + ) + task = None + try: + assert created['id'] + task = c('POST', '/tasks', body={'title': uid('task'), 'project_id': created['id']}) + assert task['id'] + assert c('GET', f'/projects/{created["id"]}/plan') is not None + finally: + if task: + c('DELETE', f'/tasks/{task["id"]}') + c('DELETE', f'/projects/{created["id"]}') + + +# --------------------------------------------------------------------------- +# Error handling against the live API +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_missing_record_raises(self): + with pytest.raises(ValueError) as exc: + c('GET', '/deals/999999999') + assert 'Pipedrive API' in str(exc.value) + + def test_bad_token_raises(self): + with pytest.raises(ValueError) as exc: + call('not-a-real-token', 'GET', '/users/me', base_url=BASE) + assert 'Pipedrive API 40' in str(exc.value) From 84bb713a4a023c484eae182a92e5b31f26a32efb Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Mon, 27 Jul 2026 09:22:01 -0700 Subject: [PATCH 02/12] fix(nodes): use the real Pipedrive brand mark for the tool_pipedrive icon The first icon was a green uppercase "P" next to an unrelated dark dot. The actual Pipedrive mark is a solid green disc carrying a white lowercase "p" whose counter shows the disc through it. Redrawn as plain geometry (disc r=128, stem 36x150, bowl r=56, counter r=27) so it stays crisp at 16px with no gradients, clip paths or embedded raster. Uses the default nonzero fill rule with the counter arc wound opposite to the bowl. Under evenodd the stem and bowl would XOR where they overlap and punch a green notch out of their junction; nonzero merges them solid while the opposing winding still renders the counter as a hole. Keeps the viewBox (SVGR runs with dimensions:false, so an icon without one computes to zero width) and keeps two distinct colours, which makes the auto-currentcolor svgo plugin treat it as a multicolour brand mark and pass the authored colours through untinted. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/tool_pipedrive/pipedrive.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive.svg b/nodes/src/nodes/tool_pipedrive/pipedrive.svg index 989aa04df..82b2a6d4c 100644 --- a/nodes/src/nodes/tool_pipedrive/pipedrive.svg +++ b/nodes/src/nodes/tool_pipedrive/pipedrive.svg @@ -1,4 +1,4 @@ - - - + + + From 9345ec60fa07ebc7ad2cec4793abfda295406cc1 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Mon, 27 Jul 2026 09:33:35 -0700 Subject: [PATCH 03/12] fix(nodes): trace the tool_pipedrive icon from the supplied brand artwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version approximated the mark with circles. The real letterform differs in three ways that are visible even at 16px: the stem carries a spur at the top left, the bowl is an ellipse rather than a circle, and the counter is an ellipse tangent to the stem's right edge. Geometry was measured off the supplied 272x272 artwork rather than eyeballed — connected-component analysis to isolate the counter, then a least-squares fit of the bowl ellipse to the glyph's right-hand edge (max residual 2px). Rasterising the traced primitives and diffing against the source leaves 1.47% of opaque pixels differing, all of it a sub-pixel outline along the shape boundary. Still a two-colour multicolour mark, so the auto-currentcolor svgo plugin passes the authored colours through, and still nonzero winding with the counter wound opposite the bowl so the stem and bowl merge solid while the counter stays open. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/tool_pipedrive/pipedrive.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive.svg b/nodes/src/nodes/tool_pipedrive/pipedrive.svg index 82b2a6d4c..dccc0546a 100644 --- a/nodes/src/nodes/tool_pipedrive/pipedrive.svg +++ b/nodes/src/nodes/tool_pipedrive/pipedrive.svg @@ -1,4 +1,4 @@ - - - + + + From b8ccd63fed7b0fb34426a6c14f7bb6a7abb1b8a2 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Mon, 27 Jul 2026 13:42:47 -0700 Subject: [PATCH 04/12] feat(nodes): warn when tool_pipedrive publishes too many tools for an agent Full coverage is 256 tools. An operator can already reach that by selecting groups, and nothing told them the agent's tool choice degrades long before it. Adds a guard rail that counts published tools rather than groups, because group sizes are lopsided: deals is 28 tools and permission_sets is 3, so a group count bounds nothing useful. The threshold is 120, just under the 128-function cap some providers impose on a single request and above the 107 the default groups publish. Crossing it warns and carries on. The tools are still published, in both validateConfig (so it shows in the config panel while editing) and beginGlobal (so it shows at pipeline start). Silently dropping tools an operator asked for surfaces later as "the agent won't do X", which is worse than a noisy panel. The warning is deliberately not raised from _collect_tool_methods, which runs on every tool.query and tool.invoke. "all" is exempt: it is already an explicit opt-in to the whole surface, and scripted callers that never route through an LLM legitimately want it. Listing every group by name is not exempt. Group sizes are derived from the __pipedrive_group__ stamps rather than a hand-maintained table, so a new tool cannot drift out of sync; a test asserts every group in ALL_GROUPS has a count. Another test pins the advisory semantics so the warning is not later "fixed" into a silent truncation. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/tool_pipedrive/IGlobal.py | 37 ++++++++++++- nodes/src/nodes/tool_pipedrive/README.md | 18 +++++++ nodes/src/nodes/tool_pipedrive/services.json | 2 +- nodes/src/nodes/tool_pipedrive/tool_groups.py | 48 ++++++++++++++++- nodes/test/tool_pipedrive/test_pipedrive.py | 54 ++++++++++++++++++- 5 files changed, 155 insertions(+), 4 deletions(-) diff --git a/nodes/src/nodes/tool_pipedrive/IGlobal.py b/nodes/src/nodes/tool_pipedrive/IGlobal.py index ba193eff9..0fc0611a4 100644 --- a/nodes/src/nodes/tool_pipedrive/IGlobal.py +++ b/nodes/src/nodes/tool_pipedrive/IGlobal.py @@ -36,7 +36,14 @@ from rocketlib import IGlobalBase, OPEN_MODE, warning from .pipedrive_client import BASE_URL, base_url_for -from .tool_groups import ALL_GROUPS, DEFAULT_GROUPS, normalize_groups +from .tool_groups import ( + ALL_GROUPS, + DEFAULT_GROUPS, + RECOMMENDED_TOOL_LIMIT, + normalize_groups, + published_tool_count, + wants_all_groups, +) class IGlobal(IGlobalBase): @@ -64,6 +71,10 @@ def beginGlobal(self) -> None: if not self.token: raise Exception('tool_pipedrive: apiToken is required') + oversized = _oversized_warning(cfg.get('toolGroups'), self.tool_groups) + if oversized: + warning(oversized) + def validateConfig(self) -> None: try: cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) @@ -72,6 +83,9 @@ def validateConfig(self) -> None: unknown = _unknown_groups(cfg.get('toolGroups')) if unknown: warning(f'unknown tool group(s): {", ".join(unknown)}') + oversized = _oversized_warning(cfg.get('toolGroups'), normalize_groups(cfg.get('toolGroups'))) + if oversized: + warning(oversized) except Exception as e: warning(str(e)) @@ -84,6 +98,27 @@ def endGlobal(self) -> None: self.allow_raw_request = True +def _oversized_warning(raw, groups) -> str: + """Warn when the selected groups publish more tools than an LLM handles well. + + Advisory only — the tools are still published. ``all`` is an explicit opt-in + to the whole surface and is exempt. + """ + if wants_all_groups(raw): + return '' + try: + total = published_tool_count(groups) + except Exception: + return '' + if total <= RECOMMENDED_TOOL_LIMIT: + return '' + return ( + f'toolGroups publishes {total} tools, above the recommended {RECOMMENDED_TOOL_LIMIT}. ' + 'Agents pick the wrong tool more often at this size, and some providers reject more ' + 'than 128 tools in one request. Drop a group, or use "all" if this is deliberate.' + ) + + def _unknown_groups(raw) -> list[str]: """Group names in the config that this node does not implement.""" if not isinstance(raw, (list, tuple)): diff --git a/nodes/src/nodes/tool_pipedrive/README.md b/nodes/src/nodes/tool_pipedrive/README.md index e122a821a..3ccf9ce37 100644 --- a/nodes/src/nodes/tool_pipedrive/README.md +++ b/nodes/src/nodes/tool_pipedrive/README.md @@ -50,6 +50,24 @@ Available groups: A tool in a group that is not published is invisible to the agent and refused if invoked anyway. +### Tool-count guard rail + +Group sizes are uneven — `deals` is 28 tools, `permission_sets` is 3 — so counting +groups tells you little. The node counts **published tools** instead and warns when +a selection exceeds **120**, both in the config panel and at pipeline start: + +``` +toolGroups publishes 148 tools, above the recommended 120. Agents pick the wrong +tool more often at this size, and some providers reject more than 128 tools in one +request. Drop a group, or use "all" if this is deliberate. +``` + +It is a warning, not a block: the tools are still published and the pipeline still +runs. Silently dropping tools an operator asked for fails later and more +confusingly than a noisy config panel. `all` is exempt, since it is already an +explicit opt-in to the full 256-tool surface and suits scripted callers that do not +route through an LLM. + ### Pagination List tools take `start` (offset, default 0) and `limit` (1-500, default 100), and diff --git a/nodes/src/nodes/tool_pipedrive/services.json b/nodes/src/nodes/tool_pipedrive/services.json index 136c7e02b..eae4afb0f 100644 --- a/nodes/src/nodes/tool_pipedrive/services.json +++ b/nodes/src/nodes/tool_pipedrive/services.json @@ -56,7 +56,7 @@ "pipedrive.toolGroups": { "type": "array", "title": "Tool groups", - "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is ~230 tools, which is more than an LLM can choose between reliably, so only the listed groups are exposed. Use all to publish everything. Available groups: deals, persons, organizations, org_relationships, activities, pipelines, stages, notes, search, fields, leads, products, files, users, roles, permission_sets, teams, goals, filters, webhooks, subscriptions, mailbox, call_logs, projects, misc.", + "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is 256 tools, which is more than an LLM can choose between reliably, so only the listed groups are exposed. Group sizes vary a lot (deals is 28 tools, permission_sets is 3); selections that publish more than 120 tools log a warning but still run, and all publishes everything without warning. Available groups: deals, persons, organizations, org_relationships, activities, pipelines, stages, notes, search, fields, leads, products, files, users, roles, permission_sets, teams, goals, filters, webhooks, subscriptions, mailbox, call_logs, projects, misc.", "items": { "type": "string" }, diff --git a/nodes/src/nodes/tool_pipedrive/tool_groups.py b/nodes/src/nodes/tool_pipedrive/tool_groups.py index c18f6f224..f88b74a2b 100644 --- a/nodes/src/nodes/tool_pipedrive/tool_groups.py +++ b/nodes/src/nodes/tool_pipedrive/tool_groups.py @@ -26,11 +26,16 @@ """ Tool grouping for the Pipedrive node. -Full v1 coverage is ~230 tools, which is far more than an LLM can choose between +Full v1 coverage is 256 tools, which is far more than an LLM can choose between reliably. Every tool is therefore tagged with a resource group, and the node only publishes the groups named in the ``pipedrive.toolGroups`` config field. The filtering happens in ``IInstance._collect_tool_methods()``, so a group that is not published is invisible to ``tool.query`` and rejected by ``tool.invoke`` alike. + +Group sizes are very uneven (``deals`` is 28 tools, ``permission_sets`` is 3), so +the guard rail counts published *tools* rather than groups. Crossing it only warns: +scripted callers legitimately want a wide surface, and silently dropping tools an +operator asked for is worse than a noisy config panel. """ from __future__ import annotations @@ -79,6 +84,47 @@ #: of by a tool group. RAW_REQUEST_TOOL = 'request' +#: Publishing more tools than this makes an LLM's tool choice unreliable, and some +#: providers reject a larger list outright (OpenAI caps a request at 128 functions). +#: Crossing it is a warning, never an error, and ``all`` is exempt — see +#: ``IGlobal.validateConfig``. +RECOMMENDED_TOOL_LIMIT = 120 + + +def wants_all_groups(raw) -> bool: + """Whether the operator asked for every group by name rather than by listing them. + + ``all`` is an explicit opt-in to the full surface, so it is exempt from the + tool-count guard rail; selecting the same groups one by one is not. + """ + if isinstance(raw, str): + raw = raw.split(',') + if not isinstance(raw, (list, tuple, set, frozenset)): + return False + names = {str(g).strip().lower() for g in raw if str(g).strip()} + return 'all' in names or '*' in names + + +def tool_counts_by_group() -> dict[str, int]: + """Map each group to the number of tools it publishes. + + ``IInstance`` is imported lazily because the mixins it composes import this + module; a top-level import would be circular. + """ + from .IInstance import IInstance + + counts: dict[str, int] = {} + for name in dir(IInstance): + group = getattr(getattr(IInstance, name, None), '__pipedrive_group__', None) + if group: + counts[group] = counts.get(group, 0) + 1 + return counts + + +def published_tool_count(groups) -> int: + """How many tools the given set of groups publishes.""" + return sum(count for group, count in tool_counts_by_group().items() if group in groups) + def normalize_groups(raw) -> frozenset: """Turn the configured ``toolGroups`` value into a set of known group names. diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py index 5b65841b1..6f3a7dbfd 100644 --- a/nodes/test/tool_pipedrive/test_pipedrive.py +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -124,7 +124,17 @@ def _scoped_stubs() -> Iterator[None]: paginated, split_custom_fields, ) - from tool_pipedrive.tool_groups import ALL_GROUPS, DEFAULT_GROUPS, normalize_groups, pipedrive_tool + from tool_pipedrive.IGlobal import _oversized_warning + from tool_pipedrive.tool_groups import ( + ALL_GROUPS, + DEFAULT_GROUPS, + RECOMMENDED_TOOL_LIMIT, + normalize_groups, + pipedrive_tool, + published_tool_count, + tool_counts_by_group, + wants_all_groups, + ) from tool_pipedrive.tools._base import body_from, paging_params @@ -745,3 +755,45 @@ def test_file_create_sends_multipart(self, mock_request): assert kwargs['files']['file'][0] == 'a.txt' assert kwargs['files']['file'][1] == b'hello' assert kwargs['data'] == {'deal_id': 3} + + +class TestToolCountGuardRail: + """The cap counts published tools, warns rather than blocks, and exempts `all`.""" + + def test_every_group_has_a_count(self): + counts = tool_counts_by_group() + assert set(counts) == ALL_GROUPS + assert counts['deals'] > counts['permission_sets'] # sizes really are uneven + + def test_published_count_sums_only_selected_groups(self): + counts = tool_counts_by_group() + selection = {'deals', 'persons'} + assert published_tool_count(selection) == counts['deals'] + counts['persons'] + + def test_defaults_stay_under_the_limit(self): + assert published_tool_count(DEFAULT_GROUPS) <= RECOMMENDED_TOOL_LIMIT + + def test_full_surface_exceeds_the_limit(self): + assert published_tool_count(ALL_GROUPS) > RECOMMENDED_TOOL_LIMIT + + def test_no_warning_for_the_defaults(self): + assert _oversized_warning(sorted(DEFAULT_GROUPS), DEFAULT_GROUPS) == '' + + def test_warns_when_an_explicit_selection_is_too_large(self): + msg = _oversized_warning(sorted(ALL_GROUPS), ALL_GROUPS) + assert str(published_tool_count(ALL_GROUPS)) in msg + assert str(RECOMMENDED_TOOL_LIMIT) in msg + + @pytest.mark.parametrize('raw', [['all'], 'all', ['*'], ['deals', 'all']]) + def test_all_is_exempt(self, raw): + assert wants_all_groups(raw) is True + assert _oversized_warning(raw, ALL_GROUPS) == '' + + def test_listing_every_group_by_name_is_not_exempt(self): + assert wants_all_groups(sorted(ALL_GROUPS)) is False + assert _oversized_warning(sorted(ALL_GROUPS), ALL_GROUPS) != '' + + def test_warning_never_reduces_the_published_tools(self): + """The guard rail is advisory: an oversized selection still publishes everything.""" + published = _instance(tool_groups=ALL_GROUPS)._collect_tool_methods() + assert len(published) > RECOMMENDED_TOOL_LIMIT From cad916907836c115113ad22626a5905215e6dae8 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Tue, 28 Jul 2026 22:36:11 -0700 Subject: [PATCH 05/12] fix(ai): flatten provider content blocks to visible text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic with extended thinking returns `content` as a list of typed blocks, not a string. Every non-streaming path handed that list straight to callers, who stringified it — so the answer arrived as the blocks' repr, carrying the raw reasoning and its base64 signatures. For agents it was worse than cosmetic: the ReAct parser reads output back with a greedy `Action Input: (.*)`, so the serialized wrapper's trailing `", "type": "text"}]` landed inside the tool arguments and every declared field then reported as missing. Extract the block vocabulary that only the streaming path knew into `ai.common.utils.flatten_content_blocks` and route chat's invoke, the stream fallback, and the agent's `extract_text` through it. The streaming path now shares that vocabulary instead of duplicating it, so the paths cannot drift; the fallback also forwards recovered reasoning to `on_reasoning_chunk` rather than dropping it. Co-Authored-By: Claude Opus 5 (1M context) --- .../ai/src/ai/common/agent/_internal/utils.py | 28 +++- packages/ai/src/ai/common/chat.py | 53 ++++--- packages/ai/src/ai/common/utils/__init__.py | 7 +- .../ai/src/ai/common/utils/content_blocks.py | 82 +++++++++++ packages/ai/tests/ai/common/agent/__init__.py | 0 .../ai/common/agent/test_extract_text.py | 95 +++++++++++++ .../ai/common/utils/test_content_blocks.py | 134 ++++++++++++++++++ 7 files changed, 365 insertions(+), 34 deletions(-) create mode 100644 packages/ai/src/ai/common/utils/content_blocks.py create mode 100644 packages/ai/tests/ai/common/agent/__init__.py create mode 100644 packages/ai/tests/ai/common/agent/test_extract_text.py create mode 100644 packages/ai/tests/ai/common/utils/test_content_blocks.py diff --git a/packages/ai/src/ai/common/agent/_internal/utils.py b/packages/ai/src/ai/common/agent/_internal/utils.py index e9ce250e4..e79a03ab3 100644 --- a/packages/ai/src/ai/common/agent/_internal/utils.py +++ b/packages/ai/src/ai/common/agent/_internal/utils.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Union -from ai.common.utils import safe_str # re-imported so the helpers below can use it +from ai.common.utils import flatten_content_blocks, safe_str # re-imported so the helpers below can use it def new_run_id() -> str: @@ -53,6 +53,21 @@ def messages_to_transcript(messages: Union[str, List[Dict[str, str]]]) -> str: return '\n'.join(parts) +def _as_text(value: Any) -> str: + """Stringify a response payload, flattening provider content blocks. + + A list here is Anthropic-style typed blocks, not text. ``safe_str`` on it + yields the blocks' repr, which for an agent is actively harmful: the ReAct + parser reads the model's output back with a greedy ``Action Input: (.*)``, + so the serialized wrapper's trailing ``", "type": "text"}]`` lands inside the + tool arguments and every declared field then reports as missing. + """ + if isinstance(value, list): + text, _reasoning, _sig_only = flatten_content_blocks(value) + return text + return safe_str(value) + + def extract_text(result: Any) -> str: """ Extract response text from common engine return shapes. @@ -61,18 +76,21 @@ def extract_text(result: Any) -> str: - objects with `getText()` - objects with `getJson()` that include `answer`/`content`/`text` - any other object via `str(...)` + + Provider content blocks are flattened to their visible text wherever they + appear; reasoning blocks are dropped, since callers want the answer. """ try: if hasattr(result, 'getText') and callable(getattr(result, 'getText')): - return (safe_str(result.getText()) or '').strip() + return (_as_text(result.getText()) or '').strip() if hasattr(result, 'getJson') and callable(getattr(result, 'getJson')): data = result.getJson() if isinstance(data, dict): for k in ('answer', 'content', 'text'): if k in data and data[k] is not None: - return safe_str(data[k]).strip() - return safe_str(data).strip() - return safe_str(result).strip() + return _as_text(data[k]).strip() + return _as_text(data).strip() + return _as_text(result).strip() except Exception: return safe_str(result).strip() diff --git a/packages/ai/src/ai/common/chat.py b/packages/ai/src/ai/common/chat.py index 64c403914..b7da65d77 100644 --- a/packages/ai/src/ai/common/chat.py +++ b/packages/ai/src/ai/common/chat.py @@ -18,6 +18,7 @@ from ai.common.schema import Answer, Question from ai.common.config import Config from ai.common.util import parseJson +from ai.common.utils.content_blocks import flatten_content_blocks from ai.common.validation import validate_model_name, validate_max_tokens, validate_prompt from ai.common.llm_native_stream import STOP_SEQUENCES_VAR, dispatch_native_chat_stream @@ -242,8 +243,12 @@ def _chat(self, prompt: str) -> str: # so non-agent callers (and backends/mocks without a stop param) are unaffected. results = self._llm.invoke(prompt, **_stop_kwargs()) - # Return the results - return results.content + # `content` is a plain string on OpenAI-style backends but a list of typed + # blocks on Anthropic with extended thinking. Returning that list verbatim + # hands every caller the repr of the blocks instead of the answer, and the + # reasoning is not the answer in any case. Flatten to the visible text. + text, _reasoning, _sig_only = flatten_content_blocks(results.content) + return text def getTokens(self, value: str) -> int: """ @@ -497,8 +502,14 @@ def _chat_string_responses( # the full fallback would arrive on top of the partial we already streamed. if emitted is None or not emitted['any']: results = self._llm.invoke(prompt, **_stop_kwargs()) - content = getattr(results, 'content', '') or '' - content_text = content if isinstance(content, str) else str(content) + # str() on a typed-block list yields the blocks' repr, not the + # answer — it would put the model's raw reasoning (and its base64 + # signatures) into the visible output. + content_text, fallback_reasoning, _sig_only = flatten_content_blocks( + getattr(results, 'content', '') or '' + ) + if fallback_reasoning and on_reasoning_chunk is not None: + on_reasoning_chunk(fallback_reasoning) text_parts = [content_text] # Push the fallback answer through on_chunk so the open UI bubble # gets the visible text (the caller dedupes the final pipeline result). @@ -603,30 +614,16 @@ def on_chunk_w(t): text = '' thinking_delta = '' if isinstance(content, list): - for b in content: - if not isinstance(b, dict): - continue - btype = b.get('type', '') - if btype == 'thinking': - # carries either text deltas or a signature-only final delta. - piece_text = b.get('thinking') or b.get('text') or '' - if piece_text: - thinking_delta += piece_text - elif b.get('signature') and not _signature_only_note_sent: - if on_reasoning_chunk_w is not None: - thinking_delta += ( - '_Extended thinking ran, but this stream only delivered the ' - 'block verification signature, not the readable chain-of-thought ' - 'text. The answer below still reflects internal reasoning._\n\n' - ) - _signature_only_note_sent = True - elif btype == 'reasoning': - # LangChain v1 standard block (thinking → reasoning). - piece_text = b.get('reasoning') or b.get('text') or '' - if piece_text: - thinking_delta += piece_text - elif btype == 'text' or not btype: - text += b.get('text', '') + # Block vocabulary lives in flatten_content_blocks so the + # non-streaming paths cannot drift away from this one. + text, thinking_delta, sig_only = flatten_content_blocks(content) + if sig_only and not _signature_only_note_sent and on_reasoning_chunk_w is not None: + thinking_delta += ( + '_Extended thinking ran, but this stream only delivered the ' + 'block verification signature, not the readable chain-of-thought ' + 'text. The answer below still reflects internal reasoning._\n\n' + ) + _signature_only_note_sent = True elif isinstance(content, str): # Strip inline `...` (Perplexity sonar-reasoning fallback). text, _thinking_inline = _think_split(content) diff --git a/packages/ai/src/ai/common/utils/__init__.py b/packages/ai/src/ai/common/utils/__init__.py index b585951e8..ce1273670 100644 --- a/packages/ai/src/ai/common/utils/__init__.py +++ b/packages/ai/src/ai/common/utils/__init__.py @@ -3,6 +3,8 @@ Public surface: - ``safe_str`` — convert any value to a string without raising. +- ``flatten_content_blocks`` — split a provider response's ``content`` + (string, or Anthropic-style typed blocks) into visible text and reasoning. - ``normalize_tool_input``, ``validate_tool_input_schema``, and the ``require_*`` / ``optional_*`` / ``require_dict`` / ``int_arg`` validators — strict parsing of LLM-supplied tool arguments. @@ -20,7 +22,8 @@ — image/ndarray (de)serialization helpers. - ``validate_public_url`` — reject non-public/SSRF-prone URLs. -Implementations live in submodules (``string_utils``, ``tool_args``, +Implementations live in submodules (``string_utils``, ``content_blocks``, +``tool_args``, ``config_utils``, ``agent_tools``, ``file_utils``, ``cuda_utils``, ``http_retry``, ``image_utils``, ``url_utils``); this package re-exports them so the canonical import path is ``from ai.common.utils import ``. @@ -28,6 +31,7 @@ from .agent_tools import langchain_messages_to_transcript, normalize_bound_tools from .config_utils import config_int, parse_bool +from .content_blocks import flatten_content_blocks from .file_utils import decode_data_url, guess_filename from .cuda_utils import pick_torch_device, pick_torch_dtype, resolve_pipeline_device from .http_retry import get_with_retry, post_with_retry @@ -53,6 +57,7 @@ 'colorize_depth', 'config_int', 'decode_data_url', + 'flatten_content_blocks', 'guess_filename', 'get_with_retry', 'int_arg', diff --git a/packages/ai/src/ai/common/utils/content_blocks.py b/packages/ai/src/ai/common/utils/content_blocks.py new file mode 100644 index 000000000..7556fe105 --- /dev/null +++ b/packages/ai/src/ai/common/utils/content_blocks.py @@ -0,0 +1,82 @@ +""" +Flatten provider content blocks into plain text. + +Chat models return one of two shapes. OpenAI-style backends return a plain +string. Anthropic-style backends with extended thinking enabled return a LIST of +typed blocks:: + + [{'type': 'thinking', 'thinking': '...', 'signature': ''}, {'type': 'text', 'text': 'the actual answer'}] + +Anything that treats that list as text — ``str(content)``, ``safe_str(...)``, or +just returning it — produces the *repr of the list* rather than the answer. That +is not a cosmetic problem for agents: the ReAct parser reads the model's output +back with a greedy ``Action Input: (.*)`` and swallows the serialized wrapper's +trailing ``", "type": "text"}]`` into the tool arguments, which json-repair then +welds onto the first key. Every tool call arrives as a single-key dict and every +declared field reports as missing. + +So the block vocabulary lives here, once, and both the streaming and the +non-streaming response paths read it from the same place. +""" + +from __future__ import annotations + +from typing import Any, Tuple + +__all__ = ['flatten_content_blocks'] + + +def flatten_content_blocks(content: Any) -> Tuple[str, str, bool]: + """Split provider content into visible text and reasoning text. + + Args: + content: A response's ``content``: a plain string, or a list of typed + blocks. Any other type is stringified so a caller never has to + special-case ``None`` or an unexpected shape. + + Returns: + ``(text, reasoning, saw_signature_only)``: + + - ``text`` — the visible answer: ``text`` blocks, plus untyped blocks, + concatenated in order. + - ``reasoning`` — chain-of-thought carried by ``thinking`` blocks + (Anthropic) or ``reasoning`` blocks (the LangChain v1 standard name). + Callers route this to a reasoning lane; it must never be concatenated + into ``text``. + - ``saw_signature_only`` — True when a thinking block carried only a + verification ``signature`` and no readable text. Streaming callers use + this to explain the gap to the user once per response. + + A plain string passes through as ``text`` untouched — inline ```` + tags are a separate, stateful concern owned by the streaming path. + """ + if isinstance(content, str): + return content, '', False + if not isinstance(content, list): + return ('' if content is None else str(content)), '', False + + text_parts: list = [] + reasoning_parts: list = [] + saw_signature_only = False + + for block in content: + if not isinstance(block, dict): + continue + btype = block.get('type', '') + if btype == 'thinking': + # Carries either readable deltas or, on the final delta, only the + # block verification signature. + piece = block.get('thinking') or block.get('text') or '' + if piece: + reasoning_parts.append(piece) + elif block.get('signature'): + saw_signature_only = True + elif btype == 'reasoning': + # LangChain v1 renamed the standard block: thinking -> reasoning. + piece = block.get('reasoning') or block.get('text') or '' + if piece: + reasoning_parts.append(piece) + elif btype == 'text' or not btype: + text_parts.append(block.get('text', '')) + + return ''.join(text_parts), ''.join(reasoning_parts), saw_signature_only diff --git a/packages/ai/tests/ai/common/agent/__init__.py b/packages/ai/tests/ai/common/agent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/ai/tests/ai/common/agent/test_extract_text.py b/packages/ai/tests/ai/common/agent/test_extract_text.py new file mode 100644 index 000000000..d2111da0c --- /dev/null +++ b/packages/ai/tests/ai/common/agent/test_extract_text.py @@ -0,0 +1,95 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +""" +Unit tests for ``ai.common.agent._internal.utils.extract_text``. + +This is the seam every agent driver reads the host LLM through +(``AgentBase.call_llm`` -> ``extract_text``). It used to stringify whatever it +was handed, so an Anthropic-style typed-block list arrived at CrewAI's ReAct +parser as the blocks' repr and every tool call lost its arguments. The tests +below pin the flattening at each of the shapes ``extract_text`` accepts. + +Run with:: + + pytest packages/ai/tests/ai/common/agent/test_extract_text.py -v +""" + +from __future__ import annotations + +from typing import Any + +from ai.common.agent._internal.utils import extract_text, truncate_at_stop_words + +BLOCKS = [ + {'type': 'thinking', 'thinking': 'internal reasoning', 'signature': 'BASE64=='}, + {'type': 'text', 'text': 'Action Input: {"task": "go"}'}, +] + + +class _WithText: + """Engine result exposing ``getText()``.""" + + def __init__(self, value: Any): + self._value = value + + def getText(self): + return self._value + + +class _WithJson: + """Engine result exposing ``getJson()``.""" + + def __init__(self, value: Any): + self._value = value + + def getJson(self): + return self._value + + +class TestPlainShapes: + def test_get_text_string(self): + assert extract_text(_WithText(' hello ')) == 'hello' + + def test_get_json_dict_prefers_answer(self): + assert extract_text(_WithJson({'answer': 'a', 'content': 'b'})) == 'a' + + def test_get_json_dict_falls_through_to_text(self): + assert extract_text(_WithJson({'text': 'c'})) == 'c' + + def test_bare_value(self): + assert extract_text('plain') == 'plain' + + +class TestContentBlocks: + """The regression: a list is blocks, not text.""" + + def test_blocks_from_get_text(self): + assert extract_text(_WithText(BLOCKS)) == 'Action Input: {"task": "go"}' + + def test_blocks_from_get_json_top_level(self): + assert extract_text(_WithJson(BLOCKS)) == 'Action Input: {"task": "go"}' + + def test_blocks_nested_under_a_dict_key(self): + assert extract_text(_WithJson({'content': BLOCKS})) == 'Action Input: {"task": "go"}' + + def test_reasoning_is_dropped(self): + out = extract_text(_WithText(BLOCKS)) + assert 'internal reasoning' not in out + assert 'BASE64' not in out + + def test_no_block_wrapper_survives(self): + """`"type": "text"` in the output is what corrupted the ReAct argument capture.""" + out = extract_text(_WithText(BLOCKS)) + assert '"type"' not in out + assert not out.startswith('['), 'a list repr means the blocks were stringified' + + +class TestStopWordsAfterFlattening: + def test_truncation_matches_once_the_newline_is_real(self): + """A serialized block list escapes newlines, so the marker never matched.""" + blocks = [{'type': 'text', 'text': 'Thought: go\nObservation: fabricated'}] + text = extract_text(_WithText(blocks)) + assert truncate_at_stop_words(text, ['\nObservation:']) == 'Thought: go' diff --git a/packages/ai/tests/ai/common/utils/test_content_blocks.py b/packages/ai/tests/ai/common/utils/test_content_blocks.py new file mode 100644 index 000000000..c563cef15 --- /dev/null +++ b/packages/ai/tests/ai/common/utils/test_content_blocks.py @@ -0,0 +1,134 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +""" +Unit tests for ``ai.common.utils.content_blocks.flatten_content_blocks``. + +These pin the bug where an Anthropic-style typed-block list reached the agent +layer as text. ``str()`` / ``safe_str()`` on that list yields the blocks' repr:: + + [{'type': 'thinking', ...}, {'text': 'Thought: ...', 'type': 'text'}] + +and CrewAI's ReAct parser reads the model's output back with a greedy +``Action Input: (.*)`` under DOTALL. The capture therefore ran past the JSON +arguments and swallowed the serialized wrapper's trailing ``", "type": "text"}]``, +which json-repair welded onto the first key. Every tool call arrived as a +single-key dict and every declared field reported ``Field required``. + +The regression assertions are ``test_answer_survives_the_react_argument_regex`` +and ``test_reasoning_never_leaks_into_text``. + +Run with:: + + pytest packages/ai/tests/ai/common/utils/test_content_blocks.py -v +""" + +from __future__ import annotations + +import re + +from ai.common.utils import flatten_content_blocks + +# The regex CrewAI uses (crewai/agents/constants.py): greedy, DOTALL. +ACTION_INPUT_REGEX = re.compile(r'Action\s*\d*\s*:\s*(.*?)\s*Action\s*\d*\s*Input\s*\d*\s*:\s*(.*)', re.DOTALL) + + +class TestPlainStrings: + def test_string_passes_through(self): + assert flatten_content_blocks('hello') == ('hello', '', False) + + def test_none_is_empty(self): + assert flatten_content_blocks(None) == ('', '', False) + + def test_unexpected_type_is_stringified_not_raised(self): + text, reasoning, sig = flatten_content_blocks(42) + assert (text, reasoning, sig) == ('42', '', False) + + +class TestBlockVocabulary: + def test_text_blocks_concatenate_in_order(self): + blocks = [{'type': 'text', 'text': 'one '}, {'type': 'text', 'text': 'two'}] + assert flatten_content_blocks(blocks)[0] == 'one two' + + def test_thinking_goes_to_reasoning(self): + blocks = [{'type': 'thinking', 'thinking': 'ponder'}, {'type': 'text', 'text': 'answer'}] + text, reasoning, _ = flatten_content_blocks(blocks) + assert text == 'answer' + assert reasoning == 'ponder' + + def test_reasoning_block_is_the_langchain_v1_alias(self): + blocks = [{'type': 'reasoning', 'reasoning': 'ponder'}, {'type': 'text', 'text': 'answer'}] + text, reasoning, _ = flatten_content_blocks(blocks) + assert (text, reasoning) == ('answer', 'ponder') + + def test_untyped_block_counts_as_text(self): + assert flatten_content_blocks([{'text': 'bare'}])[0] == 'bare' + + def test_signature_only_thinking_is_reported_not_emitted(self): + blocks = [{'type': 'thinking', 'signature': 'BASE64=='}, {'type': 'text', 'text': 'answer'}] + text, reasoning, sig_only = flatten_content_blocks(blocks) + assert (text, reasoning, sig_only) == ('answer', '', True) + assert 'BASE64' not in text, 'a verification signature is not model output' + + def test_non_dict_entries_are_skipped(self): + assert flatten_content_blocks(['junk', None, {'type': 'text', 'text': 'ok'}])[0] == 'ok' + + def test_empty_list_is_empty_text(self): + assert flatten_content_blocks([]) == ('', '', False) + + +class TestReActRegression: + """The exact failure that motivated this helper.""" + + BLOCKS = [ + {'type': 'thinking', 'thinking': 'I should delegate this.', 'signature': 'BASE64=='}, + { + 'type': 'text', + 'text': ( + 'Thought: resolve the participants first\n' + 'Action: delegate_work_to_coworker\n' + 'Action Input: {"task": "Search", "context": "call intake", "coworker": "Specialist"}' + ), + }, + ] + + def test_answer_survives_the_react_argument_regex(self): + """Flattened, the greedy capture ends at the JSON's closing brace.""" + text, _reasoning, _sig = flatten_content_blocks(self.BLOCKS) + + match = ACTION_INPUT_REGEX.search(text) + assert match is not None + assert match.group(1) == 'delegate_work_to_coworker' + + import json + + args = json.loads(match.group(2).strip()) + assert set(args) == {'task', 'context', 'coworker'}, 'all three fields must parse' + + def test_the_unflattened_list_is_what_broke(self): + """Guards the diagnosis: str() of the blocks corrupts the captured args.""" + match = ACTION_INPUT_REGEX.search(str(self.BLOCKS)) + assert match is not None + + import json + + try: + json.loads(match.group(2).strip()) + except json.JSONDecodeError: + pass # expected: the capture ran past the JSON into the block wrapper + else: + raise AssertionError('stringified blocks should not yield parseable tool args') + + def test_reasoning_never_leaks_into_text(self): + text, reasoning, _sig = flatten_content_blocks(self.BLOCKS) + assert 'I should delegate this.' not in text + assert reasoning == 'I should delegate this.' + + def test_stop_word_truncation_can_match_a_real_newline(self): + """A serialized list escapes newlines, so "\\nObservation:" never matched.""" + blocks = [{'type': 'text', 'text': 'Thought: go\nObservation: fabricated'}] + text, _reasoning, _sig = flatten_content_blocks(blocks) + assert '\nObservation:' in text + assert '\nObservation:' not in str(blocks), 'the serialized form escapes the newline' From 15f3ceee59f8449d93117ef1c41c9b863b63cf20 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Tue, 28 Jul 2026 22:36:29 -0700 Subject: [PATCH 06/12] fix(nodes): make CrewAI tool calls and hierarchical kickoffs survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects made the CrewAI nodes fail on runs that should have worked. Tool arguments were dropped. `_ToolInput` declared a single `input` field, and CrewAI's argument filter intersects the model's emitted keys with the schema's property names — so every real parameter was discarded before validation, which then reported all required fields missing. Generate a typed model per tool instead: JSON Schema types map to real annotations (so pydantic coerces "123" -> 123), optional fields accept an explicit null, and paramless tools get an empty schema. The JSON schema is no longer appended to the description either; CrewAI renders args_schema into the prompt itself, and the two copies disagreed. `HostInvokeLLM` never declared `supports_function_calling()`. CrewAI defines it on `LLM` but not on `BaseLLM`, and several call sites invoke it unguarded — converter.py, tool_usage.py, reasoning_handler, and task_evaluator. The AttributeError was swallowed by converter's broad except and re-reported as "Failed to convert text into a Pydantic model". Declare it False: the host channel is text-in / text-out and cannot honour native tool calls, so every call belongs on the ReAct path. Pin crewai <2 for the same reason — 1.15.x moves the BaseLLM contract. Hierarchical crews then died in planning. `Crew(planning=True)` runs a task with `output_pydantic` set and drives it with the host wrapper, which cannot promise JSON; unparseable plan text dead-ends the converter and raises "Failed to get the Planning output". Make planning opt-in via a new **Crew Planning** field, off by default. Also clear `Task.tools` after construction: CrewAI back-fills them from the task's agent, and a hierarchical crew resolves the executing agent's toolset for the manager — which let the manager work a delegate's tools itself instead of delegating. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/agent_crewai/README.md | 6 +- nodes/src/nodes/agent_crewai/crewai_base.py | 132 +++++-- .../agent_crewai/crewai_manager/IGlobal.py | 5 +- .../agent_crewai/crewai_manager/README.md | 4 +- .../agent_crewai/crewai_manager/manager.py | 37 +- nodes/src/nodes/agent_crewai/requirements.txt | 5 +- .../nodes/agent_crewai/services.manager.json | 15 +- nodes/test/agent_crewai/test_llm_contract.py | 222 +++++++++++ .../agent_crewai/test_manager_planning.py | 206 ++++++++++ .../agent_crewai/test_manager_tool_scoping.py | 171 ++++++++ nodes/test/agent_crewai/test_stop_words.py | 37 +- nodes/test/agent_crewai/test_tool_schema.py | 370 ++++++++++++++++++ 12 files changed, 1145 insertions(+), 65 deletions(-) create mode 100644 nodes/test/agent_crewai/test_llm_contract.py create mode 100644 nodes/test/agent_crewai/test_manager_planning.py create mode 100644 nodes/test/agent_crewai/test_manager_tool_scoping.py create mode 100644 nodes/test/agent_crewai/test_tool_schema.py diff --git a/nodes/src/nodes/agent_crewai/README.md b/nodes/src/nodes/agent_crewai/README.md index 409a8f7d6..86a48f9bd 100644 --- a/nodes/src/nodes/agent_crewai/README.md +++ b/nodes/src/nodes/agent_crewai/README.md @@ -7,7 +7,7 @@ Run CrewAI agents inside RocketRide: as a standalone agent, as a hierarchical ma Three node variants share the same base driver (`crewai_base.py`), each loaded from its own sub-package: - **CrewAI Agent** (`agent_crewai`): a standalone single agent. Assembles a CrewAI `Agent` + `Task` into a one-agent, sequential-process `Crew` and runs it to answer the incoming question. -- **CrewAI Manager** (`agent_crewai_manager`): orchestrates a crew using CrewAI's hierarchical process. Fans out a `describe` invoke to connected CrewAI Subagent nodes, assembles a `Crew` with the manager as delegator (`planning=True`, using the manager's LLM as the planning LLM), and synthesizes sub-agent outputs into one answer. +- **CrewAI Manager** (`agent_crewai_manager`): orchestrates a crew using CrewAI's hierarchical process. Fans out a `describe` invoke to connected CrewAI Subagent nodes, assembles a `Crew` with the manager as delegator, and synthesizes sub-agent outputs into one answer. CrewAI's planner is opt-in via the **Crew Planning** field and off by default. - **CrewAI Subagent** (`agent_crewai_subagent`): a managed worker. Wired into a Manager via the `crewai` channel and delegated to by name (its `role`); it has no `questions` lane and cannot be invoked directly. Uses the **crewai** library (`>=1.14.1`). The node never calls a model provider directly: the wrapped CrewAI `BaseLLM` and `BaseTool` instances route every LLM and tool call back through the pipeline's own `llm` and `tool` channels, so each agent uses exactly the LLM and tools wired to its own node. @@ -82,7 +82,7 @@ The Manager requires at least one connected CrewAI Subagent on the `crewai` chan 1. Fans out a `describe` invoke to each node on the `crewai` channel individually. Each Subagent responds with its role, goal, backstory, task description, expected output, instructions, and a handle to its own engine channels. 2. Builds a CrewAI `Agent` + `Task` per descriptor (`max_iter=5`, `allow_delegation=False`), routing LLM and tool calls back through that sub-agent's own `llm`/`tool` channels. The sub-agents' channels are fully independent of the Manager's. 3. Builds the manager agent (`allow_delegation=True`, `max_iter=5`) from this node's `llm` channel and config. The user's prompt is placed in the manager's backstory as background context, keeping the goal generic. -4. Kicks off a `Process.hierarchical` Crew with `planning=True` and returns the synthesized result. +4. Kicks off a `Process.hierarchical` Crew and returns the synthesized result. Planning runs only when **Crew Planning** is enabled. ### Result extraction @@ -100,6 +100,8 @@ Every CrewAI bus event from a kickoff (crew/task/agent lifecycle, tool usage, LL - When the LLM uses native function calling, CrewAI invokes tools synchronously on the shared kickoff loop, so tool calls serialize across concurrent runs in that path. The ReAct tool path (`_arun`) offloads to a thread and does not block the loop. - CrewAI 1.14.x planning and delegation are patched at import time to run safely inside the shared asyncio loop. Without these patches, hierarchical crews raise `RuntimeError` when they detect a running event loop. +- **Crew Planning is off by default.** CrewAI's planner runs a task with `output_pydantic` set and drives it with the host LLM wrapper, but the host channel is text-in / text-out and cannot promise JSON. When the plan text does not parse, CrewAI's converter dead-ends and `planning_handler` raises `Failed to get the Planning output`, failing a run that would otherwise have succeeded. Enable it only with a model that reliably emits well-formed JSON. +- The LLM wrapper reports `supports_function_calling() == False`, so every tool call takes CrewAI's ReAct path. The host channel returns plain text and ignores `tools` / `response_model`, so claiming native tool support would advertise a capability the wrapper cannot honour. --- diff --git a/nodes/src/nodes/agent_crewai/crewai_base.py b/nodes/src/nodes/agent_crewai/crewai_base.py index 2ecafab61..fe01af4e0 100644 --- a/nodes/src/nodes/agent_crewai/crewai_base.py +++ b/nodes/src/nodes/agent_crewai/crewai_base.py @@ -294,6 +294,26 @@ async def acall( **kwargs, ) + def supports_function_calling(self) -> bool: + # The host channel is text-in / text-out: `call` ignores `tools`, + # `available_functions` and `response_model` and always returns a + # string, so claiming native tool support would send CrewAI down a + # path this wrapper cannot honour. Returning False keeps every + # tool call on the ReAct path that `_build_crew_tools` targets. + # + # This override is REQUIRED, not just a preference. CrewAI defines + # supports_function_calling() on crewai.llm.LLM and on each provider + # completion class, but NOT on BaseLLM. Most callers probe it with + # hasattr(), yet several do not -- crewai/utilities/converter.py + # to_pydantic/ato_pydantic/to_json, crewai/tools/tool_usage.py + # _function_calling, reasoning_handler, task_evaluator. Without this + # method those raise AttributeError, which converter.py swallows in a + # broad `except Exception` and re-reports as the misleading + # "Failed to convert text into a Pydantic model due to error: ...". + # The hierarchical manager hits that path on every kickoff, because + # Crew(planning=True) runs a planner task with output_pydantic set. + return False + return HostInvokeLLM() def _build_crew_tools( @@ -318,29 +338,74 @@ def _build_crew_tools( outer_self = self outer_context = context - class _ToolInput(BaseModel): - input: Any = Field(default=None, description='Tool input payload') - model_config = ConfigDict(extra='allow') + # JSON Schema type -> Python annotation. CrewAI renders the generated model's + # JSON schema straight into the ReAct prompt as the "Tool Arguments:" block + # (crewai/tools/structured_tool.py format_description_for_llm), so an + # untyped model shows the LLM argument names with no types at all. Typing the + # fields also lets pydantic coerce "123" -> 123 instead of rejecting it. + # Nesting stays shallow on purpose: CrewAI only renders and filters the top + # level, and a deep model would reject payloads the host tool accepts through + # its own `extra` passthrough. + _JSON_TYPE_MAP: Dict[str, Any] = { + 'string': str, + 'integer': int, + 'number': float, + 'boolean': bool, + 'array': List[Any], + 'object': Dict[str, Any], + } + + class _NoArgsInput(BaseModel): + """Schema for tools that declare no parameters.""" + + model_config = ConfigDict(extra='ignore') + + def _annotation_for(prop: Dict[str, Any]) -> Any: + """Map a JSON Schema property to a Python annotation, defaulting to Any. + + `Any` is the deliberate fallback for unions, `null`, and anything the map + does not cover — a wrong guess here would reject valid tool calls, which + is worse than leaving the field untyped. + """ + return _JSON_TYPE_MAP.get(prop.get('type'), Any) def _make_args_schema(input_schema: Optional[Dict[str, Any]]) -> type[BaseModel]: """ Build a dynamic Pydantic model from a JSON Schema so that CrewAI's argument filter preserves real tool parameters. + + The filter matters: crewai/tools/tool_usage.py intersects the model's + emitted argument keys with this schema's property names and silently drops + everything else. A name the schema does not declare is lost before + validation, which then reports every required field as missing. """ if not isinstance(input_schema, dict): - return _ToolInput - props = input_schema.get('properties', {}) - if not props: - return _ToolInput - required_keys = set(input_schema.get('required', [])) + return _NoArgsInput + props = input_schema.get('properties') + if not isinstance(props, dict) or not props: + return _NoArgsInput + required = input_schema.get('required') or [] + required_keys = set(required) if isinstance(required, (list, tuple, set)) else set() + field_defs: Dict[str, Any] = {} for key, prop in props.items(): + if not isinstance(key, str) or not key: + continue + if not isinstance(prop, dict): + prop = {} desc = prop.get('description', '') + annotation = _annotation_for(prop) if key in required_keys: - field_defs[key] = (Any, Field(..., description=desc)) + field_defs[key] = (annotation, Field(..., description=desc)) else: - default = prop.get('default', None) - field_defs[key] = (Any, Field(default=default, description=desc)) + # Optional args accept an explicit null as well as omission — + # models routinely send "field": null for "not applicable". + field_defs[key] = ( + Optional[annotation], + Field(default=prop.get('default', None), description=desc), + ) + if not field_defs: + return _NoArgsInput try: return create_model( '_DynToolInput', @@ -348,19 +413,19 @@ def _make_args_schema(input_schema: Optional[Dict[str, Any]]) -> type[BaseModel] **field_defs, ) except Exception: - return _ToolInput + return _NoArgsInput class HostTool(BaseTool): name: str description: str - args_schema: type[BaseModel] = _ToolInput + args_schema: type[BaseModel] = _NoArgsInput def __repr__(self) -> str: - # Strip JSON schema suffix and CrewAI-reformatted header so planning - # prompts see a clean one-liner — same as native CrewAI tool reprs. - desc = self.description.split('\n\nTool input schema (JSON):')[0] - if '\nTool Description: ' in desc: - desc = desc.split('\nTool Description: ', 1)[1].split('\n')[0] + # Keep planning prompts to a clean one-liner, same as native CrewAI + # tool reprs. CrewAI composes its own "Tool Name / Tool Arguments / + # Tool Description" block on demand via `formatted_description` and no + # longer rewrites `description`, so only the first line is needed. + desc = self.description.split('\n')[0] return f'Tool(name={self.name!r}, description={desc!r})' __str__ = __repr__ @@ -377,18 +442,12 @@ def _run(self, **framework_args: Any) -> str: return safe_str(out) async def _arun(self, **framework_args: Any) -> str: - # The ReAct tool path goes tool.ainvoke -> _arun, so this is what - # gets called when the LLM does NOT use native function calling. - # Wraps the existing sync _run via to_thread to avoid blocking - # the shared kickoff loop. - # - # NOTE: the native tools path (used by GPT-4 / Claude / any LLM - # with supports_function_calling()==True) bypasses _arun entirely - # and calls _run synchronously via available_functions[name]. - # See the "Known Limitations" section of the plan -- tool calls - # serialize on the loop in that path. Override - # supports_function_calling()->False on HostInvokeLLM to force - # the ReAct path if full tool concurrency is required. + # Kept for callers that reach BaseTool.arun directly. CrewAI's own + # ReAct path does NOT come through here: to_structured_tool() binds + # func=self._run (the sync method), and CrewStructuredTool.ainvoke + # sees a non-coroutine and dispatches it via run_in_executor. The + # off-loop behaviour this wrapper was written for is therefore already + # provided by CrewAI; this override only matters if that changes back. return await asyncio.to_thread(self._run, **framework_args) tools = [] @@ -399,15 +458,12 @@ async def _arun(self, **framework_args: Any) -> str: continue if not desc: desc = f'Invoke host tool: {name}' + # The schema is NOT appended to the description. CrewAI renders the + # args_schema into the prompt itself as a "Tool Arguments:" JSON Schema + # block, so a second copy here gives the model two contracts to reconcile + # for the same tool — and the two disagree, because CrewAI's copy is run + # through its strict-mode normaliser first. input_schema = td.get('inputSchema') if isinstance(td, dict) else None - if isinstance(input_schema, dict): - try: - schema_text = json.dumps(input_schema, ensure_ascii=False) - except Exception: - schema_text = '' - if schema_text: - desc = f'{desc}\n\nTool input schema (JSON): {schema_text}' - schema_cls = _make_args_schema(input_schema) tools.append(HostTool(name=name, description=desc, args_schema=schema_cls)) return tools diff --git a/nodes/src/nodes/agent_crewai/crewai_manager/IGlobal.py b/nodes/src/nodes/agent_crewai/crewai_manager/IGlobal.py index fad93915c..894df3027 100644 --- a/nodes/src/nodes/agent_crewai/crewai_manager/IGlobal.py +++ b/nodes/src/nodes/agent_crewai/crewai_manager/IGlobal.py @@ -6,7 +6,7 @@ """ CrewAI Manager — global state for the hierarchical multi-agent CrewAI node. -Loads the manager-only connConfig fields (`goal`, `backstory`), ensures the +Loads the manager-only connConfig fields (`goal`, `backstory`, `planning`), ensures the process-wide CrewAI kickoff runner is running, and instantiates the `CrewManager` driver held on `self.agent`. @@ -29,6 +29,7 @@ class IGlobal(IGlobalBase): agent: Any = None goal: str = '' backstory: str = '' + planning: bool = False _kickoff_runner: Any = None def beginGlobal(self) -> None: @@ -51,6 +52,7 @@ def beginGlobal(self) -> None: self.goal = str(conn_config.get('goal') or '').strip() self.backstory = str(conn_config.get('backstory') or '').strip() + self.planning = bool(conn_config.get('planning', False)) from ..crewai_runner import get_shared_runner @@ -64,4 +66,5 @@ def endGlobal(self) -> None: self.agent = None self.goal = '' self.backstory = '' + self.planning = False self._kickoff_runner = None diff --git a/nodes/src/nodes/agent_crewai/crewai_manager/README.md b/nodes/src/nodes/agent_crewai/crewai_manager/README.md index 97234a9ed..f58eae0b9 100644 --- a/nodes/src/nodes/agent_crewai/crewai_manager/README.md +++ b/nodes/src/nodes/agent_crewai/crewai_manager/README.md @@ -18,7 +18,7 @@ Hierarchical [CrewAI](https://docs.crewai.com/) manager node. Fans out to all co | Channel | Required | Description | | -------- | ----------- | ----------------------------------------- | -| `llm` | yes | LLM for the manager agent and planning | +| `llm` | yes | LLM for the manager agent (and planning, when enabled) | | `crewai` | yes (min 1) | Connected `CrewAI Subagent` nodes (min 1) | The `crewai` channel accepts only `CrewAI Subagent` nodes. `CrewAI Agent` nodes cannot be used as sub-agents, their `classType` does not include `crewai`. The Manager itself also cannot be nested under another Manager (it lacks `classType: "crewai"`); cross-Manager composition is supported via `tool.run_agent` instead. @@ -45,6 +45,6 @@ By default only **Instructions** is shown. Toggle **Advanced Mode** to expose th 1. Fans out `describe` to each connected `CrewAI Subagent` node individually 2. Each `CrewAI Subagent` responds with its role, goal, backstory, task description, expected output, and tools 3. The manager builds an `Agent + Task` per sub-agent, routing LLM and tool calls back through that sub-agent's own pipeline channels -4. Kicks off a hierarchical Crew with `planning=True` using the manager's LLM +4. Kicks off a hierarchical Crew, running CrewAI's planner with the manager's LLM only when **Crew Planning** is enabled (off by default) The node raises an error at runtime if no sub-agents are connected or none respond to `describe`. diff --git a/nodes/src/nodes/agent_crewai/crewai_manager/manager.py b/nodes/src/nodes/agent_crewai/crewai_manager/manager.py index 76a6bfe82..c06eecb46 100644 --- a/nodes/src/nodes/agent_crewai/crewai_manager/manager.py +++ b/nodes/src/nodes/agent_crewai/crewai_manager/manager.py @@ -261,6 +261,27 @@ def _run( context=[], ) + # Keep the delegate's tools off the manager. + # + # CrewAI back-fills Task.tools from the task's agent (crewai/task.py + # check_tools), and crewai/crews/utils.py then resolves the executing + # agent's toolset as `task.tools or agent_to_use.tools` -- while + # Crew._get_agent_to_use (crewai/crew.py) returns the MANAGER for a + # hierarchical process. The manager therefore inherits whichever delegate's tools + # belong to the task it is running and can work the tools itself + # instead of delegating, which is exactly what a tool-split prompt + # forbids. + # + # This must happen after construction: passing `tools=[]` to Task() + # does not survive, because check_tools runs as an after-validator and + # reads the empty list as "unset". Task sets no validate_assignment, so + # assigning here is not re-validated and sticks. + # + # Delegation is unaffected: crewai/tools/agent_tools builds a fresh + # Task bound to the coworker, which back-fills from that agent's own + # tools. + task_obj.tools = [] + sub_agents.append(agent_obj) sub_tasks.append(task_obj) @@ -288,13 +309,25 @@ def _run( ) # 5. Assemble and kick off the hierarchical Crew. + # + # Planning is opt-in and off by default. CrewAI's planner runs a task with + # output_pydantic=PlannerTaskPydanticOutput and drives it with our LLM + # wrapper, but the host channel is text-in / text-out and cannot promise + # JSON. When the plan text does not parse, crewai's converter dead-ends + # (utilities/converter.py:67 passes agent=None into a fallback that + # requires one) and planning_handler.py:78 then raises + # "Failed to get the Planning output" -- so a crew that would otherwise + # have run fine dies during planning. It also costs an extra LLM + # round-trip per kickoff. Operators whose model reliably emits JSON can + # turn it back on. + planning = bool(getattr(ig, 'planning', False)) crew = Crew( agents=sub_agents, tasks=sub_tasks, process=Process.hierarchical, manager_agent=manager_agent, - planning=True, - planning_llm=manager_llm, + planning=planning, + planning_llm=manager_llm if planning else None, verbose=False, ) diff --git a/nodes/src/nodes/agent_crewai/requirements.txt b/nodes/src/nodes/agent_crewai/requirements.txt index 05f7921d7..19d636210 100644 --- a/nodes/src/nodes/agent_crewai/requirements.txt +++ b/nodes/src/nodes/agent_crewai/requirements.txt @@ -1 +1,4 @@ -crewai>=1.14.1 +# Upper-bounded: crewai moves the BaseLLM contract in minor releases. 1.15.x ships +# a BaseLLM without supports_function_calling() while several call sites still +# invoke it unguarded, which broke HostInvokeLLM (see crewai_base._build_crew_llm). +crewai>=1.14.1,<2 diff --git a/nodes/src/nodes/agent_crewai/services.manager.json b/nodes/src/nodes/agent_crewai/services.manager.json index 046818a40..9fe8f1a61 100644 --- a/nodes/src/nodes/agent_crewai/services.manager.json +++ b/nodes/src/nodes/agent_crewai/services.manager.json @@ -30,7 +30,8 @@ "instructions": [], "advanced_mode": false, "goal": "", - "backstory": "" + "backstory": "", + "planning": false } } }, @@ -66,7 +67,7 @@ "conditional": [ { "value": true, - "properties": ["goal", "backstory"] + "properties": ["goal", "backstory", "planning"] } ] }, @@ -81,6 +82,16 @@ "format": "textarea", "title": "Manager Backstory", "description": "Background context for the manager's persona. Maps to CrewAI Agent(backstory=...)." + }, + "planning": { + "type": "boolean", + "title": "Crew Planning", + "description": "Run CrewAI's planner before the crew executes, adding a step-by-step plan to each task. Off by default: the planner requires the LLM to return JSON matching CrewAI's plan schema, and a model that answers in prose instead fails the whole run with Failed to get the Planning output. It also costs one extra LLM call per kickoff. Enable only with a model that reliably emits well-formed JSON.", + "default": false, + "enum": [ + [false, "Off"], + [true, "On"] + ] } }, "shape": [ diff --git a/nodes/test/agent_crewai/test_llm_contract.py b/nodes/test/agent_crewai/test_llm_contract.py new file mode 100644 index 000000000..87c7eff9a --- /dev/null +++ b/nodes/test/agent_crewai/test_llm_contract.py @@ -0,0 +1,222 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Network-free unit tests for the CrewAI LLM adapter contract. + +Bug: hierarchical crews failed with + + Failed to convert text into a Pydantic model due to error: + 'HostInvokeLLM' object has no attribute 'supports_function_calling' + +Root cause: CrewAI defines ``supports_function_calling()`` on ``crewai.llm.LLM`` +and on each provider completion class, but **not** on ``BaseLLM``. Most callers +probe it with ``hasattr`` first; several do not — ``crewai/utilities/converter.py`` +(``to_pydantic`` / ``ato_pydantic`` / ``to_json``) and ``crewai/tools/tool_usage.py`` +(``_function_calling``) call it straight. Our ``HostInvokeLLM`` subclasses +``BaseLLM`` and implemented only ``call`` and ``acall``, so those paths raised +``AttributeError``, which ``converter.py`` swallows in a broad ``except Exception`` +and re-reports as the misleading message above. + +``Crew(planning=True)`` — hardcoded by the hierarchical manager — runs a planner +task with ``output_pydantic`` set, so the manager reached that path on every +kickoff whose plan text was not already clean JSON. + +``crewai_base`` cannot be imported in a plain interpreter (it pulls ``rocketlib``, +``ai.common`` and ``crewai``, the last needing pywin32), so these tests stub those +three seams and exercise the REAL ``CrewBase._build_crew_llm``. The stubs are +installed and torn down around the import so a full ``builder nodes:test`` run, +where the real modules are present and session-shared, is unaffected. +""" + +from __future__ import annotations + +import asyncio +import importlib +import sys +import types +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import pytest + +# Point at nodes/src/nodes, not nodes/src: importing through the `nodes` package +# would execute nodes/src/nodes/__init__.py, which pulls the engine-only `depends`. +_NODES_DIR = Path(__file__).resolve().parents[2] / 'src' / 'nodes' +if str(_NODES_DIR) not in sys.path: + sys.path.insert(0, str(_NODES_DIR)) + +_STUB_MODULE_NAMES = ( + 'rocketlib', + 'crewai', + 'ai', + 'ai.common', + 'ai.common.agent', + 'ai.common.utils', +) + + +class _StubBaseLLM: + """Stand-in for ``crewai.BaseLLM``. + + Mirrors the two things ``HostInvokeLLM`` relies on: an ``__init__`` taking + ``model``/``temperature``, and a ``stop_sequences`` property. It deliberately + does NOT define ``supports_function_calling`` — that absence is the bug under + test, and defining it here would make these tests pass vacuously. + """ + + def __init__(self, model: str = '', temperature=None, **kwargs): + self.model = model + self.temperature = temperature + self.stop = [] + + @property + def stop_sequences(self): + return self.stop + + +def _build_stubs() -> dict: + mod_rocketlib = types.ModuleType('rocketlib') + mod_rocketlib.ToolDescriptor = dict + + # No `crewai.crew` / `crewai.tools` submodules: the import-time compatibility + # patches in crewai_base wrap their imports in try/except and no-op without them. + mod_crewai = types.ModuleType('crewai') + mod_crewai.BaseLLM = _StubBaseLLM + + mod_ai = types.ModuleType('ai') + mod_ai_common = types.ModuleType('ai.common') + + mod_agent = types.ModuleType('ai.common.agent') + + class AgentBase: + pass + + class AgentContext: + pass + + mod_agent.AgentBase = AgentBase + mod_agent.AgentContext = AgentContext + + mod_utils = types.ModuleType('ai.common.utils') + mod_utils.safe_str = str + + return { + 'rocketlib': mod_rocketlib, + 'crewai': mod_crewai, + 'ai': mod_ai, + 'ai.common': mod_ai_common, + 'ai.common.agent': mod_agent, + 'ai.common.utils': mod_utils, + } + + +@contextmanager +def _scoped_stubs() -> Iterator[None]: + original = {name: sys.modules.get(name) for name in _STUB_MODULE_NAMES} + sys.modules.update(_build_stubs()) + try: + yield + finally: + for name, module in original.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +with _scoped_stubs(): + crewai_base = importlib.import_module('agent_crewai.crewai_base') + + +@pytest.fixture(autouse=True) +def _stubbed_crewai(): + """Keep the stubs live during the tests too. + + ``_build_crew_llm`` does ``from crewai import BaseLLM`` at call time, not at + import time, so the stub has to be installed while each test runs. Scoping it + per test restores the real modules afterwards, which matters under a full + ``builder nodes:test`` run where they are present and session-shared. + """ + with _scoped_stubs(): + yield + + +class _Recorder(crewai_base.CrewBase): + """Minimal CrewBase whose call_llm records what the adapter forwarded.""" + + def __init__(self): + self.calls = [] + + def call_llm(self, context, messages, role=None, stop_words=None): + self.calls.append({'context': context, 'messages': messages, 'role': role, 'stop_words': stop_words}) + return 'host reply' + + +def _llm(): + return _Recorder()._build_crew_llm(context=object(), role='Rocket Ralph') + + +class TestFunctionCallingSupport: + def test_method_exists_and_is_callable(self): + llm = _llm() + assert callable(getattr(llm, 'supports_function_calling', None)) + + def test_reports_no_native_function_calling(self): + """`call` ignores tools/response_model and returns a string, so False is the truth.""" + assert _llm().supports_function_calling() is False + + def test_base_class_does_not_provide_it(self): + """Guards against 'BaseLLM covers this now' — the override must stay explicit.""" + assert not hasattr(_StubBaseLLM, 'supports_function_calling') + + def test_converter_probe_does_not_raise(self): + """Reproduces crewai/utilities/converter.py:97, which calls this unguarded.""" + llm = _llm() + if llm.supports_function_calling(): + response = llm.call(messages=[{'role': 'user', 'content': 'hi'}], response_model=object) + else: + response = llm.call([{'role': 'user', 'content': 'hi'}]) + assert response == 'host reply' + + def test_tool_usage_probe_does_not_raise(self): + """Reproduces crewai/tools/tool_usage.py:814, the tool-call repair path.""" + llm = _llm() + assert llm.supports_function_calling() in (True, False) + + +class TestUnguardedInterface: + """CrewAI calls these on the agent's LLM without a hasattr guard.""" + + @pytest.mark.parametrize('name', ['call', 'acall', 'supports_function_calling']) + def test_method_is_present(self, name): + assert callable(getattr(_llm(), name, None)) + + def test_inherited_capability_methods_are_reachable(self): + """These come from BaseLLM; assert we did not shadow them into nothing.""" + llm = _llm() + for name in ('stop_sequences',): + assert hasattr(llm, name) + + +class TestCallForwarding: + def test_call_forwards_role_and_stop_sequences(self): + recorder = _Recorder() + llm = recorder._build_crew_llm(context='ctx', role='Rocket Ralph') + llm.stop = ['\nObservation:'] + + assert llm.call('question') == 'host reply' + assert recorder.calls == [ + {'context': 'ctx', 'messages': 'question', 'role': 'Rocket Ralph', 'stop_words': ['\nObservation:']} + ] + + def test_acall_bridges_to_the_sync_channel(self): + recorder = _Recorder() + llm = recorder._build_crew_llm(context='ctx', role='Rocket Ralph') + + assert asyncio.run(llm.acall('question')) == 'host reply' + assert recorder.calls[0]['messages'] == 'question' diff --git a/nodes/test/agent_crewai/test_manager_planning.py b/nodes/test/agent_crewai/test_manager_planning.py new file mode 100644 index 000000000..9bf6c6710 --- /dev/null +++ b/nodes/test/agent_crewai/test_manager_planning.py @@ -0,0 +1,206 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Network-free unit tests for the CrewAI Manager's opt-in planning flag. + +The manager used to hardcode ``Crew(planning=True)``. CrewAI's planner runs a +task with ``output_pydantic=PlannerTaskPydanticOutput`` and drives it with our +``HostInvokeLLM``, but the host channel is text-in / text-out and cannot promise +JSON. When the plan text did not parse, crewai's converter dead-ended -- +``utilities/converter.py:67`` passes ``agent=None`` into ``handle_partial_json``, +whose fallback ``convert_with_instructions`` raises +``TypeError: Agent must be provided if converter_cls is not specified`` -- and +even with that repaired ``planning_handler.py:78`` raises +``Failed to get the Planning output``. A crew that would otherwise have run +fine died during planning, on every kickoff. + +Planning is therefore opt-in and off by default. These tests pin the contract at +the two seams that are checkable without the engine: the manifest the config +panel renders from, and ``IGlobal``'s parsing of the field. +""" + +from __future__ import annotations + +import json +import sys +import types +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import pytest + +_NODE_DIR = Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'agent_crewai' +_MANIFEST = json.loads((_NODE_DIR / 'services.manager.json').read_text(encoding='utf-8')) + + +class TestManifest: + """The field the operator actually sees in the config panel.""" + + def test_planning_field_exists(self): + assert 'planning' in _MANIFEST['fields'] + + def test_planning_defaults_to_off(self): + assert _MANIFEST['fields']['planning']['default'] is False + assert _MANIFEST['preconfig']['profiles']['default']['planning'] is False + + def test_planning_is_a_boolean_toggle(self): + field = _MANIFEST['fields']['planning'] + assert field['type'] == 'boolean' + assert field['enum'] == [[False, 'Off'], [True, 'On']] + + def test_planning_is_gated_behind_advanced_mode(self): + conditional = _MANIFEST['fields']['advanced_mode']['conditional'] + advanced = next(c for c in conditional if c['value'] is True) + assert 'planning' in advanced['properties'] + + def test_description_warns_about_the_json_requirement(self): + description = _MANIFEST['fields']['planning']['description'] + assert 'JSON' in description + + +# --------------------------------------------------------------------------- +# IGlobal wiring — stub the engine-only seams so the real module can be imported +# --------------------------------------------------------------------------- + +_STUB_MODULE_NAMES = ( + 'depends', + 'rocketlib', + 'ai', + 'ai.common', + 'ai.common.config', + 'agent_crewai', + 'agent_crewai.crewai_runner', + 'agent_crewai.crewai_manager', + 'agent_crewai.crewai_manager.manager', +) + + +class _StubConfig: + """Stands in for ai.common.config.Config; returns whatever a test queued.""" + + node_config: dict = {} + + @staticmethod + def getNodeConfig(logical_type, conn_config): # noqa: ARG004 — signature parity + return _StubConfig.node_config + + +def _build_stubs() -> dict: + mod_depends = types.ModuleType('depends') + mod_depends.depends = lambda *args, **kwargs: None + + mod_rocketlib = types.ModuleType('rocketlib') + + class IGlobalBase: + pass + + class _OpenMode: + CONFIG = 'CONFIG' + SOURCE = 'SOURCE' + + mod_rocketlib.IGlobalBase = IGlobalBase + mod_rocketlib.OPEN_MODE = _OpenMode + + mod_ai = types.ModuleType('ai') + mod_ai_common = types.ModuleType('ai.common') + mod_config = types.ModuleType('ai.common.config') + mod_config.Config = _StubConfig + + # Synthetic package parents so IGlobal's relative imports resolve without + # dragging in crewai_runner / manager / crewai_base, none of which this test + # exercises and all of which need the engine runtime. + pkg = types.ModuleType('agent_crewai') + pkg.__path__ = [] + sub_pkg = types.ModuleType('agent_crewai.crewai_manager') + sub_pkg.__path__ = [] + + mod_runner = types.ModuleType('agent_crewai.crewai_runner') + mod_runner.get_shared_runner = lambda: object() + + mod_manager = types.ModuleType('agent_crewai.crewai_manager.manager') + + class CrewManager: + def __init__(self, iGlobal): + self.iGlobal = iGlobal + + mod_manager.CrewManager = CrewManager + + return { + 'depends': mod_depends, + 'rocketlib': mod_rocketlib, + 'ai': mod_ai, + 'ai.common': mod_ai_common, + 'ai.common.config': mod_config, + 'agent_crewai': pkg, + 'agent_crewai.crewai_runner': mod_runner, + 'agent_crewai.crewai_manager': sub_pkg, + 'agent_crewai.crewai_manager.manager': mod_manager, + } + + +@contextmanager +def _scoped_stubs() -> Iterator[None]: + original = {name: sys.modules.get(name) for name in _STUB_MODULE_NAMES} + sys.modules.update(_build_stubs()) + try: + yield + finally: + for name, module in original.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +def _load_iglobal(): + """Load the real IGlobal module under its package name so relative imports work.""" + import importlib.util + + name = 'agent_crewai.crewai_manager.IGlobal' + spec = importlib.util.spec_from_file_location(name, _NODE_DIR / 'crewai_manager' / 'IGlobal.py') + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(name, None) + return module + + +def _begin(config: dict): + """Run beginGlobal against a stubbed engine and return the populated IGlobal.""" + with _scoped_stubs(): + iglobal_mod = _load_iglobal() + _StubConfig.node_config = config + + instance = iglobal_mod.IGlobal() + endpoint = types.SimpleNamespace(endpoint=types.SimpleNamespace(openMode='SOURCE')) + instance.IEndpoint = endpoint + instance.glb = types.SimpleNamespace(logicalType='agent_crewai_manager', connConfig={}) + + instance.beginGlobal() + return instance + + +class TestIGlobalWiring: + def test_planning_defaults_to_false_when_absent(self): + assert _begin({'goal': '', 'backstory': ''}).planning is False + + @pytest.mark.parametrize('value', [True, 1, 'yes']) + def test_truthy_config_enables_planning(self, value): + assert _begin({'planning': value}).planning is True + + @pytest.mark.parametrize('value', [False, 0, '', None]) + def test_falsy_config_keeps_planning_off(self, value): + assert _begin({'planning': value}).planning is False + + def test_endglobal_resets_planning(self): + instance = _begin({'planning': True}) + assert instance.planning is True + instance.endGlobal() + assert instance.planning is False diff --git a/nodes/test/agent_crewai/test_manager_tool_scoping.py b/nodes/test/agent_crewai/test_manager_tool_scoping.py new file mode 100644 index 000000000..69667aa80 --- /dev/null +++ b/nodes/test/agent_crewai/test_manager_tool_scoping.py @@ -0,0 +1,171 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Network-free unit tests for the hierarchical manager's tool isolation. + +The CRM Manager prompt declares a strict tool split -- Deal Desk owns deals, +Accounts & Identity owns search, Activity & Comms owns notes -- and states the +manager itself holds no CRM tools. A pipeline trace showed the opposite: the +manager's own system prompt listed every ``tool_pipedrive_*`` tool and it called +Pipedrive directly rather than delegating. + +That is not a wiring mistake. It is CrewAI, in three steps: + +1. ``crewai/task.py`` ``check_tools`` is an ``@model_validator(mode='after')`` + that copies ``self.agent.tools`` into ``self.tools`` whenever ``self.tools`` + is falsy. Every Task we build with ``agent=`` therefore acquires + that delegate's toolset, even though we never pass ``tools=``. +2. ``crewai/crews/utils.py`` resolves the toolset for a task as + ``task.tools or agent_to_use.tools or []``. +3. ``crewai/crew.py`` ``Crew._get_agent_to_use`` returns ``self.manager_agent`` + for ``Process.hierarchical``. + +Composed: the manager executes each task carrying the *delegate's* tools. + +The fix in ``crewai_manager/manager.py`` clears ``task_obj.tools`` after the Task +is constructed. It cannot be done by passing ``tools=[]``, because ``check_tools`` +runs after ``__init__`` and reads the empty list as "unset". + +SCOPE: these tests do not import the real crewai. The built bundle at +``dist/server/lib/site-packages`` pulls ``pywin32``, which is absent from the +test environment, so an end-to-end assertion is not available here. What follows +pins (a) the resolution semantics above, against a double that mirrors +``check_tools`` exactly, and (b) that the manager source still performs the +clearing. Line references are given so a CrewAI upgrade can be re-verified by +hand against the vendored source. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +_MANAGER_SRC = ( + Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'agent_crewai' / 'crewai_manager' / 'manager.py' +).read_text(encoding='utf-8') + + +# --------------------------------------------------------------------------- +# A faithful double of the CrewAI seams described above +# --------------------------------------------------------------------------- + + +class _Agent: + def __init__(self, role: str, tools: list[str] | None = None): + self.role = role + self.tools = list(tools or []) + + +class _Task: + """Mirrors crewai.Task's tool back-fill. + + ``check_tools`` is an after-validator, so it runs on construction only -- + crewai.Task declares ``model_config = {'arbitrary_types_allowed': True}`` with + no ``validate_assignment``, which is why a later assignment is not revalidated. + """ + + def __init__(self, description: str, agent: _Agent, tools: list[str] | None = None): + self.description = description + self.agent = agent + self.tools = list(tools or []) + # crewai/task.py check_tools + if not self.tools and self.agent and self.agent.tools: + self.tools = self.agent.tools + + +def _resolve_executor_tools(task: _Task, manager: _Agent) -> list[str]: + """crewai/crews/utils.py, for a hierarchical crew. + + ``_get_agent_to_use`` returns the manager, so ``agent_to_use`` below is the + manager and never the delegate. + """ + agent_to_use = manager + return task.tools or agent_to_use.tools or [] + + +# --------------------------------------------------------------------------- +# The mechanism +# --------------------------------------------------------------------------- + + +class TestCrewAIToolBackfill: + """Why the bug happens, and why the obvious fix does not work.""" + + def test_task_inherits_its_agents_tools(self): + delegate = _Agent('Deal Desk', ['deal_search', 'deal_create']) + task = _Task('work the deal', agent=delegate) + assert task.tools == ['deal_search', 'deal_create'] + + def test_constructor_tools_empty_list_does_not_survive(self): + """The reason the fix is a post-construction assignment, not a kwarg.""" + delegate = _Agent('Deal Desk', ['deal_search']) + task = _Task('work the deal', agent=delegate, tools=[]) + assert task.tools == ['deal_search'], 'an empty list reads as "unset" to check_tools' + + def test_manager_inherits_the_delegates_tools_when_task_is_untouched(self): + """The observed failure: the manager can work the tools instead of delegating.""" + delegate = _Agent('Deal Desk', ['deal_search']) + manager = _Agent('Manager', []) + task = _Task('work the deal', agent=delegate) + assert _resolve_executor_tools(task, manager) == ['deal_search'] + + def test_clearing_task_tools_isolates_the_manager(self): + delegate = _Agent('Deal Desk', ['deal_search']) + manager = _Agent('Manager', []) + task = _Task('work the deal', agent=delegate) + + task.tools = [] # what manager.py does + + assert _resolve_executor_tools(task, manager) == [] + + def test_clearing_the_task_leaves_the_delegate_armed(self): + """Delegation must still work: agent_tools builds a fresh Task per handoff.""" + delegate = _Agent('Deal Desk', ['deal_search']) + task = _Task('work the deal', agent=delegate) + task.tools = [] + + assert delegate.tools == ['deal_search'] + # crewai/tools/agent_tools/base_agent_tools.py constructs a new Task bound + # to the coworker, which back-fills from that agent's own tools. + handoff = _Task('sub-request', agent=delegate) + assert handoff.tools == ['deal_search'] + + +# --------------------------------------------------------------------------- +# Our side of the contract +# --------------------------------------------------------------------------- + + +class TestManagerSourceClearsTaskTools: + """A source guard: the clearing is a one-liner that is easy to drop in a refactor. + + The construction loop lives inline in ``CrewManager.run_agent`` and needs the + engine's invoke seams to execute, so this asserts on the source rather than on + behaviour. If that loop is ever extracted into a helper, replace this with a + real call. + """ + + def test_task_tools_are_cleared(self): + assert 'task_obj.tools = []' in _MANAGER_SRC + + def test_the_reason_is_recorded_next_to_it(self): + """Without the why, the next reader deletes it as dead code.""" + assert 'check_tools' in _MANAGER_SRC + assert '_get_agent_to_use' in _MANAGER_SRC + + def test_manager_agent_is_still_built_without_tools(self): + """Belt and braces -- the manager must not be handed a toolset directly either.""" + start = _MANAGER_SRC.index('manager_agent = Agent(') + block = _MANAGER_SRC[start : _MANAGER_SRC.index(')', _MANAGER_SRC.index('max_iter', start))] + assert 'tools=' not in block + + +@pytest.mark.parametrize('needle', ['crewai/task.py', 'crews/utils.py', 'agent_tools']) +def test_manager_cites_the_crewai_seams_it_depends_on(needle): + """These are private CrewAI internals; an upgrade must be re-verified against them.""" + assert needle in _MANAGER_SRC diff --git a/nodes/test/agent_crewai/test_stop_words.py b/nodes/test/agent_crewai/test_stop_words.py index 9af1e5c80..68ee77feb 100644 --- a/nodes/test/agent_crewai/test_stop_words.py +++ b/nodes/test/agent_crewai/test_stop_words.py @@ -46,34 +46,37 @@ from pathlib import Path # --------------------------------------------------------------------------- -# Load the REAL truncate_at_stop_words from packages/ai, stubbing only its one -# dependency (ai.common.utils.safe_str) so no engine modules are required. +# Load the REAL truncate_at_stop_words from packages/ai, stubbing only the +# ai.common.utils names it imports so no engine modules are required. +# +# `safe_str` is trivial enough to stub inline. `flatten_content_blocks` is not — +# it owns the provider block vocabulary, and a hand-copied stub here would drift +# from the real one. It has no imports beyond `typing`, so load it from source by +# path instead and keep exactly one implementation. # --------------------------------------------------------------------------- -_UTILS_PATH = ( - Path(__file__).resolve().parents[3] - / 'packages' - / 'ai' - / 'src' - / 'ai' - / 'common' - / 'agent' - / '_internal' - / 'utils.py' -) +_AI_SRC = Path(__file__).resolve().parents[3] / 'packages' / 'ai' / 'src' / 'ai' / 'common' +_UTILS_PATH = _AI_SRC / 'agent' / '_internal' / 'utils.py' +_CONTENT_BLOCKS_PATH = _AI_SRC / 'utils' / 'content_blocks.py' + + +def _load_by_path(module_name: str, path: Path): + """Import a dependency-free module from source, bypassing the package.""" + spec = importlib.util.spec_from_file_location(module_name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod def _load_real_truncate(): saved = {k: sys.modules.get(k) for k in ('ai', 'ai.common', 'ai.common.utils')} acu = types.ModuleType('ai.common.utils') acu.safe_str = lambda x: '' if x is None else str(x) + acu.flatten_content_blocks = _load_by_path('rr_real_content_blocks', _CONTENT_BLOCKS_PATH).flatten_content_blocks sys.modules['ai'] = types.ModuleType('ai') sys.modules['ai.common'] = types.ModuleType('ai.common') sys.modules['ai.common.utils'] = acu try: - spec = importlib.util.spec_from_file_location('rr_real_agent_utils', _UTILS_PATH) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod.truncate_at_stop_words + return _load_by_path('rr_real_agent_utils', _UTILS_PATH).truncate_at_stop_words finally: for k, v in saved.items(): if v is None: diff --git a/nodes/test/agent_crewai/test_tool_schema.py b/nodes/test/agent_crewai/test_tool_schema.py new file mode 100644 index 000000000..96c2745a9 --- /dev/null +++ b/nodes/test/agent_crewai/test_tool_schema.py @@ -0,0 +1,370 @@ +# ============================================================================= +# RocketRide Engine +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +"""Network-free unit tests for the CrewAI tool bridge's generated args_schema. + +Bug: every tool call in a crew failed with + + Arguments validation failed: Field required [type=missing] + +even for tools whose arguments the model had clearly supplied, and including +CrewAI's own delegation tool. + +Root cause: ``crewai/tools/tool_usage.py`` intersects the model's emitted +argument keys with ``args_schema.model_json_schema()["properties"].keys()`` and +**silently drops** everything that does not match:: + + acceptable_args = tool.args_schema.model_json_schema()['properties'].keys() + arguments = {k: v for k, v in calling.arguments.items() if k in acceptable_args} + +When nothing matches, ``arguments`` becomes ``{}`` and +``structured_tool.py`` validates that empty dict, reporting every required field +as missing with ``input_value={}``. + +Our contribution was typing every field as ``Any``. CrewAI renders the generated +schema into the ReAct prompt as the ``Tool Arguments:`` block, and an ``Any`` +field carries no ``"type"`` at all — so the model was shown argument names with +no types, while a second, differently-shaped copy of the schema was appended to +the description. These tests pin the generated schema: real types, no duplicated +schema text, and the exact empty-dict regression. +""" + +from __future__ import annotations + +import importlib +import sys +import types +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +import pytest + +_NODES_DIR = Path(__file__).resolve().parents[2] / 'src' / 'nodes' +if str(_NODES_DIR) not in sys.path: + sys.path.insert(0, str(_NODES_DIR)) + +_STUB_MODULE_NAMES = ('rocketlib', 'crewai', 'crewai.tools', 'ai', 'ai.common', 'ai.common.agent', 'ai.common.utils') + + +def _build_stubs() -> dict: + from pydantic import BaseModel as PydanticBaseModel + + mod_rocketlib = types.ModuleType('rocketlib') + mod_rocketlib.ToolDescriptor = dict + + mod_crewai = types.ModuleType('crewai') + + class BaseLLM: + def __init__(self, model: str = '', temperature=None, **kwargs): + self.model = model + + mod_crewai.BaseLLM = BaseLLM + + mod_crewai_tools = types.ModuleType('crewai.tools') + + class BaseTool(PydanticBaseModel): + """Minimal stand-in: real BaseTool is a pydantic model with these fields.""" + + name: str = '' + description: str = '' + args_schema: Any = None + + mod_crewai_tools.BaseTool = BaseTool + mod_crewai.tools = mod_crewai_tools + + mod_ai = types.ModuleType('ai') + mod_ai_common = types.ModuleType('ai.common') + mod_agent = types.ModuleType('ai.common.agent') + + class AgentBase: + pass + + class AgentContext: + pass + + mod_agent.AgentBase = AgentBase + mod_agent.AgentContext = AgentContext + + mod_utils = types.ModuleType('ai.common.utils') + mod_utils.safe_str = str + + return { + 'rocketlib': mod_rocketlib, + 'crewai': mod_crewai, + 'crewai.tools': mod_crewai_tools, + 'ai': mod_ai, + 'ai.common': mod_ai_common, + 'ai.common.agent': mod_agent, + 'ai.common.utils': mod_utils, + } + + +@contextmanager +def _scoped_stubs() -> Iterator[None]: + original = {name: sys.modules.get(name) for name in _STUB_MODULE_NAMES} + sys.modules.update(_build_stubs()) + try: + yield + finally: + for name, module in original.items(): + if module is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = module + + +with _scoped_stubs(): + crewai_base = importlib.import_module('agent_crewai.crewai_base') + + +@pytest.fixture(autouse=True) +def _stubbed_crewai(): + """`_build_crew_tools` imports crewai and pydantic at call time, not import time.""" + with _scoped_stubs(): + yield + + +class _Recorder(crewai_base.CrewBase): + def __init__(self): + self.calls = [] + + def call_tool(self, context, name, args): + self.calls.append({'context': context, 'name': name, 'args': args}) + return {'ok': True} + + +def _tools(*descriptors): + return _Recorder()._build_crew_tools(context=object(), tool_descriptors=list(descriptors)) + + +def _schema_of(input_schema): + descriptor = {'name': 'demo', 'description': 'Demo tool.', 'inputSchema': input_schema} + return _tools(descriptor)[0].args_schema + + +# --------------------------------------------------------------------------- +# Types reach the prompt +# --------------------------------------------------------------------------- + + +class TestGeneratedTypes: + @pytest.mark.parametrize( + 'json_type,expected', + [ + ('string', 'string'), + ('integer', 'integer'), + ('number', 'number'), + ('boolean', 'boolean'), + ('array', 'array'), + ('object', 'object'), + ], + ) + def test_required_field_carries_its_type(self, json_type, expected): + schema = _schema_of({'type': 'object', 'required': ['value'], 'properties': {'value': {'type': json_type}}}) + rendered = schema.model_json_schema()['properties']['value'] + assert rendered.get('type') == expected + + def test_every_property_is_typed(self): + """The regression: an all-Any model renders properties with no "type" key.""" + schema = _schema_of( + { + 'type': 'object', + 'required': ['deal_id'], + 'properties': { + 'deal_id': {'type': 'integer', 'description': 'Deal id.'}, + 'term': {'type': 'string'}, + }, + } + ) + for name, prop in schema.model_json_schema()['properties'].items(): + assert _is_typed(prop), f'{name} rendered without a type: {prop}' + + def test_unknown_type_falls_back_to_any(self): + schema = _schema_of({'type': 'object', 'required': ['weird'], 'properties': {'weird': {'type': 'nonsense'}}}) + assert schema.model_validate({'weird': {'anything': 1}}).weird == {'anything': 1} + + def test_missing_type_falls_back_to_any(self): + schema = _schema_of({'type': 'object', 'required': ['x'], 'properties': {'x': {}}}) + assert schema.model_validate({'x': 'anything'}).x == 'anything' + + +def _is_typed(prop: dict) -> bool: + """Whether a rendered property advertises a type to the model. + + Required fields carry `type` directly; optional ones render as + `anyOf: [{type: X}, {type: null}]`, which is equally informative. + """ + if 'type' in prop: + return True + return any('type' in variant for variant in prop.get('anyOf', [])) + + +# --------------------------------------------------------------------------- +# Validation behaviour +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_required_stays_required(self): + schema = _schema_of({'type': 'object', 'required': ['deal_id'], 'properties': {'deal_id': {'type': 'integer'}}}) + assert schema.model_json_schema()['required'] == ['deal_id'] + + def test_the_reported_regression(self): + """Validating {} against a schema with required fields is what produced the error.""" + schema = _schema_of({'type': 'object', 'required': ['deal_id'], 'properties': {'deal_id': {'type': 'integer'}}}) + with pytest.raises(Exception, match='deal_id'): + schema.model_validate({}) + assert schema.model_validate({'deal_id': 7}).deal_id == 7 + + def test_string_digits_coerce_to_integer(self): + """Any blocked this: a model emitting "7" could not be repaired.""" + schema = _schema_of({'type': 'object', 'required': ['deal_id'], 'properties': {'deal_id': {'type': 'integer'}}}) + assert schema.model_validate({'deal_id': '7'}).deal_id == 7 + + def test_optional_field_may_be_omitted_or_null(self): + schema = _schema_of( + { + 'type': 'object', + 'required': ['term'], + 'properties': {'term': {'type': 'string'}, 'limit': {'type': 'integer'}}, + } + ) + assert schema.model_validate({'term': 'acme'}).limit is None + assert schema.model_validate({'term': 'acme', 'limit': None}).limit is None + assert schema.model_validate({'term': 'acme', 'limit': 5}).limit == 5 + + def test_declared_default_is_preserved(self): + schema = _schema_of({'type': 'object', 'properties': {'status': {'type': 'string', 'default': 'open'}}}) + assert schema.model_validate({}).status == 'open' + + def test_unknown_keys_are_ignored_not_rejected(self): + schema = _schema_of({'type': 'object', 'required': ['term'], 'properties': {'term': {'type': 'string'}}}) + assert schema.model_validate({'term': 'acme', 'stray': 1}).term == 'acme' + + +# --------------------------------------------------------------------------- +# The argument filter CrewAI applies before validating +# --------------------------------------------------------------------------- + + +class TestArgumentFilterSurvival: + """crewai/tools/tool_usage.py keeps only keys the schema declares.""" + + def test_real_parameter_names_survive_the_filter(self): + schema = _schema_of( + { + 'type': 'object', + 'required': ['deal_id'], + 'properties': {'deal_id': {'type': 'integer'}, 'limit': {'type': 'integer'}}, + } + ) + acceptable = schema.model_json_schema()['properties'].keys() + emitted = {'deal_id': 7, 'limit': 5} + assert {k: v for k, v in emitted.items() if k in acceptable} == emitted + + def test_zero_arg_tool_advertises_no_parameters(self): + """The old fallback declared a bogus required `input` field for these.""" + for empty in ({'type': 'object', 'properties': {}}, {}, None, 'not a schema'): + schema = _schema_of(empty) + assert schema.model_json_schema().get('properties', {}) == {} + + +# --------------------------------------------------------------------------- +# Malformed descriptors must not take down the whole tool list +# --------------------------------------------------------------------------- + + +class TestMalformedSchemas: + @pytest.mark.parametrize( + 'input_schema', + [ + {'type': 'object', 'properties': 'not a dict'}, + {'type': 'object', 'properties': {'x': 'not a dict'}, 'required': ['x']}, + {'type': 'object', 'properties': {'x': {'type': 'string'}}, 'required': None}, + {'type': 'object', 'properties': {'': {'type': 'string'}}}, + ], + ) + def test_does_not_raise(self, input_schema): + assert _schema_of(input_schema) is not None + + def test_a_bad_descriptor_does_not_drop_the_good_ones(self): + tools = _tools( + { + 'name': 'good', + 'description': 'ok', + 'inputSchema': {'type': 'object', 'properties': {'a': {'type': 'string'}}}, + }, + {'name': 'bad', 'description': 'broken', 'inputSchema': {'properties': 'nope'}}, + ) + assert [t.name for t in tools] == ['good', 'bad'] + + +# --------------------------------------------------------------------------- +# Description +# --------------------------------------------------------------------------- + + +class TestDescription: + def test_schema_is_not_duplicated_into_the_description(self): + tool = _tools( + { + 'name': 'deal_get', + 'description': 'Get a single deal by id.', + 'inputSchema': { + 'type': 'object', + 'required': ['deal_id'], + 'properties': {'deal_id': {'type': 'integer'}}, + }, + } + )[0] + assert 'Tool input schema (JSON)' not in tool.description + assert tool.description == 'Get a single deal by id.' + + def test_missing_description_gets_a_fallback(self): + tool = _tools({'name': 'thing', 'inputSchema': {}})[0] + assert tool.name in tool.description + + def test_repr_is_a_one_liner(self): + tool = _tools({'name': 'deal_get', 'description': 'Line one.\nLine two.', 'inputSchema': {}})[0] + assert repr(tool) == "Tool(name='deal_get', description='Line one.')" + + +# --------------------------------------------------------------------------- +# Forwarding +# --------------------------------------------------------------------------- + + +class TestForwarding: + def test_run_forwards_validated_args_to_the_host(self): + recorder = _Recorder() + tool = recorder._build_crew_tools( + context='ctx', + tool_descriptors=[ + { + 'name': 'deal_get', + 'description': 'd', + 'inputSchema': {'type': 'object', 'properties': {'deal_id': {'type': 'integer'}}}, + } + ], + )[0] + + assert tool._run(deal_id=7) == '{"ok": true}' + assert recorder.calls == [{'context': 'ctx', 'name': 'deal_get', 'args': {'deal_id': 7}}] + + def test_host_errors_come_back_as_structured_output(self): + class _Boom(_Recorder): + def call_tool(self, context, name, args): + raise RuntimeError('host exploded') + + tool = _Boom()._build_crew_tools( + context='ctx', tool_descriptors=[{'name': 't', 'description': 'd', 'inputSchema': {}}] + )[0] + out = tool._run() + assert 'host exploded' in out + assert 'RuntimeError' in out From 0deb463ecbc7130884aef1b1be33204857672f04 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Tue, 28 Jul 2026 22:36:42 -0700 Subject: [PATCH 07/12] fix(nodes): move tool_pipedrive search to the v2 API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipedrive retired the v1 search routes: `/persons/search` and its siblings answer 404 "Unknown method ." and `/itemSearch` answers a plain 404, so every search tool the node published was dead. Non-search v1 endpoints still route normally, so only the search calls move — a blanket swap would break the many endpoints with no v2 equivalent. Adds a v2 base URL (company-domain aware, sharing v1's normalisation) and a v2 envelope wrapper: v2 replaced offset pagination with an opaque `next_cursor`, which `paginated` would have missed entirely and reported as a single page. Search tool schemas now advertise `cursor` instead of `start`, and `item_search_by_field` follows v2's renames — `field_type` -> `entity_type` with the "Field" suffix dropped from each value, and `exact_match` replaced by the three-way `match` mode. Also turns Tool groups into a multi-select dropdown with per-group tool counts, so the field no longer requires typing group names from memory. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/tool_pipedrive/IGlobal.py | 6 +- nodes/src/nodes/tool_pipedrive/README.md | 18 +- .../nodes/tool_pipedrive/pipedrive_client.py | 68 ++++++- nodes/src/nodes/tool_pipedrive/services.json | 33 ++- nodes/src/nodes/tool_pipedrive/tools/_base.py | 46 ++++- nodes/src/nodes/tool_pipedrive/tools/deals.py | 12 +- nodes/src/nodes/tool_pipedrive/tools/leads.py | 14 +- .../tool_pipedrive/tools/organizations.py | 12 +- .../src/nodes/tool_pipedrive/tools/persons.py | 12 +- .../nodes/tool_pipedrive/tools/products.py | 14 +- .../src/nodes/tool_pipedrive/tools/search.py | 53 ++--- nodes/test/tool_pipedrive/test_pipedrive.py | 192 +++++++++++++++++- nodes/test/tool_pipedrive/test_tools.py | 64 +++++- 13 files changed, 470 insertions(+), 74 deletions(-) diff --git a/nodes/src/nodes/tool_pipedrive/IGlobal.py b/nodes/src/nodes/tool_pipedrive/IGlobal.py index 0fc0611a4..0b4d92dee 100644 --- a/nodes/src/nodes/tool_pipedrive/IGlobal.py +++ b/nodes/src/nodes/tool_pipedrive/IGlobal.py @@ -35,7 +35,7 @@ from ai.common.config import Config from rocketlib import IGlobalBase, OPEN_MODE, warning -from .pipedrive_client import BASE_URL, base_url_for +from .pipedrive_client import BASE_URL, BASE_URL_V2, base_url_for, base_url_v2_for from .tool_groups import ( ALL_GROUPS, DEFAULT_GROUPS, @@ -52,6 +52,8 @@ class IGlobal(IGlobalBase): token: str = '' company_domain: str = '' base_url: str = BASE_URL + #: Search tools only — Pipedrive retired the v1 search routes. + base_url_v2: str = BASE_URL_V2 read_only: bool = False tool_groups: frozenset = DEFAULT_GROUPS allow_raw_request: bool = True @@ -64,6 +66,7 @@ def beginGlobal(self) -> None: self.token = str((cfg.get('apiToken') or '')).strip() self.company_domain = str((cfg.get('companyDomain') or '')).strip() self.base_url = base_url_for(self.company_domain) + self.base_url_v2 = base_url_v2_for(self.company_domain) self.read_only = bool(cfg.get('readOnly', False)) self.tool_groups = normalize_groups(cfg.get('toolGroups')) self.allow_raw_request = bool(cfg.get('allowRawRequest', True)) @@ -93,6 +96,7 @@ def endGlobal(self) -> None: self.token = '' self.company_domain = '' self.base_url = BASE_URL + self.base_url_v2 = BASE_URL_V2 self.read_only = False self.tool_groups = DEFAULT_GROUPS self.allow_raw_request = True diff --git a/nodes/src/nodes/tool_pipedrive/README.md b/nodes/src/nodes/tool_pipedrive/README.md index 3ccf9ce37..cd3876546 100644 --- a/nodes/src/nodes/tool_pipedrive/README.md +++ b/nodes/src/nodes/tool_pipedrive/README.md @@ -30,7 +30,7 @@ mutating tool. | `apiToken` | string | Default empty. Pipedrive API token (Settings -> Personal preferences -> API), or an OAuth access token. Stored encrypted. | | `companyDomain` | string | Default empty. The "acme" in `https://acme.pipedrive.com`. When set, requests go to `https://{domain}.pipedrive.com/api/v1`; otherwise `https://api.pipedrive.com/api/v1`. | | `readOnly` | boolean | Default false. When enabled, every create, update and delete tool is blocked and `request` only accepts GET. | -| `toolGroups` | array | Default `["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"]`. Which groups of tools to publish. Use `["all"]` for everything. | +| `toolGroups` | array | Default `["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"]`. Which groups of tools to publish, shown as a multi-select dropdown with per-group tool counts. Select **All groups** for everything. | | `allowRawRequest` | boolean | Default true. Publishes the generic `request` tool. | ### Tool groups @@ -40,12 +40,18 @@ and more than some providers accept in one request, so the node only publishes t groups listed in **Tool groups**. The default eight groups publish 108 tools — the everyday CRM surface. Add group names to reach further, or use `all`. -Available groups: +Available groups, with the number of tools each publishes: -`activities`, `call_logs`, `deals`, `fields`, `files`, `filters`, `goals`, `leads`, -`mailbox`, `misc`, `notes`, `org_relationships`, `organizations`, `permission_sets`, -`persons`, `pipelines`, `products`, `projects`, `roles`, `search`, `stages`, -`subscriptions`, `teams`, `users`, `webhooks`. +`deals` (28), `projects` (22), `organizations` (18), `persons` (18), `products` (16), +`activities` (13), `roles` (13), `leads` (12), `users` (12), `notes` (11), +`subscriptions` (9), `files` (8), `misc` (8), `pipelines` (8), `teams` (8), +`filters` (7), `stages` (7), `fields` (6), `mailbox` (6), `call_logs` (5), +`goals` (5), `org_relationships` (5), `search` (4), `permission_sets` (3), +`webhooks` (3). + +The config panel renders these as a multi-select dropdown. RJSF picks that widget for +an array carrying `uniqueItems: true` and an `items.enum`; **All groups** is the last +option. A tool in a group that is not published is invisible to the agent and refused if invoked anyway. diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py index ab0b0a9bb..2fcde0e61 100644 --- a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py +++ b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py @@ -45,6 +45,15 @@ from tenacity import RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential BASE_URL = 'https://api.pipedrive.com/api/v1' + +#: Pipedrive retired the v1 search routes: ``/persons/search`` and its siblings +#: answer 404 ``Unknown method .`` (the resource still exists, the action does +#: not) and ``/itemSearch`` answers a plain 404 ``Not Found`` (no such resource +#: any more). Non-search v1 endpoints are unaffected and still route normally, +#: so only the search tools read this base. See ``tools/search.py`` and the +#: ``*_search`` methods in the per-entity mixins. +BASE_URL_V2 = 'https://api.pipedrive.com/api/v2' + DEFAULT_TIMEOUT = 30 #: Pipedrive's offset pagination caps ``limit`` at 500. @@ -153,22 +162,44 @@ def _auth(token: str) -> tuple[dict, dict]: return {}, {'api_token': token} -def base_url_for(company_domain: str | None) -> str: - """Build the API base URL for an optional company domain. +def _company_host(company_domain: str | None) -> str | None: + """Reduce a configured company domain to its bare subdomain. Accepts ``acme``, ``acme.pipedrive.com`` or ``https://acme.pipedrive.com/`` - and normalises all three to ``https://acme.pipedrive.com/api/v1``. + and returns ``acme``. Returns ``None`` when nothing usable was configured, so + callers fall back to the generic host. + + Shared by both version-specific builders below: the v1 and v2 base URLs must + always agree about which company they address. """ domain = (company_domain or '').strip() if not domain: - return BASE_URL + return None domain = re.sub(r'^https?://', '', domain).strip('/') domain = domain.split('/')[0] if domain.endswith('.pipedrive.com'): domain = domain[: -len('.pipedrive.com')] - if not domain: - return BASE_URL - return f'https://{domain}.pipedrive.com/api/v1' + return domain or None + + +def base_url_for(company_domain: str | None) -> str: + """Build the v1 API base URL for an optional company domain. + + Accepts ``acme``, ``acme.pipedrive.com`` or ``https://acme.pipedrive.com/`` + and normalises all three to ``https://acme.pipedrive.com/api/v1``. + """ + host = _company_host(company_domain) + return f'https://{host}.pipedrive.com/api/v1' if host else BASE_URL + + +def base_url_v2_for(company_domain: str | None) -> str: + """Build the v2 API base URL for an optional company domain. + + Same normalisation as :func:`base_url_for`, emitting ``/api/v2``. Only the + search tools use this — see :data:`BASE_URL_V2`. + """ + host = _company_host(company_domain) + return f'https://{host}.pipedrive.com/api/v2' if host else BASE_URL_V2 # --------------------------------------------------------------------------- @@ -326,7 +357,7 @@ def call(token: str, method: str, path: str, **kwargs: Any) -> Any: def paginated(envelope: Any, items: list) -> dict: - """Wrap a cleaned list plus Pipedrive's pagination cursor for the agent.""" + """Wrap a cleaned list plus Pipedrive's v1 offset cursor for the agent.""" pagination = {} if isinstance(envelope, dict): pagination = ((envelope.get('additional_data') or {}).get('pagination')) or {} @@ -338,6 +369,27 @@ def paginated(envelope: Any, items: list) -> dict: } +def paginated_v2(envelope: Any, items: list) -> dict: + """Wrap a cleaned list plus Pipedrive's v2 opaque cursor for the agent. + + v2 replaced offset pagination with cursors: ``additional_data.next_cursor`` + holds an opaque string to pass back as ``cursor``, and is absent or null on + the last page. Running v2 responses through :func:`paginated` instead would + read the v1 ``additional_data.pagination`` key, find nothing, and silently + report a single page — so the two wrappers stay separate. + """ + additional = {} + if isinstance(envelope, dict): + additional = envelope.get('additional_data') or {} + next_cursor = additional.get('next_cursor') + return { + 'items': items, + 'count': len(items), + 'more_items_in_collection': bool(next_cursor), + 'next_cursor': next_cursor, + } + + # --------------------------------------------------------------------------- # Response cleaners — Pipedrive payloads are very wide (every custom field, plus # *_flat duplicates and picture blobs), so trim them to what an agent can use. diff --git a/nodes/src/nodes/tool_pipedrive/services.json b/nodes/src/nodes/tool_pipedrive/services.json index eae4afb0f..3bc9f97ea 100644 --- a/nodes/src/nodes/tool_pipedrive/services.json +++ b/nodes/src/nodes/tool_pipedrive/services.json @@ -56,9 +56,38 @@ "pipedrive.toolGroups": { "type": "array", "title": "Tool groups", - "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is 256 tools, which is more than an LLM can choose between reliably, so only the listed groups are exposed. Group sizes vary a lot (deals is 28 tools, permission_sets is 3); selections that publish more than 120 tools log a warning but still run, and all publishes everything without warning. Available groups: deals, persons, organizations, org_relationships, activities, pipelines, stages, notes, search, fields, leads, products, files, users, roles, permission_sets, teams, goals, filters, webhooks, subscriptions, mailbox, call_logs, projects, misc.", + "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is 256 tools, which is more than an LLM can choose between reliably, so only the selected groups are exposed. Pick one or more from the list; tool counts are shown per group, and selections totalling more than 120 log a warning but still run. Selecting All groups publishes everything and skips that warning.", + "uniqueItems": true, "items": { - "type": "string" + "type": "string", + "enum": [ + ["deals", "Deals (28)"], + ["persons", "Persons (18)"], + ["organizations", "Organizations (18)"], + ["activities", "Activities (13)"], + ["pipelines", "Pipelines (8)"], + ["stages", "Stages (7)"], + ["notes", "Notes (11)"], + ["search", "Search (4)"], + ["call_logs", "Call logs (5)"], + ["fields", "Custom fields (6)"], + ["files", "Files (8)"], + ["filters", "Filters (7)"], + ["goals", "Goals (5)"], + ["leads", "Leads (12)"], + ["mailbox", "Mailbox (6)"], + ["misc", "Misc: currencies, channels, meetings (8)"], + ["org_relationships", "Organization relationships (5)"], + ["permission_sets", "Permission sets (3)"], + ["products", "Products (16)"], + ["projects", "Projects (22)"], + ["roles", "Roles (13)"], + ["subscriptions", "Subscriptions (9)"], + ["teams", "Teams (8)"], + ["users", "Users (12)"], + ["webhooks", "Webhooks (3)"], + ["all", "All groups (256) - skips the size warning"] + ] }, "default": ["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"] }, diff --git a/nodes/src/nodes/tool_pipedrive/tools/_base.py b/nodes/src/nodes/tool_pipedrive/tools/_base.py index 24c23983a..080154a87 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/_base.py +++ b/nodes/src/nodes/tool_pipedrive/tools/_base.py @@ -112,6 +112,20 @@ def PAGING() -> dict: } +def PAGING_V2() -> dict: + """Cursor paging for the v2 search endpoints. + + v2 dropped the numeric offset: there is no ``start``, only an opaque + ``cursor`` echoed back from the previous page. This schema is rendered + verbatim into the agent's tool prompt, so advertising ``start`` here would + invite calls Pipedrive silently ignores. + """ + return { + 'cursor': STR('Pagination cursor from a previous call (its next_cursor). Omit for the first page.'), + 'limit': INT(f'Number of records to return (1-{MAX_LIMIT}, default 100).'), + } + + # --------------------------------------------------------------------------- # Mixin base # --------------------------------------------------------------------------- @@ -131,6 +145,10 @@ def _token(self) -> str: def _base(self) -> str: return self.IGlobal.base_url + def _base_v2(self) -> str: + """The ``/api/v2`` base. Search tools only — see ``BASE_URL_V2``.""" + return self.IGlobal.base_url_v2 + def _require_write(self) -> None: if self.IGlobal.read_only: raise ValueError('This operation is not permitted: the node is configured in read-only mode') @@ -143,6 +161,15 @@ def _call(self, method: str, path: str, **kwargs: Any) -> Any: def _call_envelope(self, method: str, path: str, **kwargs: Any) -> Any: return call_envelope(self._token(), method, path, base_url=self._base(), **kwargs) + def _call_envelope_v2(self, method: str, path: str, **kwargs: Any) -> Any: + """Same as :meth:`_call_envelope` but against ``/api/v2``. + + Only the search tools use this. Everything else stays on v1, which still + routes normally — a blanket swap would break the many endpoints that have + no v2 equivalent yet. + """ + return call_envelope(self._token(), method, path, base_url=self._base_v2(), **kwargs) + def _list(self, path: str, args: dict, cleaner, *, extra: dict | None = None) -> dict: """GET a collection with offset pagination and return cleaned items + cursor.""" params = paging_params(args) @@ -177,7 +204,7 @@ def args_of(args: Any) -> dict: def paging_params(args: dict) -> dict: - """Clamp start/limit to what Pipedrive accepts.""" + """Clamp start/limit to what Pipedrive's v1 offset pagination accepts.""" params: dict = {} if args.get('start') is not None: params['start'] = max(0, int(args['start'])) @@ -186,6 +213,23 @@ def paging_params(args: dict) -> dict: return params +def paging_params_v2(args: dict) -> dict: + """Clamp limit and pass through the v2 cursor. + + ``cursor`` is opaque: it is echoed back exactly as Pipedrive issued it, never + parsed or clamped. ``start`` is deliberately not emitted — v2 has no offset + parameter, and sending one would be ignored rather than rejected, which reads + as a silently truncated result set. + """ + params: dict = {} + cursor = args.get('cursor') + if cursor is not None and str(cursor).strip(): + params['cursor'] = str(cursor).strip() + if args.get('limit') is not None: + params['limit'] = max(1, min(int(args['limit']), MAX_LIMIT)) + return params + + def body_from(args: dict, keys: Iterable[str], *, extra_key: str = 'extra') -> dict: """Collect the provided typed keys into a request body, then merge ``extra``. diff --git a/nodes/src/nodes/tool_pipedrive/tools/deals.py b/nodes/src/nodes/tool_pipedrive/tools/deals.py index 8485cff34..df0c7c561 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/deals.py +++ b/nodes/src/nodes/tool_pipedrive/tools/deals.py @@ -36,6 +36,7 @@ clean_search_item, clean_user, paginated, + paginated_v2, ) from ..tool_groups import pipedrive_tool from ._base import ( @@ -47,11 +48,13 @@ NUM, OBJ, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, paging_params, + paging_params_v2, params_from, passthrough, require_id, @@ -145,20 +148,21 @@ def deal_get(self, args): organization_id=INT('Only deals linked to this organization.'), status=ENUM('Only deals with this status.', ['open', 'won', 'lost']), include_fields=STR('Extra fields to include, e.g. "deal.cc_email".'), - **PAGING(), + **PAGING_V2(), ), description='Search deals by title, notes or custom field values.', ) def deal_search(self, args): + # v2: Pipedrive retired /api/v1/deals/search (404 "Unknown method ."). args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'deal_search') params.update( params_from(args, ('fields', 'exact_match', 'person_id', 'organization_id', 'status', 'include_fields')) ) - envelope = self._call_envelope('GET', '/deals/search', params=params) + envelope = self._call_envelope_v2('GET', '/deals/search', params=params) items = ((envelope.get('data') or {}).get('items')) or [] - return paginated(envelope, [clean_search_item(i) for i in items]) + return paginated_v2(envelope, [clean_search_item(i) for i in items]) @pipedrive_tool( group='deals', diff --git a/nodes/src/nodes/tool_pipedrive/tools/leads.py b/nodes/src/nodes/tool_pipedrive/tools/leads.py index b5029b285..db9f9b229 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/leads.py +++ b/nodes/src/nodes/tool_pipedrive/tools/leads.py @@ -27,7 +27,7 @@ from __future__ import annotations -from ..pipedrive_client import clean_lead, clean_search_item, paginated +from ..pipedrive_client import clean_lead, clean_search_item, paginated_v2 from ..tool_groups import pipedrive_tool from ._base import ( ARR, @@ -37,11 +37,12 @@ INT, OBJ, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, - paging_params, + paging_params_v2, params_from, passthrough, require_text, @@ -127,18 +128,19 @@ def lead_get(self, args): person_id=INT('Only leads linked to this person.'), organization_id=INT('Only leads linked to this organization.'), include_fields=STR('Extra fields to include, e.g. "lead.was_seen".'), - **PAGING(), + **PAGING_V2(), ), description='Search leads by title, notes or custom field values.', ) def lead_search(self, args): + # v2: Pipedrive retired /api/v1/leads/search (404 "Unknown method ."). args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'lead_search') params.update(params_from(args, ('fields', 'exact_match', 'person_id', 'organization_id', 'include_fields'))) - envelope = self._call_envelope('GET', '/leads/search', params=params) + envelope = self._call_envelope_v2('GET', '/leads/search', params=params) items = ((envelope.get('data') or {}).get('items')) or [] - return paginated(envelope, [clean_search_item(i) for i in items]) + return paginated_v2(envelope, [clean_search_item(i) for i in items]) @pipedrive_tool( group='leads', diff --git a/nodes/src/nodes/tool_pipedrive/tools/organizations.py b/nodes/src/nodes/tool_pipedrive/tools/organizations.py index 21f8d4461..7d9b13bc8 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/organizations.py +++ b/nodes/src/nodes/tool_pipedrive/tools/organizations.py @@ -36,6 +36,7 @@ clean_person, clean_search_item, paginated, + paginated_v2, ) from ..tool_groups import pipedrive_tool from ._base import ( @@ -45,11 +46,13 @@ EXTRA, INT, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, paging_params, + paging_params_v2, params_from, passthrough, require_id, @@ -108,18 +111,19 @@ def organization_get(self, args): term=STR('Search term, at least 2 characters (1 when exact_match is true).'), fields=STR('Comma-separated fields to search in: address, custom_fields, notes, name.'), exact_match=BOOL('Require an exact, case-sensitive match.'), - **PAGING(), + **PAGING_V2(), ), description='Search organizations by name, address, notes or custom field values.', ) def organization_search(self, args): + # v2: Pipedrive retired /api/v1/organizations/search (404 "Unknown method ."). args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'organization_search') params.update(params_from(args, ('fields', 'exact_match'))) - envelope = self._call_envelope('GET', '/organizations/search', params=params) + envelope = self._call_envelope_v2('GET', '/organizations/search', params=params) items = ((envelope.get('data') or {}).get('items')) or [] - return paginated(envelope, [clean_search_item(i) for i in items]) + return paginated_v2(envelope, [clean_search_item(i) for i in items]) @pipedrive_tool( group='organizations', diff --git a/nodes/src/nodes/tool_pipedrive/tools/persons.py b/nodes/src/nodes/tool_pipedrive/tools/persons.py index f3c326a59..5798f8fc8 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/persons.py +++ b/nodes/src/nodes/tool_pipedrive/tools/persons.py @@ -35,6 +35,7 @@ clean_person, clean_search_item, paginated, + paginated_v2, ) from ..tool_groups import pipedrive_tool from ._base import ( @@ -44,11 +45,13 @@ EXTRA, INT, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, paging_params, + paging_params_v2, params_from, passthrough, require_id, @@ -137,18 +140,19 @@ def person_get(self, args): exact_match=BOOL('Require an exact, case-sensitive match.'), organization_id=INT('Only persons in this organization.'), include_fields=STR('Extra fields to include, e.g. "person.picture".'), - **PAGING(), + **PAGING_V2(), ), description='Search persons by name, email, phone, notes or custom field values.', ) def person_search(self, args): + # v2: Pipedrive retired /api/v1/persons/search (404 "Unknown method ."). args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'person_search') params.update(params_from(args, ('fields', 'exact_match', 'organization_id', 'include_fields'))) - envelope = self._call_envelope('GET', '/persons/search', params=params) + envelope = self._call_envelope_v2('GET', '/persons/search', params=params) items = ((envelope.get('data') or {}).get('items')) or [] - return paginated(envelope, [clean_search_item(i) for i in items]) + return paginated_v2(envelope, [clean_search_item(i) for i in items]) @pipedrive_tool( group='persons', diff --git a/nodes/src/nodes/tool_pipedrive/tools/products.py b/nodes/src/nodes/tool_pipedrive/tools/products.py index e7c4b62c8..442011c19 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/products.py +++ b/nodes/src/nodes/tool_pipedrive/tools/products.py @@ -27,7 +27,7 @@ from __future__ import annotations -from ..pipedrive_client import clean_deal, clean_file, clean_product, clean_search_item, paginated +from ..pipedrive_client import clean_deal, clean_file, clean_product, clean_search_item, paginated_v2 from ..tool_groups import pipedrive_tool from ._base import ( ARR, @@ -37,11 +37,12 @@ INT, NUM, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, - paging_params, + paging_params_v2, params_from, passthrough, require_id, @@ -129,18 +130,19 @@ def product_get(self, args): fields=STR('Comma-separated fields to search in: code, custom_fields, name.'), exact_match=BOOL('Require an exact, case-sensitive match.'), include_fields=STR('Extra fields to include, e.g. "product.price".'), - **PAGING(), + **PAGING_V2(), ), description='Search products by name, code or custom field values.', ) def product_search(self, args): + # v2: Pipedrive retired /api/v1/products/search (404 "Unknown method ."). args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'product_search') params.update(params_from(args, ('fields', 'exact_match', 'include_fields'))) - envelope = self._call_envelope('GET', '/products/search', params=params) + envelope = self._call_envelope_v2('GET', '/products/search', params=params) items = ((envelope.get('data') or {}).get('items')) or [] - return paginated(envelope, [clean_search_item(i) for i in items]) + return paginated_v2(envelope, [clean_search_item(i) for i in items]) @pipedrive_tool( group='products', diff --git a/nodes/src/nodes/tool_pipedrive/tools/search.py b/nodes/src/nodes/tool_pipedrive/tools/search.py index 963ab97a4..f1d53e1c5 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/search.py +++ b/nodes/src/nodes/tool_pipedrive/tools/search.py @@ -27,17 +27,19 @@ from __future__ import annotations -from ..pipedrive_client import clean_search_item, paginated +from ..pipedrive_client import clean_search_item, paginated, paginated_v2 from ..tool_groups import pipedrive_tool from ._base import ( BOOL, ENUM, INT, PAGING, + PAGING_V2, STR, PipedriveToolsBase, args_of, paging_params, + paging_params_v2, params_from, require_text, schema, @@ -63,21 +65,21 @@ class SearchMixin(PipedriveToolsBase): search_for_related_items=BOOL('Also return the deals and persons related to each match.'), exact_match=BOOL('Require an exact, case-sensitive match.'), include_fields=STR('Extra fields to include in the results, e.g. "deal.cc_email".'), - **PAGING(), + **PAGING_V2(), ), description='Search across every Pipedrive item type at once — deals, persons, organizations, products, leads, files and projects. Use this when you do not know which record type holds the answer.', ) def item_search(self, args): args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'item_search') params.update( params_from(args, ('item_types', 'fields', 'search_for_related_items', 'exact_match', 'include_fields')) ) - envelope = self._call_envelope('GET', '/itemSearch', params=params) + envelope = self._call_envelope_v2('GET', '/itemSearch', params=params) data = envelope.get('data') or {} items = data.get('items') or [] - result = paginated(envelope, [clean_search_item(i) for i in items]) + result = paginated_v2(envelope, [clean_search_item(i) for i in items]) if data.get('related_items'): result['related_items'] = [clean_search_item(i) for i in data['related_items']] return result @@ -85,36 +87,41 @@ def item_search(self, args): @pipedrive_tool( group='search', input_schema=schema( - required=['term', 'field_type', 'field_key'], + required=['term', 'entity_type', 'field_key'], term=STR('Value to look for in the field.'), - field_type=ENUM( + # v2 renamed this from `field_type` and dropped the "Field" suffix on + # every value: v1 took `personField`, v2 takes `person`. + entity_type=ENUM( 'Which entity the field belongs to.', [ - 'dealField', - 'leadField', - 'personField', - 'organizationField', - 'productField', - 'projectField', + 'deal', + 'leads', + 'person', + 'organization', + 'product', + 'project', ], ), field_key=STR('Key of the field to search, e.g. "title" or a 40-character custom field key.'), - exact_match=BOOL('Require an exact, case-sensitive match.'), - return_item_ids=BOOL('Return matching item ids instead of distinct field values.'), - **PAGING(), + # v2 replaced the v1 `exact_match` boolean with a three-way match mode. + match=ENUM( + 'How the term must line up with the field value (default "exact").', + ['exact', 'beginning', 'middle'], + ), + **PAGING_V2(), ), - description='Search a single field for a value — for example find every deal whose custom "Contract number" field starts with a string. Returns distinct field values by default.', + description='Search a single field for a value — for example find every deal whose custom "Contract number" field starts with a string. Returns distinct field values.', ) def item_search_by_field(self, args): args = args_of(args) - params = paging_params(args) + params = paging_params_v2(args) params['term'] = require_text(args, 'term', 'item_search_by_field') - params['field_type'] = require_text(args, 'field_type', 'item_search_by_field') + params['entity_type'] = require_text(args, 'entity_type', 'item_search_by_field') params['field_key'] = require_text(args, 'field_key', 'item_search_by_field') - params.update(params_from(args, ('exact_match', 'return_item_ids'))) - envelope = self._call_envelope('GET', '/itemSearch/field', params=params) + params.update(params_from(args, ('match',))) + envelope = self._call_envelope_v2('GET', '/itemSearch/field', params=params) data = envelope.get('data') if isinstance(envelope, dict) else None - return paginated(envelope, list(data or [])) + return paginated_v2(envelope, list(data or [])) @pipedrive_tool( group='search', @@ -155,7 +162,7 @@ def lookup(self, args): 'limit': max(1, min(int(args.get('limit') or 10), 100)), } params.update(params_from(args, ('item_types',))) - envelope = self._call_envelope('GET', '/itemSearch', params=params) + envelope = self._call_envelope_v2('GET', '/itemSearch', params=params) items = ((envelope.get('data') or {}).get('items')) or [] out = [] for entry in items: diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py index 6f3a7dbfd..47ce5555a 100644 --- a/nodes/test/tool_pipedrive/test_pipedrive.py +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import sys import types from collections.abc import Iterator @@ -112,16 +113,19 @@ def _scoped_stubs() -> Iterator[None]: from tool_pipedrive.IInstance import IInstance from tool_pipedrive.pipedrive_client import ( BASE_URL, + BASE_URL_V2, MAX_LIMIT, PipedriveAPIError, _auth, _use_bearer, base_url_for, + base_url_v2_for, call, call_envelope, clean_deal, clean_person, paginated, + paginated_v2, split_custom_fields, ) from tool_pipedrive.IGlobal import _oversized_warning @@ -135,7 +139,7 @@ def _scoped_stubs() -> Iterator[None]: tool_counts_by_group, wants_all_groups, ) - from tool_pipedrive.tools._base import body_from, paging_params + from tool_pipedrive.tools._base import PAGING, PAGING_V2, body_from, paging_params, paging_params_v2 # --------------------------------------------------------------------------- @@ -171,6 +175,7 @@ def _instance(**overrides): glob = Mock() glob.token = TEST_TOKEN glob.base_url = BASE_URL + glob.base_url_v2 = BASE_URL_V2 glob.read_only = False glob.tool_groups = DEFAULT_GROUPS glob.allow_raw_request = True @@ -797,3 +802,188 @@ def test_warning_never_reduces_the_published_tools(self): """The guard rail is advisory: an oversized selection still publishes everything.""" published = _instance(tool_groups=ALL_GROUPS)._collect_tool_methods() assert len(published) > RECOMMENDED_TOOL_LIMIT + + +_MANIFEST = json.loads( + (Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'tool_pipedrive' / 'services.json').read_text( + encoding='utf-8' + ) +) + + +class TestToolGroupsField: + """The manifest drives the config panel; it must not drift from the code. + + The field renders as a multi-select dropdown only when it is an array with + `uniqueItems: true` and an `items.enum` — RJSF falls back to free-text + add/remove inputs without them. It must also carry no `ui:widget` override, + since `checkboxes` would switch the same schema to a checkbox list. + """ + + field = _MANIFEST['fields']['pipedrive.toolGroups'] + + def test_renders_as_a_multi_select_dropdown(self): + assert self.field['type'] == 'array' + assert self.field['uniqueItems'] is True + assert self.field['items']['type'] == 'string' + + def test_no_widget_override(self): + """RJSF defaults an enum array to SelectWidget; any override changes the control.""" + assert 'ui' not in self.field + + def test_every_implemented_group_is_selectable(self): + values = {option[0] for option in self.field['items']['enum']} + assert ALL_GROUPS <= values + + def test_no_option_is_an_unknown_group(self): + values = {option[0] for option in self.field['items']['enum']} + assert values - ALL_GROUPS == {'all'}, 'only the "all" sentinel may sit outside ALL_GROUPS' + + def test_options_are_unique(self): + values = [option[0] for option in self.field['items']['enum']] + assert len(values) == len(set(values)) + + def test_labels_carry_the_real_tool_counts(self): + labels = {option[0]: option[1] for option in self.field['items']['enum']} + for group, count in tool_counts_by_group().items(): + assert f'({count})' in labels[group], f'{group} label is stale: {labels[group]}' + + def test_default_selection_matches_the_code_default(self): + assert set(self.field['default']) == DEFAULT_GROUPS + + def test_all_option_advertises_the_full_surface(self): + labels = {option[0]: option[1] for option in self.field['items']['enum']} + total = sum(tool_counts_by_group().values()) + 1 # + the request escape hatch + assert str(total) in labels['all'] + + +class TestSearchUsesApiV2: + """Pipedrive retired the v1 search routes, and only those routes. + + v1 answered ``/persons/search`` and its siblings with 404 ``Unknown method .`` + (the resource still exists, the action does not) and ``/itemSearch`` with a + plain 404 ``Not Found``. Every non-search v1 endpoint kept working, so the fix + is scoped to search — a blanket swap would break the many endpoints that have + no v2 equivalent. + """ + + @pytest.mark.parametrize( + ('tool', 'args', 'path'), + [ + ('person_search', {'term': 'ada'}, '/persons/search'), + ('organization_search', {'term': 'acme'}, '/organizations/search'), + ('deal_search', {'term': 'acme'}, '/deals/search'), + ('lead_search', {'term': 'acme'}, '/leads/search'), + ('product_search', {'term': 'widget'}, '/products/search'), + ('item_search', {'term': 'acme'}, '/itemSearch'), + ('lookup', {'term': 'acme'}, '/itemSearch'), + ], + ) + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_search_tools_target_v2(self, mock_request, tool, args, path): + mock_request.return_value = _ok({'items': []}) + getattr(_instance(tool_groups=ALL_GROUPS), tool)(args) + assert mock_request.call_args[0][1] == f'{BASE_URL_V2}{path}' + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_item_search_by_field_targets_v2(self, mock_request): + mock_request.return_value = _ok([]) + _instance(tool_groups=ALL_GROUPS).item_search_by_field( + {'term': 'acme', 'entity_type': 'person', 'field_key': 'name'} + ) + assert mock_request.call_args[0][1] == f'{BASE_URL_V2}/itemSearch/field' + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_item_search_by_field_uses_the_v2_parameter_names(self, mock_request): + """v2 renamed ``field_type`` to ``entity_type`` and replaced the ``exact_match`` flag with ``match``.""" + mock_request.return_value = _ok([]) + _instance(tool_groups=ALL_GROUPS).item_search_by_field( + {'term': 'acme', 'entity_type': 'person', 'field_key': 'name', 'match': 'beginning'} + ) + params = mock_request.call_args[1]['params'] + assert params['entity_type'] == 'person' + assert params['field_key'] == 'name' + assert params['match'] == 'beginning' + assert 'field_type' not in params + assert 'exact_match' not in params + + @pytest.mark.parametrize( + ('tool', 'args', 'path'), + [ + ('organization_list', {}, '/organizations'), + ('person_list', {}, '/persons'), + ('deal_list', {}, '/deals'), + ('recents_list', {'since_timestamp': '2026-07-16 00:00:00'}, '/recents'), + ], + ) + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_non_search_tools_stay_on_v1(self, mock_request, tool, args, path): + """Guards against an accidental global version swap.""" + mock_request.return_value = _ok([]) + getattr(_instance(tool_groups=ALL_GROUPS), tool)(args) + assert mock_request.call_args[0][1] == f'{BASE_URL}{path}' + + def test_v2_base_url_defaults_to_the_generic_host(self): + assert base_url_v2_for('') == BASE_URL_V2 + assert base_url_v2_for(None) == BASE_URL_V2 + + @pytest.mark.parametrize('domain', ['acme', 'acme.pipedrive.com', 'https://acme.pipedrive.com/']) + def test_v2_base_url_normalises_the_company_domain(self, domain): + assert base_url_v2_for(domain) == 'https://acme.pipedrive.com/api/v2' + + def test_both_versions_address_the_same_company(self): + """A domain that resolves differently per version would split one account in two.""" + assert base_url_for('acme').rsplit('/', 1)[0] == base_url_v2_for('acme').rsplit('/', 1)[0] + + +class TestCursorPagination: + """v2 replaced v1's numeric offset with an opaque cursor. + + Running a v2 response through the v1 :func:`paginated` would read the absent + ``additional_data.pagination`` key, report a single page, and silently hide + every result after the first — hence the separate helpers. + """ + + def test_paging_params_v2_never_sends_start(self): + assert 'start' not in paging_params_v2({'start': 40, 'limit': 10}) + + def test_paging_params_v2_passes_the_cursor_through_trimmed(self): + assert paging_params_v2({'cursor': ' abc123 '})['cursor'] == 'abc123' + + def test_paging_params_v2_omits_a_blank_cursor(self): + assert 'cursor' not in paging_params_v2({'cursor': ' '}) + assert 'cursor' not in paging_params_v2({}) + + def test_paging_params_v2_clamps_limit(self): + assert paging_params_v2({'limit': MAX_LIMIT + 1})['limit'] == MAX_LIMIT + assert paging_params_v2({'limit': 0})['limit'] == 1 + + def test_paginated_v2_surfaces_next_cursor(self): + out = paginated_v2({'additional_data': {'next_cursor': 'abc'}}, [1, 2]) + assert out['next_cursor'] == 'abc' + assert out['count'] == 2 + assert out['more_items_in_collection'] is True + assert 'next_start' not in out + + def test_paginated_v2_reports_the_last_page(self): + out = paginated_v2({'additional_data': {'next_cursor': None}}, [1]) + assert out['next_cursor'] is None + assert out['more_items_in_collection'] is False + + def test_paginated_v2_tolerates_a_missing_envelope(self): + out = paginated_v2(None, []) + assert out == {'items': [], 'count': 0, 'more_items_in_collection': False, 'next_cursor': None} + + def test_v2_schema_advertises_cursor_and_v1_still_advertises_start(self): + """The schema is rendered verbatim into the agent prompt, so the wrong key invites dead calls.""" + assert 'cursor' in PAGING_V2() + assert 'start' not in PAGING_V2() + assert 'start' in PAGING() + assert 'cursor' not in PAGING() + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_a_search_round_trips_the_cursor(self, mock_request): + mock_request.return_value = _ok({'items': []}, additional={'next_cursor': 'page2'}) + result = _instance().person_search({'term': 'ada', 'cursor': 'page1'}) + assert mock_request.call_args[1]['params']['cursor'] == 'page1' + assert result['next_cursor'] == 'page2' diff --git a/nodes/test/tool_pipedrive/test_tools.py b/nodes/test/tool_pipedrive/test_tools.py index 9cc0a504d..bf8c3b1a9 100644 --- a/nodes/test/tool_pipedrive/test_tools.py +++ b/nodes/test/tool_pipedrive/test_tools.py @@ -25,12 +25,14 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'tool_pipedrive')) -from pipedrive_client import base_url_for, call, call_envelope # noqa: E402 +from pipedrive_client import PipedriveAPIError, base_url_for, base_url_v2_for, call, call_envelope # noqa: E402 TOKEN = os.getenv('PIPEDRIVE_API_TOKEN', '') DOMAIN = os.getenv('PIPEDRIVE_COMPANY_DOMAIN', '') ALLOW_WRITES = os.getenv('PIPEDRIVE_ALLOW_WRITES', '') == '1' BASE = base_url_for(DOMAIN) +#: Search moved to v2 when Pipedrive retired the v1 search routes. +BASE_V2 = base_url_v2_for(DOMAIN) pytestmark = pytest.mark.skipif(not TOKEN, reason='PIPEDRIVE_API_TOKEN must be set') @@ -52,6 +54,14 @@ def envelope(method, path, *, params=None): return call_envelope(TOKEN, method, path, base_url=BASE, params=params) +def c_v2(method, path, *, params=None, body=None): + return call(TOKEN, method, path, base_url=BASE_V2, params=params, body=body) + + +def envelope_v2(method, path, *, params=None): + return call_envelope(TOKEN, method, path, base_url=BASE_V2, params=params) + + def uid(prefix): return f'{prefix}-rocketride-test-{uuid.uuid4().hex[:8]}' @@ -163,31 +173,69 @@ def test_mail_threads(self): class TestSearch: + """Search lives on /api/v2 — Pipedrive retired the v1 routes. + + Run this against the sandbox to confirm the v2 contract before trusting the + node's search tools: + + PIPEDRIVE_API_TOKEN=... PIPEDRIVE_COMPANY_DOMAIN=... \\ + python -m pytest nodes/test/tool_pipedrive/test_tools.py -k Search -v + """ + def test_item_search(self): - env = envelope('GET', '/itemSearch', params={'term': 'a', 'limit': 1}) + env = envelope_v2('GET', '/itemSearch', params={'term': 'a', 'limit': 1}) assert 'items' in (env.get('data') or {}) def test_item_search_by_field(self): - data = c( + """v2 renamed ``field_type`` to ``entity_type`` and swapped ``exact_match`` for ``match``.""" + data = c_v2( 'GET', '/itemSearch/field', - params={'term': 'a', 'field_type': 'dealField', 'field_key': 'title', 'limit': 1}, + params={'term': 'a', 'entity_type': 'deal', 'field_key': 'title', 'limit': 1}, ) assert data is not None def test_deal_search(self): - env = envelope('GET', '/deals/search', params={'term': 'a', 'limit': 1}) + env = envelope_v2('GET', '/deals/search', params={'term': 'a', 'limit': 1}) assert 'items' in (env.get('data') or {}) def test_person_search(self): - env = envelope('GET', '/persons/search', params={'term': 'a', 'limit': 1}) + env = envelope_v2('GET', '/persons/search', params={'term': 'a', 'limit': 1}) assert 'items' in (env.get('data') or {}) def test_organization_search(self): - env = envelope('GET', '/organizations/search', params={'term': 'a', 'limit': 1}) + env = envelope_v2('GET', '/organizations/search', params={'term': 'a', 'limit': 1}) assert 'items' in (env.get('data') or {}) - def test_recents(self): + def test_lead_search(self): + env = envelope_v2('GET', '/leads/search', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_product_search(self): + env = envelope_v2('GET', '/products/search', params={'term': 'a', 'limit': 1}) + assert 'items' in (env.get('data') or {}) + + def test_v2_pages_with_a_cursor_not_an_offset(self): + """Pins the field name the node's paginated_v2 reads.""" + env = envelope_v2('GET', '/persons/search', params={'term': 'a', 'limit': 1}) + additional = env.get('additional_data') or {} + assert 'pagination' not in additional, 'v2 should not carry the v1 offset block' + # next_cursor is absent on a final page, so only assert the shape when present. + if additional: + assert set(additional) <= {'next_cursor'}, f'unexpected v2 pagination keys: {sorted(additional)}' + + @pytest.mark.parametrize( + 'path', + ['/itemSearch', '/deals/search', '/persons/search', '/organizations/search'], + ) + def test_v1_search_is_gone(self, path): + """Documents why the node moved. Delete this class of test if Pipedrive restores v1.""" + with pytest.raises(PipedriveAPIError) as exc: + call_envelope(TOKEN, 'GET', path, base_url=BASE, params={'term': 'a', 'limit': 1}) + assert exc.value.status_code == 404 + + def test_recents_stays_on_v1(self): + """Non-search v1 endpoints were never affected.""" data = c('GET', '/recents', params={'since_timestamp': '2020-01-01 00:00:00', 'limit': 1}) assert data is not None From 9e7bbc5c1d45afcc0f53459a003d3fd190a71cef Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Tue, 28 Jul 2026 22:36:53 -0700 Subject: [PATCH 08/12] fix(shared-ui): keep live panes working without a host event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1661 rewired every accumulating pane — Trace, Errors, Status, Tokens, Flow, Log, Analyze — to read through a DVR session obtained from the host's `openEventStream`, a prop no host in this repo binds. Hosts that have not bridged it across their transport (the VS Code webview is one) passed a null session, `useTaskEvents.ingestLive` dropped every event, and the panes rendered their empty states mid-run with no error anywhere. Fall back to an in-memory store over the live events the host already delivers. It satisfies the same session contract `deliver` expects — a pure append, each event handed over exactly once, oldest first, across repeated `play` calls — and is keyed per stream identity so the several views `SourceSection` opens all share one buffer. Replay of recorded runs still requires the real stream. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/project/ProjectView.tsx | 29 +- .../project/hooks/liveEventSession.test.ts | 257 ++++++++++++++++++ .../modules/project/hooks/liveEventSession.ts | 250 +++++++++++++++++ 3 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts create mode 100644 packages/shared-ui/src/modules/project/hooks/liveEventSession.ts diff --git a/packages/shared-ui/src/modules/project/ProjectView.tsx b/packages/shared-ui/src/modules/project/ProjectView.tsx index 97cd5fa89..2715e5674 100644 --- a/packages/shared-ui/src/modules/project/ProjectView.tsx +++ b/packages/shared-ui/src/modules/project/ProjectView.tsx @@ -35,6 +35,7 @@ import { OAUTH_ROOT_URL } from '../../config/oauth'; import { extractPipelineEnvVars } from '../../components/canvas/util/extractEnvVars'; import { SourceSection } from './components/SourceSection'; import type { TaskEventMessage, TaskEventSession, TaskTimeline } from './hooks/useTaskEvents'; +import { createLiveEventStore, type LiveEventStore } from './hooks/liveEventSession'; import type { ProjectViewMode, ViewState, TaskStatus, TraceEvent } from './types'; // ============================================================================= @@ -471,6 +472,28 @@ const ProjectView: React.FC = ({ project, documentTitle, serv return bySource; }, [liveLogEvents, sources]); + // Live-edge fallback: hosts that have not bridged `openEventStream` across + // their transport (the VS Code webview is one) would otherwise pass a null + // session, and `useTaskEvents.ingestLive` drops every event — Trace, Errors, + // Status, Tokens, Flow and Log all render their empty states mid-run with no + // error anywhere. An in-memory store over the live events the host already + // delivers keeps those panes working at the live edge. Replay of recorded + // runs still needs the real stream. + // + // One store per stream identity, kept across renders: `SourceSection` opens + // the factory more than once (the export walker takes its own view), and all + // of them must see the same buffer. + const liveStoresRef = useRef(new Map()); + const liveStore = useCallback((key: string): LiveEventStore => { + const stores = liveStoresRef.current; + let store = stores.get(key); + if (!store) { + store = createLiveEventStore(); + stores.set(key, store); + } + return store; + }, []); + /** Render the stacked SourceSections for one continuum. */ const renderSections = (runKind: 'dev' | 'deploy'): ReactNode => sources.length > 0 ? ( @@ -481,7 +504,11 @@ const ProjectView: React.FC = ({ project, documentTitle, serv runKind={runKind} projectId={projectId} liveEvents={runKind === 'dev' ? (liveBySource.get(src.id) ?? []) : []} - openSession={openEventStream ? () => openEventStream({ source: src.id, runKind }) : null} + openSession={ + openEventStream + ? () => openEventStream({ source: src.id, runKind }) + : () => liveStore(`${src.id}.${runKind}.${projectId}`).open() + } fetchTimeline={fetchTimeline ? () => fetchTimeline({ source: src.id, runKind }) : null} liveTaskStatus={runKind === 'dev' ? statusMap[src.id] : undefined} componentNames={componentNames} diff --git a/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts b/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts new file mode 100644 index 000000000..d868e9116 --- /dev/null +++ b/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts @@ -0,0 +1,257 @@ +// ============================================================================= +// Unit tests: the in-memory live-edge session. +// +// These pin the regression from PR #1661, which rewired every accumulating pane +// (Trace, Errors, Status, Tokens, Flow, Log, Analyze) to read through a +// host-injected DVR session obtained via `openEventStream` — a prop no host in +// this repo binds. `useTaskEvents.ingestLive` then dropped every live event +// (`const target = sessionRef.current; if (!target) return;`) and the panes +// rendered their empty states mid-run, silently. +// +// The contract that matters to `useTaskEvents.deliver`: it is a pure append — +// no sort, no dedupe — so a view must hand each event over EXACTLY ONCE, oldest +// first, across repeated `play` calls. +// +// Run via `shared-ui:test` (node --import tsx --test), matching the package convention. +// ============================================================================= + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { createLiveEventSession, createLiveEventStore, LIVE_BUFFER_CAP } from './liveEventSession'; +import type { TaskEventMessage } from './useTaskEvents'; + +// --- helpers ----------------------------------------------------------------- + +let seq = 0; + +const evt = (event: string, body: Record = {}): TaskEventMessage => { + seq += 1; + return { event, body: { eventTime: 1000 + seq, logSeq: seq, ...body } }; +}; + +const flow = (op: string, pipe: number, extra: Record = {}): TaskEventMessage => + evt('apaevt_flow', { op, id: pipe, ...extra }); + +/** Collect everything a view delivers. */ +const collector = () => { + const seen: TaskEventMessage[] = []; + return { seen, sink: (item: { event: TaskEventMessage }) => seen.push(item.event) }; +}; + +// --- delivery ---------------------------------------------------------------- + +test('play delivers buffered events oldest first', async () => { + const session = createLiveEventSession(); + const a = evt('apaevt_flow'); + const b = evt('apaevt_flow'); + session.ingestLive(a); + session.ingestLive(b); + + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + + assert.deepEqual( + seen.map((m) => m.body.logSeq), + [a.body.logSeq, b.body.logSeq], + ); +}); + +test('each event is delivered exactly once across repeated play calls', async () => { + const session = createLiveEventSession(); + session.ingestLive(evt('apaevt_flow')); + + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + await session.play(undefined, 0, sink); + await session.play(undefined, 0, sink); + + assert.equal(seen.length, 1, 'a replayed watermark would duplicate rows in the fold'); +}); + +test('events arriving while playing reach the sink immediately', async () => { + const session = createLiveEventSession(); + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + + session.ingestLive(evt('apaevt_flow')); + session.ingestLive(evt('apaevt_flow')); + + assert.equal(seen.length, 2, 'live arrivals must not wait for the next play() nudge'); +}); + +test('pause stops delivery and play resumes from the watermark', async () => { + const session = createLiveEventSession(); + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + + session.ingestLive(evt('apaevt_flow')); + session.pause(); + session.ingestLive(evt('apaevt_flow')); + assert.equal(seen.length, 1, 'paused view must not receive'); + + await session.play(undefined, 0, sink); + assert.equal(seen.length, 2, 'resuming delivers exactly the missed event'); +}); + +// --- seeking ----------------------------------------------------------------- + +test("seek('live') treats everything observed as delivered", async () => { + const session = createLiveEventSession(); + session.ingestLive(evt('apaevt_flow')); + session.ingestLive(evt('apaevt_flow')); + + await session.seek('live'); + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + + assert.deepEqual(seen, []); +}); + +test('numeric seek rewinds to the first event at or after the position', async () => { + const session = createLiveEventSession(); + const first = evt('apaevt_flow'); + const second = evt('apaevt_flow'); + session.ingestLive(first); + session.ingestLive(second); + await session.seek('live'); + + await session.seek(second.body.eventTime); + const { seen, sink } = collector(); + await session.play(undefined, 0, sink); + + assert.deepEqual( + seen.map((m) => m.body.logSeq), + [second.body.logSeq], + ); +}); + +// --- status ------------------------------------------------------------------ + +test('getStatus returns null before any snapshot', async () => { + assert.equal(await createLiveEventSession().getStatus(), null); +}); + +test('getStatus returns the most recent snapshot', async () => { + const session = createLiveEventSession(); + session.ingestLive(evt('apaevt_status', { objects: 1 })); + session.ingestLive(evt('apaevt_flow')); + session.ingestLive(evt('apaevt_status_update', { objects: 7 })); + + const status = await session.getStatus(); + assert.equal(status?.objects, 7); +}); + +// --- trace detail ------------------------------------------------------------ + +test('getTrace returns one request keyed by its begin seq', async () => { + const session = createLiveEventSession(); + const begin = flow('begin', 1); + session.ingestLive(begin); + session.ingestLive(flow('enter', 1, { component: 'llm' })); + session.ingestLive(flow('leave', 1, { component: 'llm' })); + session.ingestLive(flow('end', 1)); + + const { events } = await session.getTrace(begin.body.logSeq); + assert.deepEqual( + events.map((m) => m.body.op), + ['begin', 'enter', 'leave', 'end'], + ); +}); + +test('getTrace ignores other pipe slots', async () => { + const session = createLiveEventSession(); + const begin = flow('begin', 1); + session.ingestLive(begin); + session.ingestLive(flow('enter', 2, { component: 'other' })); + session.ingestLive(flow('end', 1)); + + const { events } = await session.getTrace(begin.body.logSeq); + assert.deepEqual( + events.map((m) => m.body.id), + [1, 1], + ); +}); + +test('getTrace stops at the next request reusing the same slot', async () => { + const session = createLiveEventSession(); + const begin = flow('begin', 1); + session.ingestLive(begin); + session.ingestLive(flow('enter', 1, { component: 'llm' })); + // No `end` — the slot is recycled by the next request. + session.ingestLive(flow('begin', 1)); + session.ingestLive(flow('enter', 1, { component: 'later' })); + + const { events } = await session.getTrace(begin.body.logSeq); + assert.equal(events.length, 2, 'a pipe id is a slot, not an identity'); +}); + +test('getTrace returns nothing for an unknown id', async () => { + const session = createLiveEventSession(); + session.ingestLive(flow('begin', 1)); + assert.deepEqual((await session.getTrace(999_999)).events, []); +}); + +// --- buffer cap -------------------------------------------------------------- + +test('the buffer evicts oldest first and reports what was dropped', () => { + const session = createLiveEventSession(); + assert.equal(session.truncatedBefore, null); + + for (let i = 0; i < LIVE_BUFFER_CAP + 1; i++) session.ingestLive(evt('apaevt_flow')); + + assert.ok(session.size <= LIVE_BUFFER_CAP, `size ${session.size} exceeded the cap`); + assert.ok(typeof session.truncatedBefore === 'number', 'coverage loss must be reported, not silent'); +}); + +// --- store / views ----------------------------------------------------------- + +test('a second view sees events ingested through the first', async () => { + const store = createLiveEventStore(); + const pane = store.open(); + const walker = store.open(); + + pane.ingestLive(evt('apaevt_flow')); + + const { seen, sink } = collector(); + await walker.play(undefined, 0, sink); + assert.equal(seen.length, 1, 'the export walker must see what the pane recorded'); +}); + +test('views keep independent watermarks', async () => { + const store = createLiveEventStore(); + const pane = store.open(); + const walker = store.open(); + + pane.ingestLive(evt('apaevt_flow')); + + const paneSink = collector(); + await pane.play(undefined, 0, paneSink.sink); + assert.equal(paneSink.seen.length, 1); + + // The walker has consumed nothing yet, so it still owes the same event. + const walkerSink = collector(); + await walker.play(undefined, 0, walkerSink.sink); + assert.equal(walkerSink.seen.length, 1, 'one view draining must not advance another'); +}); + +test('closing one view leaves siblings working', async () => { + const store = createLiveEventStore(); + const pane = store.open(); + const walker = store.open(); + pane.ingestLive(evt('apaevt_flow')); + + walker.closeEventStream(); + + const { seen, sink } = collector(); + await pane.play(undefined, 0, sink); + assert.equal(seen.length, 1, 'the surviving view keeps the buffer'); +}); + +test('a closed view stops accepting events', () => { + const store = createLiveEventStore(); + const view = store.open(); + view.closeEventStream(); + view.ingestLive(evt('apaevt_flow')); + assert.equal(store.size, 0); +}); diff --git a/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts b/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts new file mode 100644 index 000000000..8e9645f6b --- /dev/null +++ b/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts @@ -0,0 +1,250 @@ +// ============================================================================= +// MIT License +// Copyright (c) 2026 Aparavi Software AG Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// ============================================================================= + +/** + * In-memory live-edge session. + * + * `useTaskEvents` reads every accumulating pane (Trace, Errors, Status, Tokens, + * Flow, Log, Analyze) through a {@link TaskEventSession}. That session is + * normally the server-backed DVR stream the host opens via `openEventStream`, + * which can seek through *recorded* runs. + * + * Hosts that do not (yet) bridge `openEventStream` across their transport used + * to pass `null`, and `useTaskEvents.ingestLive` then dropped every event on the + * floor — the panes rendered their empty states while a run was executing, with + * no error anywhere. This implements the same interface over the live events the + * host already delivers, so those panes work at the live edge with no server + * round-trip. + * + * Store vs view: `SourceSection` opens the factory more than once — once for the + * pane session, and again per export (`fetchChapterEvents` walks a private + * session so its seeking cannot disturb the live one). So the buffer lives in a + * STORE, and each `open()` returns an independent VIEW with its own delivery + * watermark and sink over that shared buffer. + * + * What this deliberately does NOT do: replay. There is no recorded history to + * seek into, only what has been observed since the section mounted. Wire up the + * real `openEventStream` for DVR. + */ + +import type { TaskEventMessage, TaskEventSession } from './useTaskEvents'; + +/** + * Retained live events. Past this the oldest fifth is dropped in one splice, + * mirroring the fold's own backstop in `useTaskEvents` so a long-running task + * cannot grow the buffer without bound. + */ +export const LIVE_BUFFER_CAP = 20_000; + +/** How many are dropped when the cap is hit. */ +const TRIM_COUNT = LIVE_BUFFER_CAP / 5; + +/** Delivery sink registered by `play`. */ +type Sink = (item: { event: TaskEventMessage }) => void; + +/** A live session view, plus the introspection its callers and tests need. */ +export interface LiveEventSession extends TaskEventSession { + /** Number of events currently retained by the shared store. */ + readonly size: number; + /** + * Event time of the newest evicted event once the cap has bitten, else + * null. Callers surface this as coverage honesty rather than silently + * showing a truncated run. + */ + readonly truncatedBefore: number | null; +} + +/** Shared buffer behind one stream identity. */ +export interface LiveEventStore { + /** Open an independent view (own watermark and sink) over the buffer. */ + open(): LiveEventSession; + /** Retained event count — for tests and diagnostics. */ + readonly size: number; +} + +const eventTimeOf = (message: TaskEventMessage): number => Number(message.body?.eventTime ?? 0); +const seqOf = (message: TaskEventMessage): number => Number(message.body?.logSeq ?? 0); + +/** + * Create a store for one stream identity. + * + * Ordering contract: `useTaskEvents.deliver` is a pure append — no sort, no + * dedupe — so a view must hand events over exactly once, oldest first. The + * per-view watermark is what guarantees that across repeated `play` calls. + */ +export function createLiveEventStore(): LiveEventStore { + let buffer: TaskEventMessage[] = []; + let evictedBefore: number | null = null; + /** Views are notified on ingest so a playing view delivers immediately. */ + const views = new Set<{ drain: () => void; shift: (count: number) => void }>(); + + const ingest = (message: TaskEventMessage): void => { + buffer.push(message); + if (buffer.length > LIVE_BUFFER_CAP) { + const dropped = buffer.slice(0, TRIM_COUNT); + buffer = buffer.slice(TRIM_COUNT); + evictedBefore = eventTimeOf(dropped[dropped.length - 1]); + for (const view of views) view.shift(TRIM_COUNT); + } + for (const view of views) view.drain(); + }; + + const open = (): LiveEventSession => { + /** Index of the next event this view delivers. */ + let watermark = 0; + let sink: Sink | null = null; + let attached = true; + + const drain = (): void => { + if (!sink) return; + while (watermark < buffer.length) { + const message = buffer[watermark]; + watermark++; + sink({ event: message }); + } + }; + const shift = (count: number): void => { + watermark = Math.max(0, watermark - count); + }; + + const handle = { drain, shift }; + views.add(handle); + + const session: LiveEventSession = { + get size() { + return buffer.length; + }, + + get truncatedBefore() { + return evictedBefore; + }, + + /** + * Move this view's watermark. 'live' means the tail — everything + * already observed counts as delivered. A numeric position rewinds + * to the first retained event at-or-after it, which is as far back + * as a live-only session can honestly go. + */ + async seek(pos: number | 'live'): Promise { + if (pos === 'live') { + watermark = buffer.length; + return; + } + let index = 0; + while (index < buffer.length && eventTimeOf(buffer[index]) < pos) index++; + watermark = index; + }, + + /** + * The most recent status snapshot, or null when none has arrived — + * the hook falls back to its own seed in that case. + */ + async getStatus(): Promise | null> { + for (let index = buffer.length - 1; index >= 0; index--) { + const message = buffer[index]; + if (message.event === 'apaevt_status' || message.event === 'apaevt_status_update') { + return (message.body ?? null) as Record | null; + } + } + return null; + }, + + /** + * Register the sink and drain. Speed is ignored: live events are + * already paced by the wall clock and there is no recorded span to + * replay at a multiple of it. The sink stays registered afterwards + * so later arrivals reach the fold immediately rather than waiting + * for the hook's next nudge. + */ + async play(pos: number | 'live' | undefined, _speed: number, cb: Sink): Promise { + if (pos !== undefined) await session.seek(pos); + sink = cb; + drain(); + }, + + /** + * One trace's events. Identity is the BEGIN event's continuum seq, + * the same rule the server-backed session uses, so a `traceId` taken + * from a rendered row resolves here unchanged. + */ + async getTrace(traceId: number): Promise<{ events: TaskEventMessage[] }> { + const beginIndex = buffer.findIndex( + (message) => message.event === 'apaevt_flow' && message.body?.op === 'begin' && seqOf(message) === traceId, + ); + if (beginIndex < 0) return { events: [] }; + + const pipe = buffer[beginIndex].body?.id; + const events: TaskEventMessage[] = []; + for (let index = beginIndex; index < buffer.length; index++) { + const message = buffer[index]; + if (message.event !== 'apaevt_flow' || message.body?.id !== pipe) continue; + // A later `begin` on the same pipe slot is the NEXT request + // reusing it — the pipe id is a slot, not an identity. + if (index > beginIndex && message.body?.op === 'begin') break; + events.push(message); + if (message.body?.op === 'end') break; + } + return { events }; + }, + + /** Stop delivering to this view; `play` resumes from the watermark. */ + pause(): void { + sink = null; + }, + + /** + * Buffer one live event. Ingest is store-wide: every open view sees + * it, which is what keeps an export walker consistent with the pane. + */ + ingestLive(message: TaskEventMessage): void { + if (!attached) return; + ingest(message); + }, + + /** + * Detach this view. The store's buffer survives so sibling views + * (an in-flight export) keep working; the store itself is dropped + * by whoever owns the stream identity. + */ + closeEventStream(): void { + sink = null; + attached = false; + views.delete(handle); + }, + }; + + return session; + }; + + return { + open, + get size() { + return buffer.length; + }, + }; +} + +/** Convenience for a store with exactly one view — used by tests. */ +export function createLiveEventSession(): LiveEventSession { + return createLiveEventStore().open(); +} From c4fb63fd707d6d804353302247307ab413749714 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Wed, 29 Jul 2026 14:48:25 -0700 Subject: [PATCH 09/12] fix(test): stop node packages being shadowed by same-named test dirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodes/test is a package whose subdirectories are named after node packages (text_output/, response/, image_cleanup/, telegram/, tool_git/, ...). Two places put that directory on sys.path so they could bare-import a test-root module: conftest (for _sys_modules_guard) and cloud_tts/test_cloud_tts (for test_contracts). Once nodes/test sits ahead of src/nodes, `import text_output` resolves to the *test* package, whose __path__ holds no node module — so `from text_output.instance import Instance` dies with ModuleNotFoundError. Whether it bites was pure collection order. The affected modules bootstrap with "insert src/nodes only if absent", which never re-prioritises: previously nothing seeded src/nodes before cloud_tts (c) inserted nodes/test, so image_cleanup (i) was the first to insert src/nodes and landed it in front. The new agent_crewai tests seed src/nodes during collection of agent_crewai (a) instead, so cloud_tts now stacks nodes/test on top of it and the guard short-circuits in all four later modules — four collection errors, none of them in code this branch touches. Import both test-root modules package-relative and drop the sys.path inserts, so nodes/test never shadows a node package. cloud_tts gains the __init__.py the rest of the node-named test dirs already have, which is what makes the relative import resolvable. The four bootstraps now move src/nodes to the front unconditionally rather than skipping when it is already somewhere on the path, and the guard suite pins both invariants: nodes/test stays off sys.path, and a node package imported by name resolves under src/nodes. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/test/cloud_tts/__init__.py | 0 nodes/test/cloud_tts/test_cloud_tts.py | 7 ++-- nodes/test/conftest.py | 9 +++-- nodes/test/image_cleanup/test_provenance.py | 7 +++- nodes/test/local_text_output/test_instance.py | 7 +++- nodes/test/response/test_media_metadata.py | 7 +++- nodes/test/test_sys_modules_guard.py | 37 ++++++++++++++++++- nodes/test/text_output/test_instance.py | 7 +++- 8 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 nodes/test/cloud_tts/__init__.py diff --git a/nodes/test/cloud_tts/__init__.py b/nodes/test/cloud_tts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nodes/test/cloud_tts/test_cloud_tts.py b/nodes/test/cloud_tts/test_cloud_tts.py index 6e0a6c341..ecff3fab1 100644 --- a/nodes/test/cloud_tts/test_cloud_tts.py +++ b/nodes/test/cloud_tts/test_cloud_tts.py @@ -21,9 +21,10 @@ _DIR = Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'cloud_tts' -# Reuse the contract-test JSONC parser (handles // comments and :// URLs). -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from test_contracts import parse_service_json # noqa: E402 +# Reuse the contract-test JSONC parser (handles // comments and :// URLs). Imported +# package-relative: putting nodes/test on sys.path would let its node-named subpackages +# shadow the real node packages under src/nodes (see #1687). +from ..test_contracts import parse_service_json _SERVICES = ['services.tts_openai.json', 'services.tts_elevenlabs.json'] diff --git a/nodes/test/conftest.py b/nodes/test/conftest.py index da5892f90..2e4301573 100644 --- a/nodes/test/conftest.py +++ b/nodes/test/conftest.py @@ -51,10 +51,11 @@ # The sys.modules isolation guard (see #1640) lives in _sys_modules_guard so it is -# unit-testable in isolation. conftest is imported before pytest puts its own dir -# on sys.path, so add it here; importing the hooks registers them with pytest. -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from _sys_modules_guard import ( # noqa: E402,F401 +# unit-testable in isolation; importing the hooks registers them with pytest. +# Imported package-relative on purpose: putting this directory on sys.path would let +# its node-named subpackages (text_output/, response/, telegram/, ...) shadow the real +# node packages under src/nodes (see #1687). +from ._sys_modules_guard import ( # noqa: E402,F401 pytest_collectreport, pytest_collectstart, pytest_sessionfinish, diff --git a/nodes/test/image_cleanup/test_provenance.py b/nodes/test/image_cleanup/test_provenance.py index 85e670abd..0121815f4 100644 --- a/nodes/test/image_cleanup/test_provenance.py +++ b/nodes/test/image_cleanup/test_provenance.py @@ -25,8 +25,11 @@ from ai.common.avi.descriptor import build_stream_descriptor, descriptor_to_payload NODES_SRC = Path(__file__).parent.parent.parent / 'src' / 'nodes' -if str(NODES_SRC) not in sys.path: - sys.path.insert(0, str(NODES_SRC)) +# Move to the front rather than "insert only if absent": another test dir already on +# sys.path can hold a package with the same name as the node (see #1687). +while str(NODES_SRC) in sys.path: + sys.path.remove(str(NODES_SRC)) +sys.path.insert(0, str(NODES_SRC)) from image_cleanup.IInstance import IInstance # noqa: E402 diff --git a/nodes/test/local_text_output/test_instance.py b/nodes/test/local_text_output/test_instance.py index 0a5046a68..cd46af60c 100644 --- a/nodes/test/local_text_output/test_instance.py +++ b/nodes/test/local_text_output/test_instance.py @@ -12,8 +12,11 @@ from unittest.mock import Mock NODES_SRC = Path(__file__).parent.parent.parent / 'src' / 'nodes' -if str(NODES_SRC) not in sys.path: - sys.path.insert(0, str(NODES_SRC)) +# Move to the front rather than "insert only if absent": another test dir already on +# sys.path can hold a package with the same name as the node (see #1687). +while str(NODES_SRC) in sys.path: + sys.path.remove(str(NODES_SRC)) +sys.path.insert(0, str(NODES_SRC)) from local_text_output.IInstance import IInstance # noqa: E402 from rocketlib import extended_length_path # noqa: E402 diff --git a/nodes/test/response/test_media_metadata.py b/nodes/test/response/test_media_metadata.py index 7889c3d2c..dd3a707f3 100644 --- a/nodes/test/response/test_media_metadata.py +++ b/nodes/test/response/test_media_metadata.py @@ -19,8 +19,11 @@ from ai.common.avi.descriptor import build_stream_descriptor, descriptor_to_payload NODES_SRC = Path(__file__).parent.parent.parent / 'src' / 'nodes' -if str(NODES_SRC) not in sys.path: - sys.path.insert(0, str(NODES_SRC)) +# Move to the front rather than "insert only if absent": another test dir already on +# sys.path can hold a package with the same name as the node (see #1687). +while str(NODES_SRC) in sys.path: + sys.path.remove(str(NODES_SRC)) +sys.path.insert(0, str(NODES_SRC)) from response.IInstance import IInstance # noqa: E402 diff --git a/nodes/test/test_sys_modules_guard.py b/nodes/test/test_sys_modules_guard.py index 872f47cb3..bb676ca5b 100644 --- a/nodes/test/test_sys_modules_guard.py +++ b/nodes/test/test_sys_modules_guard.py @@ -14,10 +14,11 @@ import json import sys import types +from pathlib import Path import pytest -import _sys_modules_guard as guard +from . import _sys_modules_guard as guard class Module: @@ -90,6 +91,40 @@ def test_guard_blames_the_leaker_not_an_earlier_clean_module(): sys.modules['rocketlib'] = saved +def test_test_root_is_not_on_sys_path(): + """nodes/test must stay off sys.path (see #1687). + + Its subdirectories are named after node packages (text_output/, response/, + telegram/, tool_git/, ...). With nodes/test on sys.path ahead of src/nodes, + `import text_output` resolves to the *test* package, whose __path__ holds no + node module — so every `from text_output.instance import ...` dies with + ModuleNotFoundError. Import test-root helpers package-relative instead. + """ + test_root = str(Path(__file__).resolve().parent) + assert test_root not in sys.path, ( + f'{test_root} is on sys.path; it shadows node packages of the same name. ' + 'Import test-root modules as `from . import ...` instead of inserting the dir.' + ) + + +def test_node_packages_resolve_under_src_nodes(): + """A node package imported by name resolves to src/nodes, not the same-named test dir.""" + nodes_src = Path(__file__).resolve().parent.parent / 'src' / 'nodes' + while str(nodes_src) in sys.path: + sys.path.remove(str(nodes_src)) + sys.path.insert(0, str(nodes_src)) + + saved = sys.modules.pop('text_output', None) + try: + import text_output + + assert Path(text_output.__path__[0]).resolve() == (nodes_src / 'text_output').resolve() + finally: + sys.modules.pop('text_output', None) + if saved is not None: + sys.modules['text_output'] = saved + + def test_is_stub_module_detects_stub_built_via_module_from_spec(): """A stub with a __spec__ but no __file__ is still a stub (guard heuristic).""" spec = importlib.util.spec_from_loader('fake_core', loader=None) diff --git a/nodes/test/text_output/test_instance.py b/nodes/test/text_output/test_instance.py index 9d25930b8..c1476899f 100644 --- a/nodes/test/text_output/test_instance.py +++ b/nodes/test/text_output/test_instance.py @@ -20,8 +20,11 @@ import pytest NODES_SRC = Path(__file__).parent.parent.parent / 'src' / 'nodes' -if str(NODES_SRC) not in sys.path: - sys.path.insert(0, str(NODES_SRC)) +# Move to the front rather than "insert only if absent": another test dir already on +# sys.path can hold a package with the same name as the node (see #1687). +while str(NODES_SRC) in sys.path: + sys.path.remove(str(NODES_SRC)) +sys.path.insert(0, str(NODES_SRC)) from text_output.instance import Instance # noqa: E402 From 7b3cc5398e596d6e45bdf97a5110164bbb2ead0f Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Wed, 29 Jul 2026 18:19:17 -0700 Subject: [PATCH 10/12] fix(nodes, shared-ui): address CodeRabbit review on the tool_pipedrive PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security - pipedrive_client._company_host accepted anything up to the first slash, so a configured domain of "evil.example#" built https://evil.example#.pipedrive.com and Requests sent it — with the api_token or Authorization header attached — to evil.example. Cut at the first path/query/fragment delimiter, refuse userinfo and ports, lower-case, and require a single DNS label; anything else falls back to the generic host. - Free-form ids (lead uuids, permission-set ids, channel ids, goal ids, note comment ids) were f-stringed into request paths unencoded, so a value carrying "/", "..", "?" or "#" retargeted the call at another endpoint — for the DELETE tools, a destructive one. Added _base.path_segment() and applied it at all 20 string-id sites. - Pin requests>=2.34.2 to match constraints.lock, as tool_cognee/tool_github/ tool_n8n already do. Correctness - crewai_base._annotation_for passed prop['type'] straight to dict.get. JSON Schema spells a nullable field as ["string", "null"], and an unhashable key raises TypeError instead of returning the default — one such property aborted the whole _build_crew_tools loop. Non-str types now fall back to Any, which is what the docstring already promised. - deal_followers_users_list GET the same /deals/{id}/followers as deal_followers_list and only differed by running follower rows through clean_user, emitting a user-shaped object built from mismatched fields. Dropped it; resolving followers to users needs a /users/{id} call per follower. - add_time (deals, organizations, persons) and origin_id (leads) were in the write-key tuples but not in the matching schema props, so body_from could never populate them. Added the properties. - product_variation_update and both project-plan updates passed the builtin dict as the response cleaner: dict(None) raises TypeError on an empty 204 body. Switched to passthrough, as every other write already uses. - liveEventSession.getStatus scanned from the live edge, contradicting the documented "as of the session position" and the caller, which seeks to an anchor first — a repositioned view seeded a replayed run with the current status. Scan back from the watermark. Maintainability - Eight bulk deletes each repeated the ids guard, the CSV build and a trailing _require_write(), and left ids optional so a missing argument surfaced as a hand-rolled ValueError. Added _base._delete_bulk (write gate first), marked ids required in every schema, and delegated all eight. - projects._cursor_list re-implemented paging_params_v2 and the v2 envelope shaping; it now uses the shared helpers, so the cursor contract has one implementation instead of two that can drift. - file_list advertised the shared 1-500 range, but /files documents a maximum limit of 100. PAGING/paging_params/_list take a max_limit override and file_list passes 100. - _build_crew_tools' docstring still claimed the JSON Schema is embedded in the tool description, which stopped being true when args_schema took over. - Tests: 'ACME'.lower() evaluated to 'acme' before the call, so the case path was never exercised; test_tools imported pipedrive_client bare, giving the module a second identity in a run that also collects test_pipedrive; a vacuous b64encode assert, a missing empty-pipelines guard, and a hard-coded field_id of 1 that fails rather than skips on a fresh sandbox. Two findings were not applied. `extra` is not dropped by fields/misc — body_from merges args['extra'] by default (tools/_base.py). And the @patch/parametrize signatures are already correct: mock injects positionally innermost-first while pytest passes parametrize values by keyword, so the suggested reorder would bind the mocks to the parametrized names. New tests cover the rejected domains, path-segment encoding, non-str schema types, the write-gate ordering, and the watermark-bounded status. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/agent_crewai/crewai_base.py | 16 ++++-- nodes/src/nodes/tool_pipedrive/README.md | 18 +++---- .../nodes/tool_pipedrive/pipedrive_client.py | 26 +++++++--- .../src/nodes/tool_pipedrive/requirements.txt | 2 +- nodes/src/nodes/tool_pipedrive/services.json | 6 +-- nodes/src/nodes/tool_pipedrive/tool_groups.py | 4 +- nodes/src/nodes/tool_pipedrive/tools/_base.py | 45 ++++++++++++++--- .../nodes/tool_pipedrive/tools/activities.py | 20 ++------ .../nodes/tool_pipedrive/tools/call_logs.py | 7 +-- nodes/src/nodes/tool_pipedrive/tools/deals.py | 27 +++------- .../src/nodes/tool_pipedrive/tools/fields.py | 8 +-- nodes/src/nodes/tool_pipedrive/tools/files.py | 9 +++- .../src/nodes/tool_pipedrive/tools/filters.py | 10 +--- nodes/src/nodes/tool_pipedrive/tools/goals.py | 7 +-- nodes/src/nodes/tool_pipedrive/tools/leads.py | 16 +++--- nodes/src/nodes/tool_pipedrive/tools/misc.py | 7 +-- nodes/src/nodes/tool_pipedrive/tools/notes.py | 9 ++-- .../tool_pipedrive/tools/organizations.py | 11 ++-- .../src/nodes/tool_pipedrive/tools/persons.py | 11 ++-- .../nodes/tool_pipedrive/tools/pipelines.py | 10 +--- .../nodes/tool_pipedrive/tools/products.py | 2 +- .../nodes/tool_pipedrive/tools/projects.py | 40 +++++++-------- nodes/src/nodes/tool_pipedrive/tools/roles.py | 5 +- nodes/test/agent_crewai/test_tool_schema.py | 12 +++++ nodes/test/tool_pipedrive/test_pipedrive.py | 50 ++++++++++++++++++- nodes/test/tool_pipedrive/test_tools.py | 30 +++++++++-- .../project/hooks/liveEventSession.test.ts | 20 +++++++- .../modules/project/hooks/liveEventSession.ts | 10 ++-- 28 files changed, 277 insertions(+), 161 deletions(-) diff --git a/nodes/src/nodes/agent_crewai/crewai_base.py b/nodes/src/nodes/agent_crewai/crewai_base.py index fe01af4e0..44f97b206 100644 --- a/nodes/src/nodes/agent_crewai/crewai_base.py +++ b/nodes/src/nodes/agent_crewai/crewai_base.py @@ -324,9 +324,10 @@ def _build_crew_tools( """ Convert host tool descriptors into CrewAI BaseTool instances. - Each tool's JSON Schema is embedded in the description so CrewAI can - pass structured arguments. A dynamic Pydantic args_schema is built per - tool to preserve real parameter names through CrewAI's argument filter. + A dynamic Pydantic args_schema is built per tool to preserve real + parameter names through CrewAI's argument filter. The JSON Schema is not + copied into the description: CrewAI renders args_schema into the prompt + itself, so a second copy would give the model two contracts to reconcile. The inner `HostTool` subclass captures `self` and `context` via closure on this method so its `_run` / `_arun` methods can call @@ -366,8 +367,15 @@ def _annotation_for(prop: Dict[str, Any]) -> Any: `Any` is the deliberate fallback for unions, `null`, and anything the map does not cover — a wrong guess here would reject valid tool calls, which is worse than leaving the field untyped. + + The non-`str` check is what makes that fallback reachable for unions: + JSON Schema spells a nullable field as `"type": ["string", "null"]`, and + handing that list to `dict.get` raises TypeError on the unhashable key + rather than returning the default — one nullable property would abort the + whole `_build_crew_tools` loop. """ - return _JSON_TYPE_MAP.get(prop.get('type'), Any) + prop_type = prop.get('type') + return _JSON_TYPE_MAP.get(prop_type, Any) if isinstance(prop_type, str) else Any def _make_args_schema(input_schema: Optional[Dict[str, Any]]) -> type[BaseModel]: """ diff --git a/nodes/src/nodes/tool_pipedrive/README.md b/nodes/src/nodes/tool_pipedrive/README.md index cd3876546..9f2377de6 100644 --- a/nodes/src/nodes/tool_pipedrive/README.md +++ b/nodes/src/nodes/tool_pipedrive/README.md @@ -6,7 +6,7 @@ A RocketRide tool node that exposes the Pipedrive CRM to an AI agent. Gives an agent the full Pipedrive REST API v1: deals, persons, organizations, activities, pipelines, stages, notes, leads, products, files, users, roles, teams, -goals, filters, webhooks, subscriptions, mail, call logs and projects — 256 tools in +goals, filters, webhooks, subscriptions, mail, call logs and projects — 255 tools in all, plus a generic `request` tool for anything Pipedrive adds later. Useful for sales automation, lead triage, CRM reporting, and RAG pipelines that read from a CRM. @@ -35,14 +35,14 @@ mutating tool. ### Tool groups -Full v1 coverage is 256 tools. That is more than an LLM can choose between reliably, +Full v1 coverage is 255 tools. That is more than an LLM can choose between reliably, and more than some providers accept in one request, so the node only publishes the groups listed in **Tool groups**. The default eight groups publish 108 tools — the everyday CRM surface. Add group names to reach further, or use `all`. Available groups, with the number of tools each publishes: -`deals` (28), `projects` (22), `organizations` (18), `persons` (18), `products` (16), +`deals` (27), `projects` (22), `organizations` (18), `persons` (18), `products` (16), `activities` (13), `roles` (13), `leads` (12), `users` (12), `notes` (11), `subscriptions` (9), `files` (8), `misc` (8), `pipelines` (8), `teams` (8), `filters` (7), `stages` (7), `fields` (6), `mailbox` (6), `call_logs` (5), @@ -58,7 +58,7 @@ invoked anyway. ### Tool-count guard rail -Group sizes are uneven — `deals` is 28 tools, `permission_sets` is 3 — so counting +Group sizes are uneven — `deals` is 27 tools, `permission_sets` is 3 — so counting groups tells you little. The node counts **published tools** instead and warns when a selection exceeds **120**, both in the config panel and at pipeline start: @@ -71,15 +71,16 @@ request. Drop a group, or use "all" if this is deliberate. It is a warning, not a block: the tools are still published and the pipeline still runs. Silently dropping tools an operator asked for fails later and more confusingly than a noisy config panel. `all` is exempt, since it is already an -explicit opt-in to the full 256-tool surface and suits scripted callers that do not +explicit opt-in to the full 255-tool surface and suits scripted callers that do not route through an LLM. ### Pagination List tools take `start` (offset, default 0) and `limit` (1-500, default 100), and return `{items, count, more_items_in_collection, next_start}`. Pass `next_start` back -as `start` for the next page. The project tools use Pipedrive's cursor pagination -instead: pass `cursor` and read `next_cursor`. +as `start` for the next page. `file_list` is the exception: `/files` documents a +maximum `limit` of 100, so that tool advertises and clamps to 100. The project tools +use Pipedrive's cursor pagination instead: pass `cursor` and read `next_cursor`. ### Custom fields @@ -153,7 +154,7 @@ Tools are published as `pipedrive.`. | `call_log_list` | List the call logs of the authenticated user. | | `call_log_recording_add` | Attach an audio recording to a call log. | -#### `deals` — 28 tools +#### `deals` — 27 tools | Tool | Description | |---|---| @@ -166,7 +167,6 @@ Tools are published as `pipedrive.`. | `deal_follower_add` | Add a follower to a deal. | | `deal_follower_delete` | Remove a follower from a deal. | | `deal_followers_list` | List followers of a deal. | -| `deal_followers_users_list` | List users who follow a deal, resolved to full user records. | | `deal_get` | Get a single deal by id, including its custom fields. | | `deal_list` | List deals, optionally filtered by owner, stage, status or a saved filter. | | `deal_mail_messages_list` | List email messages associated with a deal. | diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py index 2fcde0e61..55c4525df 100644 --- a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py +++ b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py @@ -162,24 +162,38 @@ def _auth(token: str) -> tuple[dict, dict]: return {}, {'api_token': token} +#: A company subdomain is a single DNS label — letters, digits and inner hyphens. +_SUBDOMAIN_RE = re.compile(r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$') + + def _company_host(company_domain: str | None) -> str | None: """Reduce a configured company domain to its bare subdomain. Accepts ``acme``, ``acme.pipedrive.com`` or ``https://acme.pipedrive.com/`` - and returns ``acme``. Returns ``None`` when nothing usable was configured, so - callers fall back to the generic host. + (in any case) and returns ``acme``. Returns ``None`` when nothing usable was + configured, so callers fall back to the generic host. + + The result is interpolated into ``https://{host}.pipedrive.com``, so anything + that could end the host early has to be rejected rather than passed through: + ``evil.example#`` would otherwise build ``https://evil.example#.pipedrive.com`` + and send the request — carrying the ``api_token`` or ``Authorization`` that + :func:`call_envelope` attaches — to ``evil.example``. Cut at the first path, + query or fragment delimiter, refuse userinfo and ports, and require what + remains to be a single DNS label. Shared by both version-specific builders below: the v1 and v2 base URLs must always agree about which company they address. """ - domain = (company_domain or '').strip() + domain = (company_domain or '').strip().lower() if not domain: return None - domain = re.sub(r'^https?://', '', domain).strip('/') - domain = domain.split('/')[0] + domain = re.sub(r'^https?://', '', domain) + domain = re.split(r'[/?#]', domain, maxsplit=1)[0] + if '@' in domain or ':' in domain: + return None if domain.endswith('.pipedrive.com'): domain = domain[: -len('.pipedrive.com')] - return domain or None + return domain if _SUBDOMAIN_RE.match(domain) else None def base_url_for(company_domain: str | None) -> str: diff --git a/nodes/src/nodes/tool_pipedrive/requirements.txt b/nodes/src/nodes/tool_pipedrive/requirements.txt index 365ee776b..b4a26e17e 100644 --- a/nodes/src/nodes/tool_pipedrive/requirements.txt +++ b/nodes/src/nodes/tool_pipedrive/requirements.txt @@ -1,2 +1,2 @@ -requests +requests>=2.34.2 tenacity diff --git a/nodes/src/nodes/tool_pipedrive/services.json b/nodes/src/nodes/tool_pipedrive/services.json index 3bc9f97ea..c7c12fe2f 100644 --- a/nodes/src/nodes/tool_pipedrive/services.json +++ b/nodes/src/nodes/tool_pipedrive/services.json @@ -56,12 +56,12 @@ "pipedrive.toolGroups": { "type": "array", "title": "Tool groups", - "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is 256 tools, which is more than an LLM can choose between reliably, so only the selected groups are exposed. Pick one or more from the list; tool counts are shown per group, and selections totalling more than 120 log a warning but still run. Selecting All groups publishes everything and skips that warning.", + "description": "Which groups of Pipedrive tools this node publishes to the agent. The full API is 255 tools, which is more than an LLM can choose between reliably, so only the selected groups are exposed. Pick one or more from the list; tool counts are shown per group, and selections totalling more than 120 log a warning but still run. Selecting All groups publishes everything and skips that warning.", "uniqueItems": true, "items": { "type": "string", "enum": [ - ["deals", "Deals (28)"], + ["deals", "Deals (27)"], ["persons", "Persons (18)"], ["organizations", "Organizations (18)"], ["activities", "Activities (13)"], @@ -86,7 +86,7 @@ ["teams", "Teams (8)"], ["users", "Users (12)"], ["webhooks", "Webhooks (3)"], - ["all", "All groups (256) - skips the size warning"] + ["all", "All groups (255) - skips the size warning"] ] }, "default": ["deals", "persons", "organizations", "activities", "pipelines", "stages", "notes", "search"] diff --git a/nodes/src/nodes/tool_pipedrive/tool_groups.py b/nodes/src/nodes/tool_pipedrive/tool_groups.py index f88b74a2b..8746932b8 100644 --- a/nodes/src/nodes/tool_pipedrive/tool_groups.py +++ b/nodes/src/nodes/tool_pipedrive/tool_groups.py @@ -26,13 +26,13 @@ """ Tool grouping for the Pipedrive node. -Full v1 coverage is 256 tools, which is far more than an LLM can choose between +Full v1 coverage is 255 tools, which is far more than an LLM can choose between reliably. Every tool is therefore tagged with a resource group, and the node only publishes the groups named in the ``pipedrive.toolGroups`` config field. The filtering happens in ``IInstance._collect_tool_methods()``, so a group that is not published is invisible to ``tool.query`` and rejected by ``tool.invoke`` alike. -Group sizes are very uneven (``deals`` is 28 tools, ``permission_sets`` is 3), so +Group sizes are very uneven (``deals`` is 27 tools, ``permission_sets`` is 3), so the guard rail counts published *tools* rather than groups. Crossing it only warns: scripted callers legitimately want a wide surface, and silently dropping tools an operator asked for is worse than a noisy config panel. diff --git a/nodes/src/nodes/tool_pipedrive/tools/_base.py b/nodes/src/nodes/tool_pipedrive/tools/_base.py index 080154a87..a1afe4d80 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/_base.py +++ b/nodes/src/nodes/tool_pipedrive/tools/_base.py @@ -34,6 +34,7 @@ from __future__ import annotations from typing import Any, Iterable +from urllib.parse import quote from ai.common.utils import normalize_tool_input, require_int, require_str @@ -105,10 +106,13 @@ def EXTRA() -> dict: #: Offset-pagination properties, spread into list-tool schemas. -def PAGING() -> dict: +def PAGING(max_limit: int = MAX_LIMIT) -> dict: + """Offset-paging schema. ``max_limit`` overrides the ceiling for endpoints + that document a smaller one than the shared default (``/files`` caps at 100). + """ return { 'start': INT('Pagination offset, 0-based (default 0). Pass the next_start value from a previous call.'), - 'limit': INT(f'Number of records to return (1-{MAX_LIMIT}, default 100).'), + 'limit': INT(f'Number of records to return (1-{max_limit}, default 100).'), } @@ -170,9 +174,9 @@ def _call_envelope_v2(self, method: str, path: str, **kwargs: Any) -> Any: """ return call_envelope(self._token(), method, path, base_url=self._base_v2(), **kwargs) - def _list(self, path: str, args: dict, cleaner, *, extra: dict | None = None) -> dict: + def _list(self, path: str, args: dict, cleaner, *, extra: dict | None = None, max_limit: int = MAX_LIMIT) -> dict: """GET a collection with offset pagination and return cleaned items + cursor.""" - params = paging_params(args) + params = paging_params(args, max_limit) if extra: params.update(extra) envelope = self._call_envelope('GET', path, params=params) @@ -192,6 +196,23 @@ def _delete(self, path: str, *, params: dict | None = None) -> dict: data = self._call('DELETE', path, params=params) return {'deleted': True, 'data': data} + def _delete_bulk(self, path: str, args: dict, tool: str, *, extra_key: str | None = None) -> dict: + """Delete several records in one call: ``DELETE path?ids=1,2,3``. + + The write gate runs before the argument check on purpose — a read-only + node should say so rather than complain about arguments it would never + act on. ``ids`` is also marked required in each caller's schema, so the + guard here only catches a malformed list that passed validation. + """ + self._require_write() + ids = args.get('ids') + if not isinstance(ids, list) or not ids: + raise ValueError(f'{tool}: "ids" must be a non-empty array of ids') + params = {'ids': ','.join(str(int(i)) for i in ids)} + if extra_key and isinstance(args.get(extra_key), dict): + params.update(args[extra_key]) + return {'deleted': True, 'data': self._call('DELETE', path, params=params)} + # --------------------------------------------------------------------------- # Argument helpers @@ -203,13 +224,13 @@ def args_of(args: Any) -> dict: return normalize_tool_input(args, tool_name=TOOL_NAME) -def paging_params(args: dict) -> dict: +def paging_params(args: dict, max_limit: int = MAX_LIMIT) -> dict: """Clamp start/limit to what Pipedrive's v1 offset pagination accepts.""" params: dict = {} if args.get('start') is not None: params['start'] = max(0, int(args['start'])) if args.get('limit') is not None: - params['limit'] = max(1, min(int(args['limit']), MAX_LIMIT)) + params['limit'] = max(1, min(int(args['limit']), max_limit)) return params @@ -255,3 +276,15 @@ def require_id(args: dict, key: str, tool: str) -> int: def require_text(args: dict, key: str, tool: str) -> str: return require_str(args, key, tool_name=tool) + + +def path_segment(value: Any) -> str: + """URL-encode an agent-supplied id before it is interpolated into a path. + + Numeric ids are safe because :func:`require_id` coerces them, but uuids, + permission-set ids and channel ids arrive as free-form strings straight from + the model. A value carrying ``/``, ``..``, ``?`` or ``#`` would silently + retarget the request at a different Pipedrive endpoint — for the delete tools, + an unintended destructive one. + """ + return quote(str(value), safe='') diff --git a/nodes/src/nodes/tool_pipedrive/tools/activities.py b/nodes/src/nodes/tool_pipedrive/tools/activities.py index 6a630df38..4b81a875e 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/activities.py +++ b/nodes/src/nodes/tool_pipedrive/tools/activities.py @@ -170,17 +170,11 @@ def activity_delete(self, args): @pipedrive_tool( group='activities', - input_schema=schema(ids=ARR('Activity ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Activity ids to delete.', 'integer')), description='Delete multiple activities in one call.', ) def activity_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('activity_delete_bulk: "ids" must be a non-empty array of activity ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/activities', params=params)} + return self._delete_bulk('/activities', args_of(args), 'activity_delete_bulk') # -- activity types --------------------------------------------------- @@ -247,17 +241,11 @@ def activity_type_delete(self, args): @pipedrive_tool( group='activities', - input_schema=schema(ids=ARR('Activity type ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Activity type ids to delete.', 'integer')), description='Delete multiple activity types in one call.', ) def activity_type_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('activity_type_delete_bulk: "ids" must be a non-empty array of activity type ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/activityTypes', params=params)} + return self._delete_bulk('/activityTypes', args_of(args), 'activity_type_delete_bulk') # -- activity fields -------------------------------------------------- diff --git a/nodes/src/nodes/tool_pipedrive/tools/call_logs.py b/nodes/src/nodes/tool_pipedrive/tools/call_logs.py index 4b0757062..b1061f628 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/call_logs.py +++ b/nodes/src/nodes/tool_pipedrive/tools/call_logs.py @@ -39,6 +39,7 @@ PipedriveToolsBase, args_of, body_from, + path_segment, require_text, schema, ) @@ -81,7 +82,7 @@ def call_log_list(self, args): def call_log_get(self, args): args = args_of(args) log_id = require_text(args, 'call_log_id', 'call_log_get') - return self._get(f'/callLogs/{log_id}', clean_call_log) + return self._get(f'/callLogs/{path_segment(log_id)}', clean_call_log) @pipedrive_tool( group='call_logs', @@ -121,7 +122,7 @@ def call_log_create(self, args): def call_log_delete(self, args): args = args_of(args) log_id = require_text(args, 'call_log_id', 'call_log_delete') - return self._delete(f'/callLogs/{log_id}') + return self._delete(f'/callLogs/{path_segment(log_id)}') @pipedrive_tool( group='call_logs', @@ -144,4 +145,4 @@ def call_log_recording_add(self, args): except Exception as exc: raise ValueError('call_log_recording_add: "content_base64" is not valid base64') from exc files = {'file': (file_name, content)} - return self._call('POST', f'/callLogs/{log_id}/recordings', files=files) + return self._call('POST', f'/callLogs/{path_segment(log_id)}/recordings', files=files) diff --git a/nodes/src/nodes/tool_pipedrive/tools/deals.py b/nodes/src/nodes/tool_pipedrive/tools/deals.py index df0c7c561..49d2f2c60 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/deals.py +++ b/nodes/src/nodes/tool_pipedrive/tools/deals.py @@ -34,7 +34,6 @@ clean_mail_message, clean_person, clean_search_item, - clean_user, paginated, paginated_v2, ) @@ -95,6 +94,7 @@ 'lost_reason': STR('Free-text reason, only meaningful when status is "lost".'), 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), 'label': STR('Deal label id, or a comma-separated list of label ids.'), + 'add_time': STR('Creation timestamp, YYYY-MM-DD HH:MM:SS. Use to backdate an imported record.'), 'extra': EXTRA(), } @@ -555,31 +555,20 @@ def deal_product_delete(self, args): # -- misc ------------------------------------------------------------- - @pipedrive_tool( - group='deals', - input_schema=schema(required=['deal_id'], deal_id=INT('Deal id.'), **PAGING()), - description='List users who follow a deal, resolved to full user records.', - ) - def deal_followers_users_list(self, args): - args = args_of(args) - deal_id = require_id(args, 'deal_id', 'deal_followers_users_list') - return self._list(f'/deals/{deal_id}/followers', args, clean_user) + # There is no deal_followers_users_list tool: it would GET the same + # /deals/{id}/followers as deal_followers_list and only differ by running the + # rows through clean_user, which builds a user-shaped object out of follower + # fields. Resolving followers to users needs a /users/{id} call per follower — + # not worth another entry in an already large tool surface. @pipedrive_tool( group='deals', input_schema=schema( + required=['ids'], ids=ARR('Deal ids to delete.', 'integer'), extra=OBJ('Additional query parameters passed straight through.'), ), description='Delete multiple deals in one call.', ) def deal_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('deal_delete_bulk: "ids" must be a non-empty array of deal ids') - params = {'ids': ','.join(str(int(i)) for i in ids)} - if isinstance(args.get('extra'), dict): - params.update(args['extra']) - self._require_write() - return {'deleted': True, 'data': self._call('DELETE', '/deals', params=params)} + return self._delete_bulk('/deals', args_of(args), 'deal_delete_bulk', extra_key='extra') diff --git a/nodes/src/nodes/tool_pipedrive/tools/fields.py b/nodes/src/nodes/tool_pipedrive/tools/fields.py index 25363aea3..fe9f7909c 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/fields.py +++ b/nodes/src/nodes/tool_pipedrive/tools/fields.py @@ -161,10 +161,4 @@ def field_delete(self, args): ) def field_delete_bulk(self, args): args = args_of(args) - base = _path(args, 'field_delete_bulk') - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('field_delete_bulk: "ids" must be a non-empty array of field ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', base, params=params)} + return self._delete_bulk(_path(args, 'field_delete_bulk'), args, 'field_delete_bulk') diff --git a/nodes/src/nodes/tool_pipedrive/tools/files.py b/nodes/src/nodes/tool_pipedrive/tools/files.py index affc32e58..96ed50732 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/files.py +++ b/nodes/src/nodes/tool_pipedrive/tools/files.py @@ -57,17 +57,22 @@ } +#: /files documents a maximum ``limit`` of 100, below the 500 the other v1 +#: collections accept — asking for more is rejected rather than clamped. +_FILES_MAX_LIMIT = 100 + + class FilesMixin(PipedriveToolsBase): """Tools for the ``files`` group.""" @pipedrive_tool( group='files', - input_schema=schema(sort=STR('Sort clause, e.g. "update_time DESC".'), **PAGING()), + input_schema=schema(sort=STR('Sort clause, e.g. "update_time DESC".'), **PAGING(_FILES_MAX_LIMIT)), description='List files stored in Pipedrive.', ) def file_list(self, args): args = args_of(args) - return self._list('/files', args, clean_file, extra=params_from(args, ('sort',))) + return self._list('/files', args, clean_file, extra=params_from(args, ('sort',)), max_limit=_FILES_MAX_LIMIT) @pipedrive_tool( group='files', diff --git a/nodes/src/nodes/tool_pipedrive/tools/filters.py b/nodes/src/nodes/tool_pipedrive/tools/filters.py index 6a969856f..8a3382241 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/filters.py +++ b/nodes/src/nodes/tool_pipedrive/tools/filters.py @@ -121,17 +121,11 @@ def filter_delete(self, args): @pipedrive_tool( group='filters', - input_schema=schema(ids=ARR('Filter ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Filter ids to delete.', 'integer')), description='Delete multiple saved filters in one call.', ) def filter_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('filter_delete_bulk: "ids" must be a non-empty array of filter ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/filters', params=params)} + return self._delete_bulk('/filters', args_of(args), 'filter_delete_bulk') @pipedrive_tool( group='filters', diff --git a/nodes/src/nodes/tool_pipedrive/tools/goals.py b/nodes/src/nodes/tool_pipedrive/tools/goals.py index 63e900e13..7b3e603e1 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/goals.py +++ b/nodes/src/nodes/tool_pipedrive/tools/goals.py @@ -39,6 +39,7 @@ args_of, body_from, params_from, + path_segment, require_text, schema, ) @@ -120,7 +121,7 @@ def goal_create(self, args): def goal_update(self, args): args = args_of(args) goal_id = require_text(args, 'goal_id', 'goal_update') - return self._write('PUT', f'/goals/{goal_id}', clean_goal, body=body_from(args, _GOAL_WRITE_KEYS)) + return self._write('PUT', f'/goals/{path_segment(goal_id)}', clean_goal, body=body_from(args, _GOAL_WRITE_KEYS)) @pipedrive_tool( group='goals', @@ -129,7 +130,7 @@ def goal_update(self, args): ) def goal_delete(self, args): args = args_of(args) - return self._delete(f'/goals/{require_text(args, "goal_id", "goal_delete")}') + return self._delete(f'/goals/{path_segment(require_text(args, "goal_id", "goal_delete"))}') @pipedrive_tool( group='goals', @@ -148,4 +149,4 @@ def goal_results_get(self, args): 'period.start': require_text(args, 'period_start', 'goal_results_get'), 'period.end': require_text(args, 'period_end', 'goal_results_get'), } - return self._call('GET', f'/goals/{goal_id}/results', params=params) + return self._call('GET', f'/goals/{path_segment(goal_id)}/results', params=params) diff --git a/nodes/src/nodes/tool_pipedrive/tools/leads.py b/nodes/src/nodes/tool_pipedrive/tools/leads.py index db9f9b229..e2d85d9fc 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/leads.py +++ b/nodes/src/nodes/tool_pipedrive/tools/leads.py @@ -45,6 +45,7 @@ paging_params_v2, params_from, passthrough, + path_segment, require_text, schema, ) @@ -76,6 +77,7 @@ 'was_seen': BOOL('Whether the lead has been opened by a user.'), 'channel': INT('Marketing channel id the lead came from.'), 'channel_id': STR('Optional identifier within the marketing channel.'), + 'origin_id': STR('Free-text id of the system this lead originated from.'), 'extra': EXTRA(), } @@ -116,7 +118,7 @@ def lead_list(self, args): ) def lead_get(self, args): args = args_of(args) - return self._get(f'/leads/{require_text(args, "lead_id", "lead_get")}', clean_lead) + return self._get(f'/leads/{path_segment(require_text(args, "lead_id", "lead_get"))}', clean_lead) @pipedrive_tool( group='leads', @@ -166,7 +168,7 @@ def lead_update(self, args): args = args_of(args) lead_id = require_text(args, 'lead_id', 'lead_update') body = body_from(args, (*_LEAD_WRITE_KEYS, 'is_archived')) - return self._write('PATCH', f'/leads/{lead_id}', clean_lead, body=body) + return self._write('PATCH', f'/leads/{path_segment(lead_id)}', clean_lead, body=body) @pipedrive_tool( group='leads', @@ -175,7 +177,7 @@ def lead_update(self, args): ) def lead_delete(self, args): args = args_of(args) - return self._delete(f'/leads/{require_text(args, "lead_id", "lead_delete")}') + return self._delete(f'/leads/{path_segment(require_text(args, "lead_id", "lead_delete"))}') @pipedrive_tool( group='leads', @@ -185,7 +187,7 @@ def lead_delete(self, args): def lead_permitted_users_list(self, args): args = args_of(args) lead_id = require_text(args, 'lead_id', 'lead_permitted_users_list') - return {'user_ids': self._call('GET', f'/leads/{lead_id}/permittedUsers')} + return {'user_ids': self._call('GET', f'/leads/{path_segment(lead_id)}/permittedUsers')} # -- labels ----------------------------------------------------------- @@ -230,7 +232,9 @@ def lead_label_create(self, args): def lead_label_update(self, args): args = args_of(args) label_id = require_text(args, 'label_id', 'lead_label_update') - return self._write('PATCH', f'/leadLabels/{label_id}', passthrough, body=body_from(args, ('name', 'color'))) + return self._write( + 'PATCH', f'/leadLabels/{path_segment(label_id)}', passthrough, body=body_from(args, ('name', 'color')) + ) @pipedrive_tool( group='leads', @@ -239,7 +243,7 @@ def lead_label_update(self, args): ) def lead_label_delete(self, args): args = args_of(args) - return self._delete(f'/leadLabels/{require_text(args, "label_id", "lead_label_delete")}') + return self._delete(f'/leadLabels/{path_segment(require_text(args, "label_id", "lead_label_delete"))}') # -- sources ---------------------------------------------------------- diff --git a/nodes/src/nodes/tool_pipedrive/tools/misc.py b/nodes/src/nodes/tool_pipedrive/tools/misc.py index f66e4fd39..67004c03d 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/misc.py +++ b/nodes/src/nodes/tool_pipedrive/tools/misc.py @@ -39,6 +39,7 @@ body_from, params_from, passthrough, + path_segment, require_text, schema, ) @@ -93,7 +94,7 @@ def meeting_link_create(self, args): def meeting_link_delete(self, args): args = args_of(args) link_id = require_text(args, 'link_id', 'meeting_link_delete') - return self._delete(f'/meetings/userProviderLinks/{link_id}') + return self._delete(f'/meetings/userProviderLinks/{path_segment(link_id)}') @pipedrive_tool( group='misc', @@ -122,7 +123,7 @@ def channel_create(self, args): def channel_delete(self, args): args = args_of(args) channel_id = require_text(args, 'channel_id', 'channel_delete') - return self._delete(f'/channels/{channel_id}') + return self._delete(f'/channels/{path_segment(channel_id)}') @pipedrive_tool( group='misc', @@ -176,4 +177,4 @@ def channel_conversation_delete(self, args): args = args_of(args) channel_id = require_text(args, 'channel_id', 'channel_conversation_delete') conversation_id = require_text(args, 'conversation_id', 'channel_conversation_delete') - return self._delete(f'/channels/{channel_id}/conversations/{conversation_id}') + return self._delete(f'/channels/{path_segment(channel_id)}/conversations/{path_segment(conversation_id)}') diff --git a/nodes/src/nodes/tool_pipedrive/tools/notes.py b/nodes/src/nodes/tool_pipedrive/tools/notes.py index 4235c11f1..e64ae2e89 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/notes.py +++ b/nodes/src/nodes/tool_pipedrive/tools/notes.py @@ -39,6 +39,7 @@ body_from, params_from, passthrough, + path_segment, require_id, require_text, schema, @@ -164,7 +165,7 @@ def note_comment_get(self, args): args = args_of(args) note_id = require_id(args, 'note_id', 'note_comment_get') comment_id = require_text(args, 'comment_id', 'note_comment_get') - return self._call('GET', f'/notes/{note_id}/comments/{comment_id}') + return self._call('GET', f'/notes/{note_id}/comments/{path_segment(comment_id)}') @pipedrive_tool( group='notes', @@ -194,7 +195,9 @@ def note_comment_update(self, args): note_id = require_id(args, 'note_id', 'note_comment_update') comment_id = require_text(args, 'comment_id', 'note_comment_update') content = require_text(args, 'content', 'note_comment_update') - return self._write('PUT', f'/notes/{note_id}/comments/{comment_id}', passthrough, body={'content': content}) + return self._write( + 'PUT', f'/notes/{note_id}/comments/{path_segment(comment_id)}', passthrough, body={'content': content} + ) @pipedrive_tool( group='notes', @@ -207,7 +210,7 @@ def note_comment_delete(self, args): args = args_of(args) note_id = require_id(args, 'note_id', 'note_comment_delete') comment_id = require_text(args, 'comment_id', 'note_comment_delete') - return self._delete(f'/notes/{note_id}/comments/{comment_id}') + return self._delete(f'/notes/{note_id}/comments/{path_segment(comment_id)}') # -- fields ----------------------------------------------------------- diff --git a/nodes/src/nodes/tool_pipedrive/tools/organizations.py b/nodes/src/nodes/tool_pipedrive/tools/organizations.py index 7d9b13bc8..589d4467e 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/organizations.py +++ b/nodes/src/nodes/tool_pipedrive/tools/organizations.py @@ -68,6 +68,7 @@ 'address': STR('Postal address as a single line.'), 'label': INT('Organization label id.'), 'visible_to': ENUM('Visibility group id.', ['1', '3', '5', '7']), + 'add_time': STR('Creation timestamp, YYYY-MM-DD HH:MM:SS. Use to backdate an imported record.'), 'extra': EXTRA(), } @@ -173,17 +174,11 @@ def organization_merge(self, args): @pipedrive_tool( group='organizations', - input_schema=schema(ids=ARR('Organization ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Organization ids to delete.', 'integer')), description='Delete multiple organizations in one call.', ) def organization_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('organization_delete_bulk: "ids" must be a non-empty array of organization ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/organizations', params=params)} + return self._delete_bulk('/organizations', args_of(args), 'organization_delete_bulk') # -- related records -------------------------------------------------- diff --git a/nodes/src/nodes/tool_pipedrive/tools/persons.py b/nodes/src/nodes/tool_pipedrive/tools/persons.py index 5798f8fc8..eac0d0a8d 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/persons.py +++ b/nodes/src/nodes/tool_pipedrive/tools/persons.py @@ -88,6 +88,7 @@ 'marketing_status': ENUM( 'Consent status for marketing emails.', ['no_consent', 'unsubscribed', 'subscribed', 'archived'] ), + 'add_time': STR('Creation timestamp, YYYY-MM-DD HH:MM:SS. Use to backdate an imported record.'), 'extra': EXTRA(), } @@ -202,17 +203,11 @@ def person_merge(self, args): @pipedrive_tool( group='persons', - input_schema=schema(ids=ARR('Person ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Person ids to delete.', 'integer')), description='Delete multiple persons in one call.', ) def person_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('person_delete_bulk: "ids" must be a non-empty array of person ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/persons', params=params)} + return self._delete_bulk('/persons', args_of(args), 'person_delete_bulk') # -- related records -------------------------------------------------- diff --git a/nodes/src/nodes/tool_pipedrive/tools/pipelines.py b/nodes/src/nodes/tool_pipedrive/tools/pipelines.py index 73f5fcfa5..276c94f21 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/pipelines.py +++ b/nodes/src/nodes/tool_pipedrive/tools/pipelines.py @@ -274,17 +274,11 @@ def stage_delete(self, args): @pipedrive_tool( group='stages', - input_schema=schema(ids=ARR('Stage ids to delete.', 'integer')), + input_schema=schema(required=['ids'], ids=ARR('Stage ids to delete.', 'integer')), description='Delete multiple stages in one call.', ) def stage_delete_bulk(self, args): - args = args_of(args) - ids = args.get('ids') or [] - if not isinstance(ids, list) or not ids: - raise ValueError('stage_delete_bulk: "ids" must be a non-empty array of stage ids') - self._require_write() - params = {'ids': ','.join(str(int(i)) for i in ids)} - return {'deleted': True, 'data': self._call('DELETE', '/stages', params=params)} + return self._delete_bulk('/stages', args_of(args), 'stage_delete_bulk') @pipedrive_tool( group='stages', diff --git a/nodes/src/nodes/tool_pipedrive/tools/products.py b/nodes/src/nodes/tool_pipedrive/tools/products.py index 442011c19..490cd1bdc 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/products.py +++ b/nodes/src/nodes/tool_pipedrive/tools/products.py @@ -307,7 +307,7 @@ def product_variation_update(self, args): return self._write( 'PUT', f'/products/{product_id}/variations/{variation_id}', - dict, + passthrough, body=body_from(args, ('name', 'prices')), ) diff --git a/nodes/src/nodes/tool_pipedrive/tools/projects.py b/nodes/src/nodes/tool_pipedrive/tools/projects.py index eb63d8b39..cbcdd05fc 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/projects.py +++ b/nodes/src/nodes/tool_pipedrive/tools/projects.py @@ -33,7 +33,7 @@ from __future__ import annotations -from ..pipedrive_client import MAX_LIMIT, clean_activity, clean_project, clean_task +from ..pipedrive_client import clean_activity, clean_project, clean_task, paginated_v2 from ..tool_groups import pipedrive_tool from ._base import ( ARR, @@ -41,10 +41,12 @@ ENUM, EXTRA, INT, + PAGING_V2, STR, PipedriveToolsBase, args_of, body_from, + paging_params_v2, params_from, passthrough, require_id, @@ -52,11 +54,6 @@ schema, ) -_CURSOR_PROPS = { - 'cursor': STR('Pagination cursor from a previous call. Omit for the first page.'), - 'limit': INT(f'Number of records to return (1-{MAX_LIMIT}, default 100).'), -} - _PROJECT_WRITE_KEYS = ( 'title', 'board_id', @@ -116,29 +113,26 @@ class ProjectsMixin(PipedriveToolsBase): """Tools for the ``projects`` group.""" def _cursor_list(self, path: str, args: dict, cleaner, *, extra: dict | None = None) -> dict: - params: dict = {} - if args.get('cursor'): - params['cursor'] = args['cursor'] - if args.get('limit') is not None: - params['limit'] = max(1, min(int(args['limit']), MAX_LIMIT)) + """GET a cursor-paged collection. Same contract as the v2 search tools. + + The project endpoints are v1 routes but page by opaque cursor rather than + offset, so they borrow the v2 paging vocabulary — one implementation of + the cursor contract, not two that can drift over clamping or cursor + trimming. + """ + params = paging_params_v2(args) if extra: params.update(extra) envelope = self._call_envelope('GET', path, params=params) data = envelope.get('data') if isinstance(envelope, dict) else None - items = [cleaner(item) for item in (data or [])] - additional = (envelope.get('additional_data') or {}) if isinstance(envelope, dict) else {} - return { - 'items': items, - 'count': len(items), - 'next_cursor': additional.get('next_cursor'), - } + return paginated_v2(envelope, [cleaner(item) for item in (data or [])]) # -- projects --------------------------------------------------------- @pipedrive_tool( group='projects', input_schema=schema( - **_CURSOR_PROPS, + **PAGING_V2(), filter_id=INT('Apply a saved filter by id.'), status=STR('Comma-separated statuses to include: open, completed, canceled, deleted.'), phase_id=INT('Only projects in this phase.'), @@ -234,7 +228,7 @@ def project_plan_activity_update(self, args): return self._write( 'PUT', f'/projects/{project_id}/plan/activities/{activity_id}', - dict, + passthrough, body=body_from(args, ('phase_id', 'group_id')), ) @@ -256,7 +250,7 @@ def project_plan_task_update(self, args): return self._write( 'PUT', f'/projects/{project_id}/plan/tasks/{task_id}', - dict, + passthrough, body=body_from(args, ('phase_id', 'group_id')), ) @@ -286,7 +280,7 @@ def project_activities_list(self, args): @pipedrive_tool( group='projects', - input_schema=schema(**_CURSOR_PROPS, project_id=INT('Only tasks of this project.')), + input_schema=schema(**PAGING_V2(), project_id=INT('Only tasks of this project.')), description='List project tasks.', ) def project_task_list(self, args): @@ -377,7 +371,7 @@ def project_phase_get(self, args): @pipedrive_tool( group='projects', - input_schema=schema(**_CURSOR_PROPS), + input_schema=schema(**PAGING_V2()), description='List project templates.', ) def project_template_list(self, args): diff --git a/nodes/src/nodes/tool_pipedrive/tools/roles.py b/nodes/src/nodes/tool_pipedrive/tools/roles.py index 5685ff9fc..20dc29a06 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/roles.py +++ b/nodes/src/nodes/tool_pipedrive/tools/roles.py @@ -39,6 +39,7 @@ body_from, params_from, passthrough, + path_segment, require_id, require_text, schema, @@ -234,7 +235,7 @@ def permission_set_list(self, args): def permission_set_get(self, args): args = args_of(args) set_id = require_text(args, 'permission_set_id', 'permission_set_get') - return self._call('GET', f'/permissionSets/{set_id}') + return self._call('GET', f'/permissionSets/{path_segment(set_id)}') @pipedrive_tool( group='permission_sets', @@ -244,4 +245,4 @@ def permission_set_get(self, args): def permission_set_assignments_list(self, args): args = args_of(args) set_id = require_text(args, 'permission_set_id', 'permission_set_assignments_list') - return self._list(f'/permissionSets/{set_id}/assignments', args, clean_user) + return self._list(f'/permissionSets/{path_segment(set_id)}/assignments', args, clean_user) diff --git a/nodes/test/agent_crewai/test_tool_schema.py b/nodes/test/agent_crewai/test_tool_schema.py index 96c2745a9..ec4e8c902 100644 --- a/nodes/test/agent_crewai/test_tool_schema.py +++ b/nodes/test/agent_crewai/test_tool_schema.py @@ -193,6 +193,18 @@ def test_missing_type_falls_back_to_any(self): schema = _schema_of({'type': 'object', 'required': ['x'], 'properties': {'x': {}}}) assert schema.model_validate({'x': 'anything'}).x == 'anything' + @pytest.mark.parametrize('json_type', [['string', 'null'], ['integer', 'string'], 123, {'oneOf': []}]) + def test_non_string_type_falls_back_to_any(self, json_type): + """A JSON Schema union — `"type": ["string", "null"]` — is not a map key. + + Handing that list to dict.get raises TypeError on the unhashable key rather + than returning the default, so one nullable property would abort the whole + _build_crew_tools loop instead of leaving that field untyped. + """ + schema = _schema_of({'type': 'object', 'required': ['x'], 'properties': {'x': {'type': json_type}}}) + assert schema.model_validate({'x': 'anything'}).x == 'anything' + assert schema.model_validate({'x': None}).x is None + def _is_typed(prop: dict) -> bool: """Whether a rendered property advertises a type to the model. diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py index 47ce5555a..a96043c2e 100644 --- a/nodes/test/tool_pipedrive/test_pipedrive.py +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -197,7 +197,7 @@ def test_default(self): @pytest.mark.parametrize( 'domain', - ['acme', 'acme.pipedrive.com', 'https://acme.pipedrive.com', 'https://acme.pipedrive.com/', 'ACME'.lower()], + ['acme', 'acme.pipedrive.com', 'https://acme.pipedrive.com', 'https://acme.pipedrive.com/', 'ACME', ' Acme '], ) def test_company_domain_forms(self, domain): assert base_url_for(domain) == 'https://acme.pipedrive.com/api/v1' @@ -205,6 +205,24 @@ def test_company_domain_forms(self, domain): def test_garbage_domain_falls_back(self): assert base_url_for('https://') == BASE_URL + @pytest.mark.parametrize( + 'domain', + [ + 'evil.example#', # fragment: requests would send this to evil.example + 'evil.example?', + 'evil.example/', + 'acme@evil.example', + 'acme:8080', + 'evil.example', # a company domain is one label, never a dotted host + 'acme_corp', + '-acme', + 'acme.pipedrive.com.evil.example', + ], + ) + def test_domain_that_could_retarget_the_request_falls_back(self, domain): + assert base_url_for(domain) == BASE_URL + assert base_url_v2_for(domain) == BASE_URL_V2 + class TestAuth: def test_personal_token_uses_query_param(self): @@ -602,6 +620,36 @@ def test_bulk_delete_is_blocked(self, mock_request): _instance(read_only=True).deal_delete_bulk({'ids': [1, 2]}) mock_request.assert_not_called() + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_bulk_delete_reports_read_only_before_argument_problems(self, mock_request): + """The write gate runs first: a read-only node has nothing to say about ids.""" + with pytest.raises(ValueError, match='read-only mode'): + _instance(read_only=True).deal_delete_bulk({}) + mock_request.assert_not_called() + + +# --------------------------------------------------------------------------- +# Path building +# --------------------------------------------------------------------------- + + +class TestPathSegments: + @pytest.mark.parametrize( + 'tool,args,expected', + [ + ('lead_get', {'lead_id': '../deals/1'}, '/leads/..%2Fdeals%2F1'), + ('call_log_get', {'call_log_id': 'a/b'}, '/callLogs/a%2Fb'), + ('permission_set_get', {'permission_set_id': 'x?y=1'}, '/permissionSets/x%3Fy%3D1'), + ('lead_get', {'lead_id': 'plain-uuid'}, '/leads/plain-uuid'), + ], + ) + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_string_ids_are_encoded_into_one_segment(self, mock_request, tool, args, expected): + """An agent-supplied id must not be able to re-target the request.""" + mock_request.return_value = _ok({'id': 1}) + getattr(_instance(), tool)(args) + assert mock_request.call_args[0][1] == f'{BASE_URL}{expected}' + # --------------------------------------------------------------------------- # The raw request escape hatch diff --git a/nodes/test/tool_pipedrive/test_tools.py b/nodes/test/tool_pipedrive/test_tools.py index bf8c3b1a9..c1221801d 100644 --- a/nodes/test/tool_pipedrive/test_tools.py +++ b/nodes/test/tool_pipedrive/test_tools.py @@ -24,8 +24,20 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'tool_pipedrive')) -from pipedrive_client import PipedriveAPIError, base_url_for, base_url_v2_for, call, call_envelope # noqa: E402 +# Import through the node package, as test_pipedrive.py does. Inserting the node +# directory itself would publish `pipedrive_client` under a second, generic module +# identity, so a run collecting both files would load the same source twice. +_NODES_SRC = str(Path(__file__).resolve().parents[2] / 'src' / 'nodes') +while _NODES_SRC in sys.path: + sys.path.remove(_NODES_SRC) +sys.path.insert(0, _NODES_SRC) +from tool_pipedrive.pipedrive_client import ( # noqa: E402 + PipedriveAPIError, + base_url_for, + base_url_v2_for, + call, + call_envelope, +) TOKEN = os.getenv('PIPEDRIVE_API_TOKEN', '') DOMAIN = os.getenv('PIPEDRIVE_COMPANY_DOMAIN', '') @@ -269,6 +281,7 @@ def test_pipeline_conversion_statistics(self): def test_pipeline_movement_statistics(self): pipelines = c('GET', '/pipelines') + assert pipelines, 'account has no pipelines' stats = c( 'GET', f'/pipelines/{pipelines[0]["id"]}/movement_statistics', @@ -393,7 +406,6 @@ def test_create_attach_delete(self, deal): @writes class TestFiles: def test_upload_download_delete(self, deal): - import base64 import io import requests @@ -412,7 +424,6 @@ def test_upload_download_delete(self, deal): try: downloaded = call(TOKEN, 'GET', f'/files/{file_id}/download', base_url=BASE, raw=True) assert downloaded == payload - assert base64.b64encode(downloaded) finally: c('DELETE', f'/files/{file_id}') @@ -420,12 +431,21 @@ def test_upload_download_delete(self, deal): @writes class TestFilters: def test_create_and_delete(self): + # Resolve a real deal field rather than assuming id 1 exists and is a + # status field — on a fresh sandbox that assumption fails as a client + # error instead of a skip. + fields = c('GET', '/dealFields') or [] + status_field = next((f for f in fields if f.get('key') == 'status'), None) + if status_field is None: + pytest.skip('account exposes no "status" deal field to filter on') conditions = { 'glue': 'and', 'conditions': [ { 'glue': 'and', - 'conditions': [{'object': 'deal', 'field_id': '1', 'operator': '=', 'value': 'open'}], + 'conditions': [ + {'object': 'deal', 'field_id': str(status_field['id']), 'operator': '=', 'value': 'open'} + ], }, {'glue': 'or', 'conditions': []}, ], diff --git a/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts b/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts index d868e9116..a325f241f 100644 --- a/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts +++ b/packages/shared-ui/src/modules/project/hooks/liveEventSession.test.ts @@ -132,16 +132,34 @@ test('getStatus returns null before any snapshot', async () => { assert.equal(await createLiveEventSession().getStatus(), null); }); -test('getStatus returns the most recent snapshot', async () => { +test('getStatus returns the most recent snapshot at the live edge', async () => { const session = createLiveEventSession(); session.ingestLive(evt('apaevt_status', { objects: 1 })); session.ingestLive(evt('apaevt_flow')); session.ingestLive(evt('apaevt_status_update', { objects: 7 })); + await session.seek('live'); const status = await session.getStatus(); assert.equal(status?.objects, 7); }); +test('getStatus is bounded by the session position, not the live edge', async () => { + const session = createLiveEventSession(); + const early = evt('apaevt_status', { objects: 1 }); + const later = evt('apaevt_status_update', { objects: 7 }); + session.ingestLive(early); + session.ingestLive(evt('apaevt_flow')); + session.ingestLive(later); + + // Rewind to just after the early snapshot: the status seeded for a replayed + // run must be the one in force back then, not the current one. + await session.seek(Number(later.body.eventTime)); + assert.equal((await session.getStatus())?.objects, 1); + + await session.seek(Number(early.body.eventTime)); + assert.equal(await session.getStatus(), null); +}); + // --- trace detail ------------------------------------------------------------ test('getTrace returns one request keyed by its begin seq', async () => { diff --git a/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts b/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts index 8e9645f6b..14a06275d 100644 --- a/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts +++ b/packages/shared-ui/src/modules/project/hooks/liveEventSession.ts @@ -156,11 +156,15 @@ export function createLiveEventStore(): LiveEventStore { }, /** - * The most recent status snapshot, or null when none has arrived — - * the hook falls back to its own seed in that case. + * The most recent status snapshot at-or-before this view's position, + * or null when none has arrived — the hook falls back to its own seed + * in that case. Scanning back from the watermark rather than the live + * edge is what makes it honour a `seek()` to an earlier anchor: the + * hook calls this straight after seeking, and the live-edge status + * would otherwise seed a replayed run with the current one. */ async getStatus(): Promise | null> { - for (let index = buffer.length - 1; index >= 0; index--) { + for (let index = Math.min(watermark, buffer.length) - 1; index >= 0; index--) { const message = buffer[index]; if (message.event === 'apaevt_status' || message.event === 'apaevt_status_update') { return (message.body ?? null) as Record | null; From 384742a17c12a6b8f413811696aa7ab89d98ded7 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Wed, 29 Jul 2026 18:54:01 -0700 Subject: [PATCH 11/12] fix(nodes): harden the tool_pipedrive path and bulk-delete helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the second CodeRabbit pass, both on helpers added in the previous commit. _delete_bulk merged the caller's `extra` pass-through after assigning the validated `ids`, so `deal_delete_bulk({'ids': [1], 'extra': {'ids': '2,3'}})` deleted 2 and 3 instead of 1. Merge `extra` first and let the validated CSV win. The hand-rolled version this replaced had the same ordering, so the flaw predates the helper. path_segment relied on quote() alone, but "." and ".." contain nothing quote escapes — `/notes/{id}/comments/..` survived encoding intact and addressed the parent resource. Reject those two segments instead of encoding them. Tests cover both: a dot-segment id raises before any request is made, and a pass-through "ids" no longer displaces the validated list while other extra parameters still reach the query string. Co-Authored-By: Claude Opus 5 (1M context) --- nodes/src/nodes/tool_pipedrive/tools/_base.py | 22 ++++++++++++---- nodes/test/tool_pipedrive/test_pipedrive.py | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/nodes/src/nodes/tool_pipedrive/tools/_base.py b/nodes/src/nodes/tool_pipedrive/tools/_base.py index a1afe4d80..f13b085b6 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/_base.py +++ b/nodes/src/nodes/tool_pipedrive/tools/_base.py @@ -203,14 +203,19 @@ def _delete_bulk(self, path: str, args: dict, tool: str, *, extra_key: str | Non node should say so rather than complain about arguments it would never act on. ``ids`` is also marked required in each caller's schema, so the guard here only catches a malformed list that passed validation. + + ``extra`` is merged *before* ``ids`` is assigned: a pass-through of + ``{'ids': '2,3'}`` must not quietly replace the list that was validated + and delete a different set of records. """ self._require_write() ids = args.get('ids') if not isinstance(ids, list) or not ids: raise ValueError(f'{tool}: "ids" must be a non-empty array of ids') - params = {'ids': ','.join(str(int(i)) for i in ids)} + params: dict = {} if extra_key and isinstance(args.get(extra_key), dict): params.update(args[extra_key]) + params['ids'] = ','.join(str(int(i)) for i in ids) return {'deleted': True, 'data': self._call('DELETE', path, params=params)} @@ -283,8 +288,15 @@ def path_segment(value: Any) -> str: Numeric ids are safe because :func:`require_id` coerces them, but uuids, permission-set ids and channel ids arrive as free-form strings straight from - the model. A value carrying ``/``, ``..``, ``?`` or ``#`` would silently - retarget the request at a different Pipedrive endpoint — for the delete tools, - an unintended destructive one. + the model. A value carrying ``/``, ``?`` or ``#`` would silently retarget the + request at a different Pipedrive endpoint — for the delete tools, an + unintended destructive one. + + ``.`` and ``..`` are rejected rather than encoded: they contain no character + :func:`quote` escapes, so ``/notes/{id}/comments/..`` would survive encoding + intact and resolve to the parent resource. """ - return quote(str(value), safe='') + segment = str(value) + if segment.strip() in ('.', '..'): + raise ValueError(f'invalid id {segment!r}: "." and ".." address another resource') + return quote(segment, safe='') diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py index a96043c2e..6f00f278b 100644 --- a/nodes/test/tool_pipedrive/test_pipedrive.py +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -650,6 +650,31 @@ def test_string_ids_are_encoded_into_one_segment(self, mock_request, tool, args, getattr(_instance(), tool)(args) assert mock_request.call_args[0][1] == f'{BASE_URL}{expected}' + @pytest.mark.parametrize('bad_id', ['.', '..', ' .. ']) + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_dot_segments_are_rejected_not_encoded(self, mock_request, bad_id): + """quote() escapes nothing in ".."; it would reach the API and walk up a level.""" + with pytest.raises(ValueError, match='another resource'): + _instance().lead_get({'lead_id': bad_id}) + mock_request.assert_not_called() + + +class TestBulkDeleteParams: + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_extra_cannot_override_the_validated_ids(self, mock_request): + """A pass-through "ids" must not replace the list that was validated.""" + mock_request.return_value = _ok({'success': True}) + _instance().deal_delete_bulk({'ids': [1], 'extra': {'ids': '2,3'}}) + assert mock_request.call_args[1]['params']['ids'] == '1' + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_extra_still_passes_other_params_through(self, mock_request): + mock_request.return_value = _ok({'success': True}) + _instance().deal_delete_bulk({'ids': [1, 2], 'extra': {'force': 1}}) + params = mock_request.call_args[1]['params'] + assert params['ids'] == '1,2' + assert params['force'] == 1 + # --------------------------------------------------------------------------- # The raw request escape hatch From cdc66fa40f5ebbb4ea562514f6694f7b049f93d3 Mon Sep 17 00:00:00 2001 From: Joshua Phillips Date: Thu, 30 Jul 2026 12:57:52 -0700 Subject: [PATCH 12/12] fix(nodes, ai): keep the Pipedrive token out of transport error text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The personal-token path puts the credential in the `api_token` query parameter, so requests bakes it into the URL. A transport-level failure (DNS, refused connection, TLS, timeout) raises a RequestException whose text carries that URL, and `raise ValueError(f'Pipedrive request failed: {exc}') from exc` copied the token verbatim into the error the agent framework surfaces and logs. The chained cause held the same URL, so scrubbing the message alone would still have leaked it through any printed traceback. Build the message from the exception type plus method and path — all secret-free, and the type still distinguishes ConnectTimeout from SSLError from ConnectionError — and raise `from None`. Verified this was the only such site: the JSON-decode path stringifies `resp.text[:200]` rather than the URL, `_raise_pipedrive_error` builds from parsed response fields, and the tenacity wrapper uses `reraise=True` so no RetryError repr is introduced. Three tests pin it, and all three fail against the previous code: the token appears in neither the message nor the formatted traceback (that half is what pins `from None`), and the message still names the failure and the call. Also: - notes' `add_time` was in _NOTE_WRITE_KEYS but not _NOTE_WRITE_PROPS, so body_from could never populate it — the same class fixed for deals, organizations, persons and leads in 7b3cc539. A scan of every *_WRITE_KEYS/*_WRITE_PROPS pair confirms notes was the last one. - ChatBase._chat had no direct test. flatten_content_blocks and extract_text are covered, but nothing pinned the wiring that routes the provider's content through them, which is the contract every non-streaming chat caller depends on. Co-Authored-By: Claude Opus 5 (1M context) --- .../nodes/tool_pipedrive/pipedrive_client.py | 9 +- nodes/src/nodes/tool_pipedrive/tools/notes.py | 1 + nodes/test/tool_pipedrive/test_pipedrive.py | 30 ++++++ .../ai/common/test_chat_return_contract.py | 94 +++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 packages/ai/tests/ai/common/test_chat_return_contract.py diff --git a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py index 55c4525df..4dc90e27a 100644 --- a/nodes/src/nodes/tool_pipedrive/pipedrive_client.py +++ b/nodes/src/nodes/tool_pipedrive/pipedrive_client.py @@ -289,7 +289,14 @@ def _attempt() -> Any: timeout=timeout, ) except requests.RequestException as exc: - raise ValueError(f'Pipedrive request failed: {exc}') from exc + # Never let this exception near the message. On the personal-token path + # _auth() puts the credential in the query string, so requests bakes it + # into the URL — and a RequestException stringifies to text carrying that + # URL. `from None` breaks the chain too: the cause holds the same URL and + # a printed traceback would re-expose it. The exception type still names + # the failure (ConnectTimeout / ConnectionError / SSLError / ReadTimeout), + # and method/path are secret-free — `path` carries no query string. + raise ValueError(f'Pipedrive request failed: {type(exc).__name__} on {method.upper()} {path}') from None if not resp.ok: if _is_rate_limited(resp): diff --git a/nodes/src/nodes/tool_pipedrive/tools/notes.py b/nodes/src/nodes/tool_pipedrive/tools/notes.py index e64ae2e89..aa490c4ee 100644 --- a/nodes/src/nodes/tool_pipedrive/tools/notes.py +++ b/nodes/src/nodes/tool_pipedrive/tools/notes.py @@ -68,6 +68,7 @@ 'lead_id': STR('Lead uuid the note is attached to.'), 'project_id': INT('Project the note is attached to.'), 'user_id': INT('Author user id. Defaults to the authenticated user.'), + 'add_time': STR('Creation timestamp, YYYY-MM-DD HH:MM:SS. Use to backdate an imported record.'), 'pinned_to_deal_flag': ENUM('Pin the note to the deal.', ['0', '1']), 'pinned_to_person_flag': ENUM('Pin the note to the person.', ['0', '1']), 'pinned_to_organization_flag': ENUM('Pin the note to the organization.', ['0', '1']), diff --git a/nodes/test/tool_pipedrive/test_pipedrive.py b/nodes/test/tool_pipedrive/test_pipedrive.py index 6f00f278b..ef740dd0f 100644 --- a/nodes/test/tool_pipedrive/test_pipedrive.py +++ b/nodes/test/tool_pipedrive/test_pipedrive.py @@ -11,6 +11,7 @@ import json import sys +import traceback import types from collections.abc import Iterator from contextlib import contextmanager @@ -351,6 +352,35 @@ def test_transport_failure_raises_value_error(self, mock_request): with pytest.raises(ValueError, match='Pipedrive request failed'): call(TEST_TOKEN, 'GET', '/deals') + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_transport_failure_names_the_failure_and_the_call(self, mock_request): + """The message has to stay useful once the exception text is dropped.""" + mock_request.side_effect = requests.ConnectTimeout('timed out') + with pytest.raises(ValueError) as exc: + call(TEST_TOKEN, 'GET', '/deals/1') + assert 'ConnectTimeout' in str(exc.value) + assert 'GET /deals/1' in str(exc.value) + + @patch('tool_pipedrive.pipedrive_client.requests.request') + def test_api_token_is_not_in_the_transport_error_or_its_traceback(self, mock_request): + """A personal token rides in the query string, so requests' exception text + carries it. Neither the raised message nor a printed traceback may repeat it. + """ + leaky_url = f'https://api.pipedrive.com/api/v1/deals?api_token={PERSONAL_TOKEN}' + mock_request.side_effect = requests.ConnectionError( + f"HTTPSConnectionPool(host='api.pipedrive.com', port=443): " + f'Max retries exceeded with url: {leaky_url} (Caused by NameResolutionError)' + ) + + with pytest.raises(ValueError) as exc: + call(PERSONAL_TOKEN, 'GET', '/deals') + + assert PERSONAL_TOKEN not in str(exc.value) + # `from None` is what this half pins: a chained cause would put the same URL + # back into any traceback the agent framework logs. + rendered = ''.join(traceback.format_exception(type(exc.value), exc.value, exc.value.__traceback__)) + assert PERSONAL_TOKEN not in rendered + # --------------------------------------------------------------------------- # Rate limiting diff --git a/packages/ai/tests/ai/common/test_chat_return_contract.py b/packages/ai/tests/ai/common/test_chat_return_contract.py new file mode 100644 index 000000000..6b46dae0c --- /dev/null +++ b/packages/ai/tests/ai/common/test_chat_return_contract.py @@ -0,0 +1,94 @@ +# ============================================================================= +# MIT License +# Copyright (c) 2026 Aparavi Software AG +# ============================================================================= + +""" +Unit tests for ``ChatBase._chat``'s return contract. + +``flatten_content_blocks`` is covered directly in +``tests/ai/common/utils/test_content_blocks.py``. What these pin is the wiring: +that ``_chat`` — the method every non-streaming chat caller reaches — routes the +provider's ``content`` through it and hands back a ``str``. + +That contract used to be ``return results.content``, which is a plain string on +OpenAI-style backends but a list of typed blocks on Anthropic with extended +thinking. Callers stringified whatever arrived, so the answer came back as the +blocks' repr with the raw reasoning and its base64 signatures inside it. + +``_chat`` is exercised unbound against a stub ``_llm`` — constructing a real +ChatBase needs a provider config and a live model, and neither is relevant to +the shape of what comes back. + +Run with:: + + pytest packages/ai/tests/ai/common/test_chat_return_contract.py -v +""" + +from __future__ import annotations + +from typing import Any + +from ai.common.chat import ChatBase + + +class _StubLLM: + """Stands in for the LangChain model: records the prompt, returns fixed content.""" + + def __init__(self, content: Any): + self._content = content + self.prompts: list[str] = [] + + def invoke(self, prompt: str, **_kwargs: Any) -> Any: + self.prompts.append(prompt) + return type('Result', (), {'content': self._content})() + + +def _chat_with(content: Any) -> tuple[str, _StubLLM]: + """Drive ChatBase._chat over a stub returning ``content``.""" + chat = ChatBase.__new__(ChatBase) + llm = _StubLLM(content) + chat._llm = llm + return ChatBase._chat(chat, 'what is 2+2?'), llm + + +class TestStringContent: + def test_plain_string_passes_through(self): + answer, llm = _chat_with('4') + assert answer == '4' + assert llm.prompts == ['what is 2+2?'] + + def test_return_is_always_a_string(self): + for content in ('4', [], None, [{'type': 'text', 'text': '4'}]): + assert isinstance(_chat_with(content)[0], str) + + +class TestTypedBlockContent: + """The Anthropic extended-thinking shape.""" + + def test_text_blocks_are_flattened_to_the_answer(self): + blocks = [ + {'type': 'thinking', 'thinking': 'Let me work through this.', 'signature': 'ErUBCkYIBR=='}, + {'type': 'text', 'text': '4'}, + ] + assert _chat_with(blocks)[0] == '4' + + def test_reasoning_never_reaches_the_caller(self): + blocks = [ + {'type': 'thinking', 'thinking': 'SECRET-CHAIN-OF-THOUGHT'}, + {'type': 'text', 'text': 'the answer'}, + ] + answer = _chat_with(blocks)[0] + assert 'SECRET-CHAIN-OF-THOUGHT' not in answer + assert answer == 'the answer' + + def test_block_repr_never_reaches_the_caller(self): + """The regression: the list arriving verbatim, stringified downstream.""" + answer = _chat_with([{'type': 'text', 'text': 'hello'}])[0] + assert answer == 'hello' + for artefact in ("'type'", "'text'", '[{', '}]'): + assert artefact not in answer + + def test_multiple_text_blocks_concatenate_in_order(self): + blocks = [{'type': 'text', 'text': 'one '}, {'type': 'text', 'text': 'two'}] + assert _chat_with(blocks)[0] == 'one two'