diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7ffe6dc..c5ea7d2eb 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_gohighlevel` node**: exposes the GoHighLevel (LeadConnector) v2 REST API to agents as 101 tools across 17 groups (contacts, notes, tasks, opportunities, pipelines, conversations, messages, calendars, appointments, custom fields, custom values, tags, businesses, locations, users), plus a generic `request` escape hatch. Authenticates with a sub-account Private Integration Token, publishes a 74-tool default group set, and can hide every write tool in read-only mode (#1676) + ## [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..f38b3176b 100644 --- a/docs/README-nodes.md +++ b/docs/README-nodes.md @@ -120,6 +120,7 @@ channel; they have no data lanes and **bind to an agent** (see | `tool_oura` | Oura Ring health data (sleep, readiness, activity, heart rate), read-only | | `tool_xtrace_memory`| Long-term shared agent memory, backed by xTrace Memory Manager | | `tool_mem0` | Long-term shared agent memory, backed by the hosted Mem0 Platform | +| `tool_gohighlevel` | GoHighLevel (LeadConnector) v2 sub-account operations: contacts, opportunities, conversations, calendars | ### Embeddings diff --git a/nodes/src/nodes/tool_gohighlevel/IGlobal.py b/nodes/src/nodes/tool_gohighlevel/IGlobal.py new file mode 100644 index 000000000..4f128323b --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/IGlobal.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. +# ============================================================================= + +""" +GoHighLevel tool node - global (shared) state. + +Reads the sub-account Private Integration Token, the location id it is scoped to, +the read-only flag, the 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 .tool_groups import DEFAULT_GROUPS, normalize_groups, unknown_groups + +#: Every Private Integration Token observed so far carries this prefix. It is not a +#: documented guarantee, so a token without it is warned about and still used. +TOKEN_PREFIX = 'pit-' + + +class IGlobal(IGlobalBase): + """Global state for tool_gohighlevel.""" + + token: str = '' + location_id: str = '' + read_only: bool = False + tool_groups: frozenset = DEFAULT_GROUPS + allow_raw_request: bool = True + + def beginGlobal(self) -> None: + """Read the node config and fail early when the node cannot reach an account. + + Raises: + Exception: If the token or the location id is missing. Both are required: + the token is opaque, so the location cannot be derived from it, and + without a location id GoHighLevel answers 403 with a message that + blames the token rather than the missing parameter. + """ + if self.IEndpoint.endpoint.openMode == OPEN_MODE.CONFIG: + return + + from depends import load_depends + + load_depends(__file__) + + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + self.token = str((cfg.get('privateIntegrationToken') or '')).strip() + self.location_id = str((cfg.get('locationId') or '')).strip() + 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_gohighlevel: privateIntegrationToken is required') + if not self.location_id: + raise Exception('tool_gohighlevel: locationId is required (the sub-account this token is scoped to)') + + def validateConfig(self) -> None: + """Surface config problems in the editor without starting the backend.""" + try: + cfg = Config.getNodeConfig(self.glb.logicalType, self.glb.connConfig) + token = str((cfg.get('privateIntegrationToken') or '')).strip() + if not token: + warning('privateIntegrationToken is required') + elif not token.startswith(TOKEN_PREFIX): + warning( + 'privateIntegrationToken does not start with "pit-": agency tokens, OAuth access tokens ' + 'and v1 API keys are not accepted by this node' + ) + if not str((cfg.get('locationId') or '')).strip(): + warning('locationId is required (the sub-account this Private Integration Token is scoped to)') + 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: + """Release shared state, wiping the credential.""" + self.token = '' + self.location_id = '' + self.read_only = False + self.tool_groups = DEFAULT_GROUPS + self.allow_raw_request = True diff --git a/nodes/src/nodes/tool_gohighlevel/IInstance.py b/nodes/src/nodes/tool_gohighlevel/IInstance.py new file mode 100644 index 000000000..c54e10958 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/IInstance.py @@ -0,0 +1,229 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +GoHighLevel tool node instance. + +Composes one mixin per GoHighLevel resource group, publishes only the tools the +operator enabled, and adds the generic ``request`` escape hatch that reaches any +v2 endpoint the typed tools do not model. + +Publication is filtered twice here, in one pass. The group stamp decides which +resource areas an agent sees at all, and the write stamp removes the tools a +read-only node could never run. Both are stamped by ``@gohighlevel_tool``, so +there is no registry to keep in sync with the tool definitions. +""" + +from __future__ import annotations + +from typing import Callable + +from rocketlib import IInstanceBase, tool_function + +from ai.common.utils import require_str + +from .gohighlevel_client import redact_payload +from .IGlobal import IGlobal +from .tool_groups import RAW_REQUEST_TOOL +from .tools import ( + AppointmentNotesMixin, + AppointmentsMixin, + BusinessesMixin, + CalendarGroupsMixin, + CalendarsMixin, + ContactNotesMixin, + ContactTasksMixin, + ContactsMixin, + ConversationsMixin, + CustomFieldsMixin, + CustomValuesMixin, + LocationTagsMixin, + LocationsMixin, + MessagesMixin, + OpportunitiesMixin, + PipelinesMixin, + UsersMixin, +) + +#: Methods the ``request`` tool accepts without write permission. +#: +#: GET only, deliberately narrower than the conventional safe set. The read-only config +#: field tells the operator that the request tool accepts GET in that mode, and HEAD is +#: not documented on a single GoHighLevel operation, so there is nothing to gain by +#: quietly widening the promise. +_SAFE_METHODS = {'GET'} +_ALLOWED_METHODS = {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'} + + +class IInstance( + AppointmentNotesMixin, + AppointmentsMixin, + BusinessesMixin, + CalendarGroupsMixin, + CalendarsMixin, + ContactNotesMixin, + ContactTasksMixin, + ContactsMixin, + ConversationsMixin, + CustomFieldsMixin, + CustomValuesMixin, + LocationTagsMixin, + LocationsMixin, + MessagesMixin, + OpportunitiesMixin, + PipelinesMixin, + UsersMixin, + IInstanceBase, +): + IGlobal: IGlobal + + # ----------------------------------------------------------------------- + # Tool publication + # ----------------------------------------------------------------------- + + def _collect_tool_methods(self) -> dict[str, Callable]: + """Publish only the tools this node's configuration allows an agent to run. + + The base implementation returns every ``@tool_function`` on the class, which for + full GoHighLevel coverage is far more than an agent can choose between. Two filters + run here, and both cover ``tool.query`` (the agent never sees the tool) as well as + ``tool.invoke`` (calling it anyway is refused): + + 1. The resource group, against ``gohighlevel.toolGroups``. + 2. The write stamp, when ``gohighlevel.readOnly`` is on. This is a deliberate + deviation from tool_pipedrive, which publishes its write tools in read-only mode + and refuses them at invoke time. An agent cannot tell in advance that a published + tool is blocked, so it spends a turn finding out, and roughly 40 tools it can only + ever fail on are 40 tools' worth of wasted context. + ``GoHighLevelToolsBase._require_write`` still guards invoke, both as defence in + depth and because the raw ``request`` tool carries no stamp at all. + """ + methods = super()._collect_tool_methods() + enabled = self.IGlobal.tool_groups + allow_raw = self.IGlobal.allow_raw_request + read_only = self.IGlobal.read_only + + published: dict[str, Callable] = {} + for name, method in methods.items(): + if name == RAW_REQUEST_TOOL: + # Gated by its own config switch rather than by a group. It stays published + # in read-only mode because it is still a working read tool there. + if allow_raw: + published[name] = method + continue + declared = getattr(type(self), name, None) + group = getattr(declared, '__gohighlevel_group__', None) + if group is not None and group not in enabled: + continue + if read_only and getattr(declared, '__gohighlevel_writes__', False): + continue + 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 is permitted.', + }, + 'path': { + 'type': 'string', + 'description': ( + 'Path relative to the API root, starting with a slash: for example "/contacts/", ' + '"/opportunities/pipelines" or "/locations//customValues". Do not include ' + 'the host. A trailing slash is load-bearing on several endpoints, and so is casing: ' + 'GoHighLevel answers a mis-cased path with 401 rather than 404, so copy the path from ' + 'the API reference exactly.' + ), + }, + 'params': { + 'type': 'object', + 'description': ( + 'Query-string parameters. The sub-account id is not added for you here: most endpoints ' + 'want it as locationId, and GET /opportunities/search spells it location_id.' + ), + }, + 'body': { + 'type': 'object', + 'description': ( + 'JSON request body for POST, PUT and PATCH, and for the few DELETE endpoints that require one.' + ), + }, + 'version': { + 'type': 'string', + 'description': ( + 'Value for the Version header, which every GoHighLevel operation requires. It defaults ' + 'to the value derived from the path, which is 2021-04-15 for /calendars and ' + '/conversations and 2021-07-28 for everything else. Set it only when the endpoint you ' + 'are calling documents a different value.' + ), + }, + }, + }, + description=( + 'Call any GoHighLevel v2 endpoint at https://services.leadconnectorhq.com directly, by method and ' + 'path. Use this only for endpoints that have no dedicated tool here: the typed tools validate their ' + 'input, put the sub-account id wherever the endpoint wants it and return a compact result, while ' + 'this one sends what you give it and returns the raw response body. The Authorization and Version ' + 'headers are added automatically, so do not pass them. Rate-limit retries and read-only enforcement ' + 'apply here too.' + ), + ) + def request(self, args): + """Call any GoHighLevel v2 endpoint directly.""" + args = self._args(args, 'request') + 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 "/contacts/", not a full URL') + + params = args.get('params') + body = args.get('body') + version = args.get('version') + 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') + if version is not None and not isinstance(version, str): + raise ValueError('request: "version" must be a string such as "2021-07-28"') + + payload = self._call_envelope(method, path, params=params, body=body, version=version or None) + # The only tool that returns a response this node has not projected through a key + # allowlist, so it is the one place a payload could echo the credential back. + return redact_payload(payload, self._token()) diff --git a/nodes/src/nodes/tool_gohighlevel/README.md b/nodes/src/nodes/tool_gohighlevel/README.md new file mode 100644 index 000000000..dbe936166 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/README.md @@ -0,0 +1,475 @@ +# tool_gohighlevel + +A RocketRide tool node that exposes the GoHighLevel (LeadConnector) v2 REST API to an AI agent. + +## What it does + +Gives an agent one GoHighLevel sub-account: contacts, contact notes and tasks, +opportunities, pipelines, conversations, messages, calendars, appointments and +appointment notes, custom fields, custom values, tags, businesses, locations and users. +101 tools in all, plus a generic `request` tool for any v2 endpoint that has no dedicated +tool here. Useful for lead nurture, appointment booking, CRM reporting, and pipelines +that read from or write to a sub-account. + +Uses the **requests** library against `https://services.leadconnectorhq.com` with a +30-second timeout, and **tenacity** to retry GoHighLevel's burst rate limit. There is no +response envelope to unwrap: each endpoint names its own key, and the key does not always +match the resource (`GET /contacts/{id}/appointments` returns `events`), so every tool +names the key it reads. Records come back trimmed to the fields the node documents, and +custom fields are projected into one object keyed by field id. + +Authentication is a sub-account **Private Integration Token**, and both the token and the +**location id** are required: the pipeline fails to start without them. Write operations +are **allowed by default**; enable **read-only mode** to hide every mutating tool from the +agent. + +--- + +## Configuration + +| Field | Type | Description | +|---|---|---| +| `privateIntegrationToken` | string | Default empty. Sub-account Private Integration Token (Settings -> Private Integrations), sent as `Authorization: Bearer`. Observed tokens start with `pit-`. Stored encrypted. | +| `locationId` | string | Default empty. The sub-account this node operates on, from Settings -> Business Profile. Required: the token is opaque, so the location cannot be derived from it. | +| `readOnly` | boolean | Default false. When enabled, every create, update and delete tool is hidden from the agent, and `request` accepts only GET. | +| `toolGroups` | array | Default empty, which publishes the recommended set of 74 tools. Name groups to change that, or use `["all"]` for all 101. | +| `allowRawRequest` | boolean | Default true. Publishes the generic `request` tool. | + +### Tool groups + +Full coverage here is 101 tools across 17 groups. That is more than an LLM can choose +between reliably, so the node publishes only the groups named in **Tool groups**. Leaving +the field empty publishes the default set: **74 tools** across `appointments`, +`calendars`, `contact_notes`, `contact_tasks`, `contacts`, `conversations`, +`custom_fields`, `messages`, `opportunities`, `pipelines` and `users`, which is everything +an agent needs to run lead nurture and appointment booking end to end. `users` is in the +default set despite being administrative: it is three read-only tools, and it is the only +way to resolve the user ids that `assignedTo`, `followers` and `assignedUserId` need. + +Available groups: + +`appointment_notes`, `appointments`, `businesses`, `calendar_groups`, `calendars`, +`contact_notes`, `contact_tasks`, `contacts`, `conversations`, `custom_fields`, +`custom_values`, `location_tags`, `locations`, `messages`, `opportunities`, `pipelines`, +`users`. + +A tool in a group that is not published is invisible to the agent and refused if invoked +anyway. Group names are matched case-insensitively; a name this node does not implement is +reported as a warning in the editor and otherwise ignored. + +### Pagination + +Every list tool returns the same envelope: `{items, count, total, next, has_more}`. Ask +for another page only while `has_more` is true, and pass `next` back as the parameter the +tool's description names. `next` is null when there is no next page. `total` is null on +most endpoints, and null there means unknown rather than zero. + +**18 list tools always report `total: null`, by design rather than by defect.** Their +endpoints send no record count at all, so there is nothing to report and the node does not +synthesise one. Measured by running the live suite against a real sub-account, not inferred +from the specs: + +`appointment_list`, `appointment_notes_list`, `blocked_slot_list`, `business_list`, +`calendar_group_list`, `calendar_list`, `contact_appointments_list`, +`contact_list_by_business`, `contact_notes_list`, `contact_tasks_list`, +`custom_field_list`, `custom_value_list`, `location_tags_list`, `location_tasks_search`, +`lost_reason_list`, `message_list`, `pipeline_list`, `user_list_by_location`. + +Do not treat a null `total` from any of them as an empty result or as a bug: page with +`has_more` instead. The tools that do report a count are `contact_list`, `contact_search`, +`conversation_search`, `message_export`, `opportunity_search`, +`opportunity_search_advanced` and `user_search`. Two of those, `contact_list_by_business` +and `lost_reason_list`, read a count the endpoint documents but never sent on any live +response, which is why they are in the null list above. + +GoHighLevel does not have one pagination style. The contacts list pages on a +`startAfter` plus `startAfterId` pair, the searches page on an opaque `searchAfter` value, +and several endpoints page on a `skip` offset. GoHighLevel also carries its cursors on the +records rather than on the response root, so the last page has one too. The node compares +the page size it received against the size it asked for instead of trusting cursor +presence, which is what keeps `has_more` from claiming a page that does not exist. + +Maximum page size is per endpoint, not global: `appointment_notes_list` caps at 20, +`message_export` at 500, and the rest at 100, which is the node's own ceiling on the +endpoints where GoHighLevel publishes no maximum. Sending a larger value is a hard 400 +rather than a silent clamp, so the node clamps before the request goes out. + +### Custom fields + +Read and write use opposite shapes, and GoHighLevel rejects the wrong one. Reads return +custom fields as one object keyed by field id (`{"": }`). Creates and +updates take an array (`[{"id": "", "field_value": }]`). A record from a +get tool cannot be passed back to an update unchanged. Use `custom_field_list` (group +`custom_fields`) to discover field ids. + +Several write tools, including the contact, opportunity and custom field creates and +updates, accept an `extra` object. It is merged into the request body after the typed +parameters, so it reaches any API field this node does not model explicitly: + +```json +{ "firstName": "Ada", "extra": { "someUndocumentedField": "value" } } +``` + +`tags` cannot be smuggled through `extra` on an update or an upsert. GoHighLevel replaces +the whole tag array rather than adding to it, so sending it there would delete the tags you +did not list; use `contact_tags_add` and `contact_tags_remove` instead. `contact_create` +takes `tags` as a normal parameter, since a contact being created has none to lose. + +### Tags have a sub-account-level side effect + +`contact_tags_add` is not only a per-contact write. Adding a tag name that does not exist +yet **defines that tag on the whole sub-account**, where it shows up in `location_tags_list` +and in the GoHighLevel UI from then on. `contact_tags_remove` takes the tag off the contact +and leaves the definition behind. Only `location_tags_delete` (group `location_tags`, opt +in) removes a definition. + +This matters for anything that loops. An agent that tags a few thousand contacts with +generated names, then removes the tags again, leaves a few thousand tag definitions on the +sub-account, and nothing in the contacts group can clean them up. Reuse the names +`location_tags_list` already reports, or publish `location_tags` alongside `contacts` so +whatever creates definitions can also remove them. Confirmed live against a real sub-account, +not read out of the spec: the specs describe neither half of this. + +--- + +## Authentication + +The node accepts exactly one credential: a **sub-account Private Integration Token**. +Create it under Settings -> Private Integrations, pick the scopes, and copy the token when +it is shown. There is no way to read it again afterwards. + +Three consequences are worth knowing before you deploy this node. + +**One credential reaches exactly one sub-account.** A location token authenticates that +location and nothing else. There is no fan-out: minting per-location tokens from an agency +credential runs through `POST /oauth/locationToken`, which is OAuth-only and refuses a +Private Integration Token. N sub-accounts need N node instances, each with its own token +and its own `locationId`. Agency-scoped endpoints answer `403 Forbidden resource` to a +sub-account token, and no configuration change grants access. + +**Rotation is a silent cliff.** Rotating with "expire later" keeps the old token working +for exactly 7 days and then stops it, with nothing in the API signalling the deadline +beforehand. Rotating with "expire now" breaks it immediately. A 401 raised by this node +carries that explanation rather than a bare "unauthorized", because a rotation about a week +earlier is the most likely cause. + +**Editing scopes does not mint a new token.** The existing token keeps working, so nothing +looks broken, but calls that need a scope you removed start failing while the credential +still authenticates. GoHighLevel answers those with `401 The token is not authorized for +this scope`, which the node passes through with a note that scopes are edited on the +Private Integration itself. + +### A 401 is not always a credential problem + +Four error shapes are worth naming, because all four look like auth failures and none of +them is one. + +- A missing or mismatched `locationId` returns `403 The token does not have access to this + location`, which blames the token for a missing parameter. +- A mis-cased path returns 401 rather than 404. +- A missing required parameter can return 401 too. `GET /users/search` called bare answers + `401 E01 - Unauthorized request`, and the same call answers 422 naming the parameter once + a `locationId` is supplied. The status depends on what else you sent, not on the token. +- The gateway in front of the API answers 401 for its own failures. One was captured live + carrying the body text `Command timed out`, which is a timeout rather than anything to do + with the credential. + +The node rewrites all four into messages that name the real cause, and its 401 fall-through +claims a credential problem only when the body actually complains about one or carries no +message at all. A timeout is reported as a timeout and is worth retrying; nothing there +should send anyone to rotate a working token. + +### Errors carry more than a message + +Some GoHighLevel errors name the record that caused them, and the id is the way out of the +error. `conversation_create` for a contact that already has a conversation answers HTTP 400 +with `{"message": "Conversation already exists", "canonicalCode": +"CONVERSATIONS_CONVERSATION_ALREADY_EXISTS", "conversationId": "..."}`, and that is the +common case rather than an edge one: creating a contact through the API creates its +conversation too. The node carries every id the body names, plus `canonicalCode` and +`traceId`, into the raised message and onto the exception, so the agent can use the existing +conversation instead of stopping at "already exists". + +### Why there is no OAuth option + +GoHighLevel's OAuth access tokens last about 24 hours, and its refresh tokens are +single-use: the moment you spend one, the old refresh token is dead and a new one comes +back in the response. A node's only credential store is static config, which it cannot +write to, so the new refresh token would be thrown away. The pipeline would work for +roughly a day, refresh once successfully, work for another day, then present an +invalidated refresh token and die. That failure is delayed, silent, and unrecoverable +without a browser consent flow, and pasting the original refresh token back in does not +fix it. Supporting OAuth needs a durable secret store the node can write to on every +refresh, with a lock so concurrent runs cannot race. Until that exists, the Private +Integration Token is the only credential in this API that static config is the correct +store for. + +The legacy v1 API key is not accepted either. v1 reached end of support on 31 December +2025, new keys can no longer be generated, and a v1 key will never authenticate against +`services.leadconnectorhq.com`. + +## Rate limits + +Measured against a sub-account Private Integration Token, not assumed from the docs: **25 +requests per 10 seconds** and **10,000 per day**. The 100 per 10 seconds published for +marketplace apps does not apply here. Every response carries `x-ratelimit-max`, +`x-ratelimit-remaining`, `x-ratelimit-interval-milliseconds`, `x-ratelimit-limit-daily`, +`x-ratelimit-daily-remaining` and `x-ratelimit-daily-reset`. + +GoHighLevel sends no `Retry-After`, not even on the 429 itself, so the client computes its +own wait: one burst window (`x-ratelimit-interval-milliseconds`, 10000 on every observed +response), up to three attempts. If the wait would exceed the 30-second request timeout +the call fails immediately with the wait time in the message rather than blocking the +pipeline. `x-ratelimit-daily-reset` is deliberately never used as a sleep: it is a +duration in milliseconds, about 24 hours, and treating it as a timestamp would produce a +sleep of roughly 55 years. Exhausting the daily budget is a circuit break rather than +something to retry, so the node reports it and stops. + +## Read-only mode + +With **Read-only mode** enabled, every tool that creates, updates or deletes is dropped +from the published set: the agent does not see it in `tool.query`, and invoking it anyway +is refused. The `request` tool stays published, because it is still a working read tool, +but accepts only GET. + +Hiding rather than refusing is a deliberate departure from `tool_pipedrive`, which +publishes its write tools in read-only mode and blocks them at invoke time. An agent +cannot tell in advance that a published tool is blocked, so it spends a turn finding out, +and roughly 40 tools it can only ever fail on are 40 tools' worth of wasted context. + +--- + +## Available tools + +Tools are published as `gohighlevel.`. The **Writes** column marks the tools that +read-only mode hides. + +#### `appointment_notes` (4 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `appointment_notes_create` | yes | Write a note on an appointment. | +| `appointment_notes_delete` | yes | Delete a note from an appointment. | +| `appointment_notes_list` | | List the notes written on one appointment. | +| `appointment_notes_update` | yes | Replace the text of a note on an appointment. | + +#### `appointments` (9 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `appointment_create` | yes | Book an appointment for a contact on a calendar. | +| `appointment_delete` | yes | Delete a calendar event by id. | +| `appointment_free_slots_list` | | Find the times a calendar can be booked, before calling appointment_create. | +| `appointment_get` | | Get one appointment by id. | +| `appointment_list` | | List the appointments and other events on a calendar, a user or a calendar group inside a time window. | +| `appointment_update` | yes | Update an appointment: reschedule it, reassign it, or change its status. | +| `blocked_slot_create` | yes | Block a period on a calendar or on a user so it cannot be booked. | +| `blocked_slot_list` | | List the blocked time on a calendar, a user or a calendar group inside a time window: the periods that are deliberately unavailable rather than booked. | +| `blocked_slot_update` | yes | Move or retitle a blocked slot. | + +#### `businesses` (5 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `business_create` | yes | Create a business on the configured sub-account. | +| `business_delete` | yes | Delete a business by id. | +| `business_get` | | Get one business by id. | +| `business_list` | | List the businesses on the configured sub-account. | +| `business_update` | yes | Update a business. | + +#### `calendar_groups` (6 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `calendar_group_create` | yes | Create a calendar group in the configured sub-account. | +| `calendar_group_delete` | yes | Delete a calendar group. | +| `calendar_group_list` | | List the calendar groups in the configured sub-account. | +| `calendar_group_slug_check` | | Check whether a calendar group slug is free in the configured sub-account, before creating or renaming a group. | +| `calendar_group_status_set` | yes | Enable or disable a calendar group without deleting it. | +| `calendar_group_update` | yes | Replace the name, description and slug of a calendar group. | + +#### `calendars` (5 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `calendar_create` | yes | Create a calendar in the configured sub-account. | +| `calendar_delete` | yes | Delete a calendar by id. | +| `calendar_get` | | Get one calendar by id, with its whole configuration: team members, slot sizing, booking window, open hours and custom availabilities. | +| `calendar_list` | | List the calendars in the configured sub-account. | +| `calendar_update` | yes | Update a calendar. | + +#### `contact_notes` (5 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `contact_notes_create` | yes | Write a note on a contact. | +| `contact_notes_delete` | yes | Delete a note. | +| `contact_notes_get` | | Get one note by id. | +| `contact_notes_list` | | List the notes on a contact. | +| `contact_notes_update` | yes | Update a note. | + +#### `contact_tasks` (6 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `contact_tasks_create` | yes | Create a task on a contact: a follow-up call, a document to send, anything a person has to do next. | +| `contact_tasks_delete` | yes | Delete a task. | +| `contact_tasks_get` | | Get one task by id. | +| `contact_tasks_list` | | List the tasks on a contact, open and completed alike. | +| `contact_tasks_set_completed` | yes | Mark a task done, or reopen one, without touching its title, due date or assignee. | +| `contact_tasks_update` | yes | Update a task. | + +#### `contacts` (16 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `contact_appointments_list` | | List the calendar appointments booked for a contact. | +| `contact_create` | yes | Create a contact in the configured sub-account. | +| `contact_delete` | yes | Delete a contact by id. | +| `contact_duplicate_check` | | Check whether a phone number or email already belongs to a contact, before creating one. | +| `contact_followers_add` | yes | Add users as followers of a contact. | +| `contact_followers_remove` | yes | Remove followers from a contact, by user id. | +| `contact_get` | | Get one contact by id. | +| `contact_list` | | List contacts in the configured sub-account. | +| `contact_list_by_business` | | List the contacts assigned to a business. | +| `contact_search` | | Find contacts in the configured sub-account by email, phone, name, tag or any other field. | +| `contact_tags_add` | yes | Add tags to a contact. | +| `contact_tags_remove` | yes | Remove tags from a contact. | +| `contact_update` | yes | Update a contact. | +| `contact_upsert` | yes | Create a contact, or update the existing one when its email or phone already belongs to a contact. | +| `contact_workflow_add` | yes | Enrol a contact in a workflow. | +| `contact_workflow_remove` | yes | Remove a contact from a workflow. | + +#### `conversations` (5 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `conversation_create` | yes | Open a conversation with a contact in the configured sub-account. | +| `conversation_delete` | yes | Delete a conversation and every message in it. | +| `conversation_get` | | Get one conversation by id, including how many of its messages are unread and who it is assigned to. | +| `conversation_search` | | Find conversations in the configured sub-account, by contact, owner, channel, read state or free text. | +| `conversation_update` | yes | Update a conversation read state, star or feedback. | + +#### `custom_fields` (5 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `custom_field_create` | yes | Create a custom field definition on the configured sub-account. | +| `custom_field_delete` | yes | Delete a custom field definition from the configured sub-account. | +| `custom_field_get` | | Get one custom field definition. | +| `custom_field_list` | | List the custom field definitions on the configured sub-account. | +| `custom_field_update` | yes | Update a custom field definition. | + +#### `custom_values` (5 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `custom_value_create` | yes | Create a custom value on the configured sub-account. | +| `custom_value_delete` | yes | Delete a custom value from the configured sub-account. | +| `custom_value_get` | | Get one custom value by id. | +| `custom_value_list` | | List the custom values on the configured sub-account. | +| `custom_value_update` | yes | Replace a custom value. | + +#### `location_tags` (5 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `location_tags_create` | yes | Define a new tag on the configured sub-account and return it with its id. | +| `location_tags_delete` | yes | Delete a tag definition from the sub-account. | +| `location_tags_get` | | Get one tag definition by id. | +| `location_tags_list` | | List every tag defined on the configured sub-account, with the id of each. | +| `location_tags_update` | yes | Rename a tag definition. | + +#### `locations` (2 tools, opt in) + +| Tool | Writes | Description | +|---|---|---| +| `location_get` | | Get the configured sub-account, which GoHighLevel also calls a location. | +| `location_tasks_search` | | Search tasks across the whole sub-account, optionally narrowed by contact, assignee, business, completion state or free text. | + +#### `messages` (8 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `message_email_get` | | Get one email message by id, with its subject, sender, to, cc and bcc lists, thread id and attachment URLs. | +| `message_email_schedule_cancel` | yes | Cancel a scheduled email that has not gone out yet. | +| `message_export` | | Export messages across the whole configured sub-account, rather than one conversation at a time. | +| `message_get` | | Get one message by id, including its body, direction, delivery status and attachment URLs. | +| `message_list` | | Read the messages in one conversation. | +| `message_schedule_cancel` | yes | Cancel a message that was scheduled but has not gone out yet. | +| `message_send` | yes | Send a message to a contact on any channel: SMS, email, WhatsApp, Instagram, Facebook, RCS, TikTok, live chat or a custom provider. | +| `message_transcription_get` | | Get the speech-to-text transcription of a recorded call or voicemail. | + +#### `opportunities` (10 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `opportunity_create` | yes | Create an opportunity in the configured sub-account, against a pipeline and a contact. | +| `opportunity_delete` | yes | Delete an opportunity by id. | +| `opportunity_followers_add` | yes | Add users as followers of an opportunity, so they are notified as it moves. | +| `opportunity_followers_remove` | yes | Remove followers from an opportunity, by user id, or clear them all with isRemoveAllFollowers. | +| `opportunity_get` | | Get one opportunity by id. | +| `opportunity_search` | | Find opportunities in the configured sub-account, filtered by pipeline, stage, contact, status, assignee or free text. | +| `opportunity_search_advanced` | | Search opportunities in the configured sub-account by free text, and optionally pull their notes, tasks, calendar events and unread conversation counts back in the same call. | +| `opportunity_status_update` | yes | Move an opportunity to won, lost, abandoned or back to open. | +| `opportunity_update` | yes | Update an opportunity. | +| `opportunity_upsert` | yes | Update an opportunity when you pass its id, or create one when you do not. | + +#### `pipelines` (2 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `lost_reason_list` | | List the reasons this sub-account records for a lost deal. | +| `pipeline_list` | | List the opportunity pipelines on the configured sub-account, each with its stages. | + +#### `users` (3 tools, default) + +| Tool | Writes | Description | +|---|---|---| +| `user_get` | | Get one user by id, including the permissions map and the scopes string that the two list tools leave out. | +| `user_list_by_location` | | List every user of the configured sub-account in one response. | +| `user_search` | | Find users of the configured sub-account by name, email, phone or role. | + +#### `request` (1 tool, gated by `allowRawRequest`) + +| Tool | Writes | Description | +|---|---|---| +| `request` | GET only in read-only mode | Call any GoHighLevel v2 endpoint at https://services.leadconnectorhq.com directly, by method and path. | + +`request` is the escape hatch for endpoints with no dedicated tool. It sends what you give +it and returns the raw response body, where the typed tools validate their input, put the +sub-account id wherever the endpoint wants it, and return a compact result. The +`Authorization` and `Version` headers are added for you, and rate-limit retries and +read-only enforcement apply the same way. Two things it does not do for you: the +sub-account id is not injected (most endpoints want `locationId`, but +`GET /opportunities/search` spells it `location_id`), and a trailing slash and the exact +casing of the path are both load-bearing, since GoHighLevel answers a mis-cased path with +401 rather than 404. + +--- + +## Running the tests + +```bash +# Stubbed suite: no credentials, no network +python -m pytest nodes/test/tool_gohighlevel/test_gohighlevel.py -v + +# Live suite, reads only +export GHL_PIT= +export GHL_LOCATION_ID= +python -m pytest nodes/test/tool_gohighlevel/test_tools.py -v + +# Live suite including the create and delete lifecycles +export GHL_ALLOW_WRITES=1 +python -m pytest nodes/test/tool_gohighlevel/test_tools.py -v +``` + +The live suite calls the real API. Point it at a trial or sandbox sub-account: the write +tests create real records, and they remove them in a `finally` block. Do not run it under +pytest-xdist, since parallel workers share one rate-limit budget but pace themselves +independently. + +--- + + + diff --git a/nodes/src/nodes/tool_gohighlevel/__init__.py b/nodes/src/nodes/tool_gohighlevel/__init__.py new file mode 100644 index 000000000..3c831a9e3 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/__init__.py @@ -0,0 +1,39 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +GoHighLevel (LeadConnector) node for RocketRide Engine. + +Exposes the GoHighLevel v2 REST API as agent tools: contacts, notes, tasks, +opportunities, pipelines, conversations, messages, calendars, appointments, +custom fields, custom values, tags, businesses and users, surfaced as +@tool_function decorators on IInstance. One node instance is scoped to one +sub-account (location) by its Private Integration Token. +""" + +from .IGlobal import IGlobal as IGlobal +from .IInstance import IInstance as IInstance + +__all__ = ['IGlobal', 'IInstance'] diff --git a/nodes/src/nodes/tool_gohighlevel/gohighlevel.svg b/nodes/src/nodes/tool_gohighlevel/gohighlevel.svg new file mode 100644 index 000000000..7dd1de35c --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/gohighlevel.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/nodes/src/nodes/tool_gohighlevel/gohighlevel_client.py b/nodes/src/nodes/tool_gohighlevel/gohighlevel_client.py new file mode 100644 index 000000000..9dafff0c4 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/gohighlevel_client.py @@ -0,0 +1,929 @@ +# ============================================================================= +# 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. +# ============================================================================= + + +""" +GoHighLevel REST API v2 client. + +Thin wrapper around requests that handles auth, the per-operation ``Version`` header, +rate-limit retries, error parsing and pagination. Every tool method in IInstance calls +through here. The module is deliberately framework-free (no ``ai.common`` import) so the +stubbed unit suite can exercise it without the engine. + +Spec baseline: GoHighLevel/highlevel-api-docs @ 0af86a4 (2026-06-19). Re-diff the specs on +any vendor SDK or spec upgrade: breaking changes have shipped in minor SDK releases. + +Three GoHighLevel facts shape this file, and each is the opposite of the Pipedrive client +it was ported from: + +1. There is no response envelope. Pipedrive wraps everything in + ``{"success": ..., "data": ..., "additional_data": ...}``; GoHighLevel gives every + endpoint its own top-level key (``contacts``, ``contact``, ``events``, ``pipelines``, + ``groups``), returns bare arrays from some calendar routes and a date-keyed map from + free-slots. :func:`call` therefore does not unwrap anything and each tool names its own + key. There is no derivable rule, so do not add one. +2. ``Version`` is required on every request and its value is per operation, not global: + ``2021-04-15`` for /calendars and /conversations, ``2021-07-28`` everywhere else. See + :func:`version_for`. Sending one value globally breaks the whole calendars and + conversations family with a 4xx that reads like an auth failure. ``v3`` is also a header + value rather than a URL prefix, and this client never sends it. +3. Rate-limit budgets are read from response headers, never assumed. A trial Private + Integration Token measured 25 requests per 10s and 10,000 per day, not the 100 per 10s + and 200,000 per day published for marketplace apps. + +Why this hand-rolls tenacity instead of using ``ai.common.utils.post_with_retry``: +that helper retries every 5xx and every 429 on a fixed exponential schedule and cannot see +response headers. GoHighLevel needs three things it cannot express. It must read +``x-ratelimit-interval-milliseconds`` to wait exactly one burst window rather than guessing. +It must circuit-break instead of retrying when ``x-ratelimit-daily-remaining`` reaches 0, +because backing off does not help until the daily bucket resets. And it must not retry 5xx, +which GoHighLevel declares on 32 operations with no documented idempotency story. The shared +helper is also POST-only, while most of this surface is GET. Retry policy here is: 429 only, +three attempts, and never a 403 (GoHighLevel uses 403 for a missing locationId and for +agency-scope refusal, and retrying those burns budget and hides the real error). +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Iterable +from typing import Any + +import requests +from tenacity import RetryCallState, Retrying, retry_if_exception_type, stop_after_attempt, wait_exponential + +#: The only ``servers[].url`` across all 83 spec files. There is no white-label API host: +#: white-label domains serve the browser OAuth consent screen, not the API. +BASE_URL = 'https://services.leadconnectorhq.com' +DEFAULT_TIMEOUT = 30 + +#: ``Version`` header values. Both are required by the spec on every operation, with a +#: single-value enum per operation, so neither is a user-facing setting. +VERSION_2021_07_28 = '2021-07-28' +VERSION_2021_04_15 = '2021-04-15' +DEFAULT_VERSION = VERSION_2021_07_28 + +#: First path segment to ``Version`` value, for the segments that are not on the default. +#: Exposed so a mixin can override it for a path this map does not cover. +PATH_VERSIONS: dict[str, str] = { + 'calendars': VERSION_2021_04_15, + 'conversations': VERSION_2021_04_15, +} + +#: Maximum page size per tool. There is no global maximum: the documented maxima are +#: per endpoint and span two orders of magnitude, and sending a larger value is a 400 +#: rather than a silent clamp. +#: +#: Every paged tool must be registered here or in :data:`PAGE_LIMIT_DEFAULTED`. +#: :func:`page_limit` raises on a name in neither, because a silent fallback to 100 is a +#: hard 400 on the endpoints capped below it (appointment notes cap at 20) and it also +#: mis-states the range in the schema the agent reads. +DEFAULT_PAGE_LIMIT = 100 +PAGE_LIMITS: dict[str, int] = { + 'appointment_notes_list': 20, + 'contact_list': 100, + 'contact_search': 100, + 'message_export': 500, + 'opportunity_search': 100, + 'opportunity_search_advanced': 100, +} + +#: Paged tools whose endpoint publishes no maximum, so :data:`DEFAULT_PAGE_LIMIT` applies. +#: Registering them by name is what lets :func:`page_limit` reject a name it has never seen +#: instead of treating a typo as an undocumented endpoint. +#: +#: These endpoints document a *default* page size and nothing else. A default is not a +#: ceiling, so it is not copied into :data:`PAGE_LIMITS`: doing so would advertise a +#: maximum GoHighLevel never promised and cap the agent below what the endpoint serves. +PAGE_LIMIT_DEFAULTED: frozenset[str] = frozenset( + { + 'business_list', + 'contact_list_by_business', + 'conversation_search', + 'location_tasks_search', + 'message_list', + 'user_search', + } +) + +#: Minimum page size per tool, for the one endpoint that declares a floor. +PAGE_MINIMUMS: dict[str, int] = {'message_export': 10} + +#: Keys of the free-slots response that are actual dates. The payload is a map keyed by +#: ``YYYY-MM-DD`` and ``traceId`` can land beside them as a sibling key. +_DATE_KEY_RE = re.compile(r'^\d{4}-\d{2}-\d{2}$') + +#: Error-body keys that name a record the agent can act on: ``id`` itself, or anything +#: ending in ``Id``. GoHighLevel answers some conflicts with the id of the thing that +#: already exists, and the id is the whole way forward. ``POST /conversations/`` for a +#: contact created through the API answers HTTP 400 with +#: ``{"message": "Conversation already exists", "canonicalCode": +#: "CONVERSATIONS_CONVERSATION_ALREADY_EXISTS", "conversationId": "..."}``, because creating +#: a contact creates its conversation too. Reading only ``message`` throws away the id and +#: leaves the agent with a dead end. +#: +#: Top level only, and any key matching the pattern rather than a list of the ones seen so +#: far: an error body is small, GoHighLevel adds fields without notice, and the cost of +#: surfacing one extra id is a longer message rather than a wrong one. +_ID_KEY_RE = re.compile(r'(?:^id|Id)$') + +#: Reported on its own, so it is kept out of :func:`error_details` despite matching. +_TRACE_KEY = 'traceId' + +#: GoHighLevel's error taxonomy code, for example +#: ``CONVERSATIONS_CONVERSATION_ALREADY_EXISTS``. Absent from most error bodies, and stable +#: where the prose of ``message`` is not, so it is the field worth branching on. +_CANONICAL_CODE_KEY = 'canonicalCode' + +#: Words a GoHighLevel 401 uses when it really is complaining about the credential. +#: +#: The 401 fall-through may only claim a credential problem when one of these is present or +#: the body carries no message at all. A 401 was captured live with the body text +#: ``Command timed out``, which is the gateway in front of the API failing rather than the +#: credential being wrong, and answering that with "your token may be revoked" sends the +#: user to rotate a token that works. +_CREDENTIAL_401_MARKERS = ( + 'unauthorized', + 'not authorized', + 'authentication', + 'authenticate', + 'token', + 'credential', + 'api key', + 'apikey', + 'jwt', + 'expired', + 'iam', + 'signature', +) + +#: The three spellings GoHighLevel uses for a success flag. ``succeded`` (one "e") is the +#: real field name on contacts, opportunities, users and locations deletes; reading only +#: ``succeeded`` returns None, which is falsy, so success reads as failure. +_SUCCESS_KEYS = ('succeded', 'succeeded', 'success') + +#: Rate-limit headers, verbatim as returned. All six are lowercase on the wire. +_RL_MAX = 'x-ratelimit-max' +_RL_INTERVAL_MS = 'x-ratelimit-interval-milliseconds' +_RL_REMAINING = 'x-ratelimit-remaining' +_RL_DAILY_LIMIT = 'x-ratelimit-limit-daily' +_RL_DAILY_REMAINING = 'x-ratelimit-daily-remaining' +#: A DURATION IN MILLISECONDS until the daily bucket resets (observed: 86343000, about 24h). +#: It is not an epoch timestamp. Nothing in this file ever sleeps on it: treating it as a +#: timestamp produces a sleep of roughly 55 years. +_RL_DAILY_RESET = 'x-ratelimit-daily-reset' + +_REDACTED = '[redacted]' + +#: Cap for message text taken from an unstructured body. GoHighLevel sits behind a CDN, so a +#: 502 or 503 is an HTML gateway page rather than JSON, and the whole page would otherwise +#: become the exception message an agent reads. +MAX_ERROR_CHARS = 200 + + +class GoHighLevelAPIError(ValueError): + """Raised when the GoHighLevel API returns an error response. + + ``canonical_code`` and ``details`` carry the half of an error body that is not prose. + ``details`` holds every id the body named, keyed as GoHighLevel keyed it, so a caller that + wants to recover from a conflict can read ``exc.details['conversationId']`` rather than + parsing it back out of the message. Both are empty on the error bodies that carry neither, + which is most of them. + """ + + def __init__( + self, + status_code: int, + message: str, + trace_id: str = '', + canonical_code: str = '', + details: dict[str, str] | None = None, + ): + super().__init__(f'GoHighLevel API {status_code}: {message}') + self.status_code = status_code + self.message = message + self.trace_id = trace_id + self.canonical_code = canonical_code + self.details = dict(details or {}) + + +class RateLimitError(Exception): + """Raised when GoHighLevel signals a burst rate limit (429). Internal to this module.""" + + def __init__(self, response: requests.Response): + self.response = response + + +# --------------------------------------------------------------------------- +# Credential hygiene +# --------------------------------------------------------------------------- + + +def _redact(text: Any, token: str) -> str: + """Replace the credential with a placeholder anywhere it appears in an error message. + + This covers error messages only: :func:`_raise_ghl_error`, the unparseable-body branch of + :func:`call_envelope` and its ``RequestException`` wrapper. Success payloads are returned + verbatim, because walking every response body to find a string GoHighLevel has no reason + to echo would cost a full traversal per call. + + The one place a success payload can carry the credential back is the raw ``request`` + escape hatch, which takes a caller-supplied path and returns the whole response. That tool + is the consumer of :func:`redact_payload`, and it must call it before returning. + """ + out = '' if text is None else str(text) + secret = (token or '').strip() + if secret and len(secret) >= 8: + out = out.replace(secret, _REDACTED) + return out + + +def redact_payload(payload: Any, token: str) -> Any: + """Strip the credential out of a decoded success payload, structure preserved. + + For the raw ``request`` tool, which returns a whole undeclared response to the agent. + Every other tool projects a known key allowlist, so nothing unexpected reaches the agent + and none of them needs this. + """ + secret = (token or '').strip() + if not secret or len(secret) < 8: + return payload + if isinstance(payload, str): + return payload.replace(secret, _REDACTED) + if isinstance(payload, dict): + return {redact_payload(key, secret): redact_payload(value, secret) for key, value in payload.items()} + if isinstance(payload, (list, tuple)): + return [redact_payload(item, secret) for item in payload] + return payload + + +# --------------------------------------------------------------------------- +# Version +# --------------------------------------------------------------------------- + + +def version_for(path: str) -> str: + """Return the ``Version`` header value for a request path. + + /calendars and /conversations are on 2021-04-15; everything else is on 2021-07-28. + Hardcoding one value globally is the single most expensive mistake available here: it + surfaces as a 4xx on the whole calendars and conversations family that reads like an + authentication failure. + """ + root = (path or '').strip().lstrip('/').split('?')[0].split('/')[0].lower() + return PATH_VERSIONS.get(root, DEFAULT_VERSION) + + +# --------------------------------------------------------------------------- +# 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 _int_hdr(headers: Any, name: str) -> int | None: + """Read a header as an int, or None when it is absent or not a number.""" + raw = _hdr(headers, name) + if raw is None: + return None + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return None + + +def daily_budget_exhausted(headers: Any) -> bool: + """Whether the daily request bucket is empty. + + When it is, retrying is pointless until the bucket resets, so callers fail fast rather + than backing off. + """ + return _int_hdr(headers, _RL_DAILY_REMAINING) == 0 + + +def _rate_limit_hint(headers: Any) -> str: + """Human-readable rate-limit context for an error message, or '' when there is none. + + Only for a 429 or an exhausted daily bucket. ``x-ratelimit-interval-milliseconds`` is on + every response, including every 404 and every 422, so calling this unconditionally puts a + burst-window sentence on errors that have nothing to do with rate limiting and invites the + agent to sleep and retry a request that will never succeed. :func:`_raise_ghl_error` owns + that gate. + """ + retry_after = _hdr(headers, 'Retry-After') + if retry_after: + return f'retry after {retry_after}s' + if daily_budget_exhausted(headers): + limit = _hdr(headers, _RL_DAILY_LIMIT) or 'the account' + return f'the daily request budget ({limit} requests) is exhausted, so retrying will not help until it resets' + interval_ms = _int_hdr(headers, _RL_INTERVAL_MS) + if interval_ms: + burst = _hdr(headers, _RL_MAX) + seconds = interval_ms / 1000.0 + if burst: + return f'the burst window allows {burst} requests per {seconds:g}s' + return f'the burst window is {seconds:g}s' + return '' + + +def _is_rate_limited(resp: requests.Response) -> bool: + """Whether a failed response is a burst rate limit rather than a real error. + + 429 is the only signal. Do not add Pipedrive's "a 403 carrying rate-limit markers counts + too" branch: GoHighLevel answers 403 for a missing locationId and for agency-scope + refusal, both of which are permanent, so retrying them burns the budget and buries the + error the user needs to see. + """ + return resp.status_code == 429 + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +def _stringify(value: Any) -> str: + """Render one error field as a message, or '' when it carries nothing usable. + + ``message`` is a string on most responses and a list of strings on validation failures, + where ``str(value)`` would produce an unreadable Python repr for the agent. + """ + if value is None or isinstance(value, (dict, bool)): + return '' + if isinstance(value, str): + return value.strip() + if isinstance(value, (list, tuple)): + parts = [_stringify(item) for item in value] + return ', '.join(part for part in parts if part) + return str(value).strip() + + +def error_details(payload: Any) -> dict[str, str]: + """Pull the ids an error body names out of it, keyed as GoHighLevel keyed them. + + ``message`` is only half of some error bodies. A conflict answers with the id of the + record that already exists, and that id is the caller's way forward:: + + { + 'message': 'Conversation already exists', + 'canonicalCode': 'CONVERSATIONS_CONVERSATION_ALREADY_EXISTS', + 'conversationId': 'hzB0N9v0FmRoW3B2ZM4d', + } + + Reading ``message`` alone turns that into "Conversation already exists" and a dead end. + + Kept general on purpose: any top-level key that is ``id`` or ends in ``Id``, rather than a + per-endpoint rule. ``traceId`` is skipped because it is reported separately, and container + and boolean values are skipped because an id is a scalar. ``canonicalCode`` is read by the + caller, not here, since it is not an id. + """ + if not isinstance(payload, dict): + return {} + details: dict[str, str] = {} + for key, value in payload.items(): + if not isinstance(key, str) or key == _TRACE_KEY or not _ID_KEY_RE.search(key): + continue + if value is None or isinstance(value, (dict, list, tuple, bool)): + continue + text = _stringify(value) + if text: + details[key] = text + return details + + +def _error_message(payload: Any, resp: requests.Response) -> str: + """Pull a message out of any of the six observed error body shapes. + + Observed live on one trial account:: + + {"message": "Forbidden resource", "error": "Forbidden", "statusCode": 403} + {"message": ["companyId must be a string", "..."], "error": "Unprocessable Entity", ...} + {"error": "Contact with id X not found", "status": 400} + {"error": "Task with id search not found"} + {"statusCode": 429, "message": "Too Many Requests"} + {"message": "Conversation already exists", "canonicalCode": "...", "conversationId": "..."} + + The 429 carries no ``error`` key at all, so ``message`` has to be read first rather than as + a fallback to ``error``. The last shape carries more than a message, and the rest of it is + read by :func:`error_details` rather than here. + + An unknown path answers 404 with an empty body, so the fall-through to ``reason`` is a + real path rather than a defensive one. Both fall-throughs are capped: an unstructured body + here is a CDN error page, not a message anyone wants in full. + """ + if isinstance(payload, dict): + for key in ('message', 'msg', 'error', 'detail'): + text = _stringify(payload.get(key)) + if text: + return text + else: + text = _stringify(payload) + if text: + return text[:MAX_ERROR_CHARS] + return (resp.text or '').strip()[:MAX_ERROR_CHARS] or (resp.reason or '').strip() or 'unknown error' + + +def _looks_like_a_credential_complaint(lowered: str) -> bool: + """Whether a 401 body is actually complaining about the credential. + + An empty body counts: a bare 401 with nothing in it is a credential problem until + something says otherwise. A body that names something else does not, which is the point. + See :data:`_CREDENTIAL_401_MARKERS`. + """ + if not lowered: + return True + return any(marker in lowered for marker in _CREDENTIAL_401_MARKERS) + + +def _guidance(status_code: int, message: str) -> str: + """Turn a known-misleading vendor message into one that names the real cause.""" + lowered = (message or '').lower() + if status_code == 403: + if 'does not have access to this location' in lowered: + return ( + 'the token is probably fine and locationId is missing or mismatched: set ' + 'gohighlevel.locationId to the sub-account this Private Integration Token belongs to' + ) + if 'forbidden resource' in lowered: + return ( + 'this is an agency-scoped endpoint and a sub-account Private Integration Token ' + 'cannot call it, so no configuration change will grant access' + ) + if status_code == 401: + if 'not authorized for this scope' in lowered: + return ( + 'the token authenticates but is missing a scope this operation requires; scopes are ' + 'edited on the Private Integration itself and existing tokens keep working after an edit' + ) + if 'e01 - unauthorized request' in lowered: + return ( + 'some endpoints answer 401 for a missing required parameter rather than a bad token, ' + 'for example GET /users/search requires companyId; the same call answers 422 naming the ' + 'parameter once locationId is supplied, so add the parameters before touching the token' + ) + if 'timed out' in lowered or 'timeout' in lowered: + return ( + 'this is an upstream timeout wearing a 401 rather than a credential problem: the gateway in ' + 'front of the API was captured answering 401 with the body "Command timed out", so retry the ' + 'call rather than rotating anything' + ) + if not _looks_like_a_credential_complaint(lowered): + # Only claim a credential problem when the body actually complains about one. + # GoHighLevel's 401 covers a missing parameter and a gateway failure too, and + # sending someone to rotate a working token over either costs them the afternoon. + return ( + 'this 401 names no credential, so read the message before rotating anything: GoHighLevel also ' + 'answers 401 for a missing required parameter, for an upstream gateway failure, and for a ' + 'mis-cased path, for example /locations/customvalues instead of /locations/customValues' + ) + return ( + 'the token may be invalid, expired or revoked, and a rotated Private Integration Token keeps ' + 'working for exactly 7 days before it stops with no warning; a mis-cased path also answers 401 ' + 'here rather than 404, for example /locations/customvalues instead of /locations/customValues' + ) + return '' + + +def _raise_ghl_error(resp: requests.Response, token: str = '') -> None: + """Parse a GoHighLevel error response and raise GoHighLevelAPIError. + + The status code always comes from the HTTP response. The body's own ``statusCode`` can + disagree with it (``POST /contacts/`` with only a locationId answers HTTP 400 with a body + saying 422), so nothing here or downstream may branch on the body field. + """ + try: + payload = resp.json() + except Exception: + payload = None + + status_code = resp.status_code + message = _error_message(payload, resp) + + trace_id = '' + canonical_code = '' + details: dict[str, str] = {} + if isinstance(payload, dict): + trace_id = _stringify(payload.get(_TRACE_KEY)) + canonical_code = _stringify(payload.get(_CANONICAL_CODE_KEY)) + details = {key: _redact(value, token) for key, value in error_details(payload).items()} + + # Facts the body carried, ahead of the advice this module adds. An id in an error body is + # the way out of the error ("Conversation already exists" plus the id of the conversation + # that exists), so it leads; canonicalCode follows because it is what to branch on. + facts = [f'{key}: {value}' for key, value in details.items()] + if canonical_code: + facts.append(f'{_CANONICAL_CODE_KEY}: {canonical_code}') + + # The rate-limit clause belongs on a rate-limit error and nowhere else. The burst-window + # header is on every response, so appending it unconditionally would put "the burst window + # allows 25 requests per 10s" on every 404 and every 422 the agent ever sees. The daily + # branch is worth surfacing on any status, because nothing succeeds until that bucket resets. + candidates = [_guidance(status_code, message)] + if status_code == 429 or daily_budget_exhausted(resp.headers): + candidates.append(_rate_limit_hint(resp.headers)) + + hints = facts + [hint for hint in candidates if hint] + if hints: + joined = '; '.join(hints) + message = f'{message} ({joined})' + if trace_id: + message = f'{message} [traceId: {trace_id}]' + + raise GoHighLevelAPIError(status_code, _redact(message, token), trace_id, canonical_code, details) + + +# --------------------------------------------------------------------------- +# 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, + version: str | None = None, + timeout: int = DEFAULT_TIMEOUT, +) -> Any: + """Make an authenticated GoHighLevel API call and return the parsed response body. + + ``version`` overrides the header derived from the path; pass it for a path + :func:`version_for` does not classify. With ``raw=True`` the undecoded body is returned + instead. Raises :class:`GoHighLevelAPIError` with a human-readable message on HTTP errors. + + Trailing slashes in ``path`` are load-bearing (``POST /contacts/``, ``POST /opportunities/``, + ``GET /calendars/`` and others all carry one) and are passed through untouched. Path casing + is passed through untouched as well: GoHighLevel answers a mis-cased path with a 401. + """ + credential = (token or '').strip() + headers = { + 'Accept': 'application/json', + 'Authorization': f'Bearer {credential}', + 'Version': version or version_for(path), + } + send_json = body is not None and files is None and form is None + if send_json: + headers['Content-Type'] = 'application/json' + + query = {k: v for k, v in (params or {}).items() if v is not None} + url = base_url.rstrip('/') + '/' + path.lstrip('/') + + def _attempt() -> Any: + try: + resp = requests.request( + method.upper(), + url, + headers=headers, + params=query, + json=body if send_json else None, + data=form, + files=files, + timeout=timeout, + ) + except requests.RequestException as exc: + raise ValueError(_redact(f'GoHighLevel request failed: {exc}', token)) from exc + + if not resp.ok: + # Retry the burst limit, but never the daily one: it resets in about 24h, so + # backing off inside a pipeline run cannot help. + if _is_rate_limited(resp) and not daily_budget_exhausted(resp.headers): + raise RateLimitError(resp) + _raise_ghl_error(resp, token) + + if raw: + return resp.content + + if resp.status_code == 204 or not (resp.content or b'').strip(): + return {} + + try: + payload = resp.json() + except ValueError as exc: + text = _redact(resp.text, token) + raise GoHighLevelAPIError(resp.status_code, f'response was not JSON: {text[:MAX_ERROR_CHARS]}') from exc + + # No success-flag check here. GoHighLevel has no response envelope: the flag is + # absent from nearly every payload, and where it exists it is spelled three + # different ways. Callers read it per resource through normalize_success. + return payload + + def gohighlevel_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. It has never been + # observed on a GoHighLevel response, including on a real captured 429, and + # the official SDK ignores it. Read it opportunistically, expect its absence. + 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-interval-milliseconds is the length of the burst window + # (10000 on every observed response), so waiting one window clears it. + # x-ratelimit-daily-reset is deliberately not consulted: it is a duration in + # milliseconds, about 24h, and is never a legitimate sleep. + interval_ms = _hdr(hdrs, _RL_INTERVAL_MS) + if interval_ms is not None: + try: + window = float(interval_ms) / 1000.0 + if not math.isfinite(window) or window < 0: + # A malformed window is no information at all, so fall through to + # exponential backoff rather than flooring nonsense to one second. + raise ValueError() + return _check_and_cap(max(1.0, window)) + except ValueError: + pass + return float(wait_exponential(multiplier=2, min=2, max=timeout)(retry_state)) + + try: + return Retrying( + stop=stop_after_attempt(3), + wait=gohighlevel_rate_limit_wait, + retry=retry_if_exception_type(RateLimitError), + reraise=True, + )(_attempt) + except RateLimitError as exc: + _raise_ghl_error(exc.response, token) + + +def call(token: str, method: str, path: str, **kwargs: Any) -> Any: + """Make a GoHighLevel API call and return the parsed payload. + + This is near-identity by design and the absence of an unwrap here is the point, not an + oversight. GoHighLevel has no response envelope: ``GET /contacts/`` answers ``contacts``, + ``GET /contacts/{id}`` answers ``contact``, ``GET /contacts/{id}/appointments`` answers + ``events``, ``GET /calendars/resources/{type}`` answers a bare array and + ``GET /calendars/{id}/free-slots`` answers a map keyed by date. Each tool names its own + key. The only normalisation is a JSON ``null`` body becoming an empty dict. + """ + payload = call_envelope(token, method, path, **kwargs) + if kwargs.get('raw'): + return payload + return {} if payload is None else payload + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + + +def page_limit(tool: str) -> int: + """Maximum page size for a tool. Raises on a tool this module has never heard of. + + A silent fallback to :data:`DEFAULT_PAGE_LIMIT` is not safe here. The tool name is a + hand-typed literal repeated at every call site, the documented maxima run from 20 to 500, + and GoHighLevel answers a value above the maximum with a 400 rather than clamping. A + misspelled name would therefore send 100 to an endpoint capped at 20 and 400 on every + call, and it would also print the wrong range in the schema the agent reads. + """ + if tool in PAGE_LIMITS: + return PAGE_LIMITS[tool] + if tool in PAGE_LIMIT_DEFAULTED: + return DEFAULT_PAGE_LIMIT + raise ValueError( + f'{tool}: no page-size entry in gohighlevel_client. Add the maximum the endpoint documents to ' + f'PAGE_LIMITS, or add the tool name to PAGE_LIMIT_DEFAULTED when the endpoint documents none.' + ) + + +def check_page_limits(tool_names: Iterable[str], paged_tools: Iterable[str] | None = None) -> list[str]: + """Reconcile the page-size tables against the tools actually published. [] when clean. + + Two directions, both of which drift silently otherwise: + + 1. Every key in :data:`PAGE_LIMITS`, :data:`PAGE_LIMIT_DEFAULTED` and + :data:`PAGE_MINIMUMS` must name a real published tool, so a typo in a table entry is + caught rather than sitting there shadowing nothing. + 2. Every tool that publishes a ``limit`` property must have an entry, which is the same + condition :func:`page_limit` enforces at call time, moved to import or test time. + + Kept framework-free and caller-driven on purpose: this module cannot import IInstance. + Call it from the unit suite (or from IInstance after composition) with the tool method + names and, for the second check, the subset whose input schema declares ``limit``. + """ + published = {str(name) for name in tool_names or ()} + problems: list[str] = [] + for table, label in ( + (set(PAGE_LIMITS), 'PAGE_LIMITS'), + (set(PAGE_LIMIT_DEFAULTED), 'PAGE_LIMIT_DEFAULTED'), + (set(PAGE_MINIMUMS), 'PAGE_MINIMUMS'), + ): + for name in sorted(table - published): + problems.append(f'{label} names {name!r}, which is not a published tool') + for name in sorted({str(t) for t in paged_tools or ()} - set(PAGE_LIMITS) - set(PAGE_LIMIT_DEFAULTED)): + problems.append(f'{name} takes a limit but has no PAGE_LIMITS or PAGE_LIMIT_DEFAULTED entry') + for name in sorted(set(PAGE_MINIMUMS) & (set(PAGE_LIMITS) | set(PAGE_LIMIT_DEFAULTED))): + if PAGE_MINIMUMS[name] > page_limit(name): + problems.append(f'{name}: PAGE_MINIMUMS is above its maximum') + return problems + + +def clamp_limit(tool: str, value: Any) -> int: + """Clamp a requested page size into the range the endpoint accepts. + + The limits are per endpoint, from appointment notes at 20 up to the message export at + 500, and GoHighLevel answers a 400 rather than clamping for you. + """ + ceiling = page_limit(tool) + floor = PAGE_MINIMUMS.get(tool, 1) + try: + requested = int(value) + except (TypeError, ValueError): + return min(DEFAULT_PAGE_LIMIT, ceiling) + return max(floor, min(requested, ceiling)) + + +def paginated( + items: list, + *, + total: Any = None, + next_cursor: Any = None, + has_more: Any = None, + requested_limit: Any = None, +) -> dict: + """Wrap a cleaned list plus its cursor for the agent. + + ``next`` is whatever the tool's pagination style uses: a cursor, an offset, a page + number or None. ``total`` stays None wherever GoHighLevel does not return one, which is + most endpoints; do not synthesise it. + + ``requested_limit`` is the page size actually sent, after clamping. Pass it from every + cursor-paged tool. GoHighLevel carries its cursors on the records themselves rather than + on the response root, so a cursor is present on the last non-empty page too and cursor + presence alone reports ``has_more: true`` on every listing that fits in one page. That is + one wasted request per traversal against a measured 25-request/10s budget, and it + contradicts the count the agent can see. A short page means there is no next page. + + ``has_more`` still wins when a tool knows the answer outright (an endpoint that returns + its own ``hasMore``, or an offset style that already compared the count to the limit). + With neither argument the field falls back to cursor presence. + """ + records = list(items or []) + try: + counted = int(total) if total is not None else None + except (TypeError, ValueError): + counted = None + + try: + page_size = int(requested_limit) if requested_limit is not None else None + except (TypeError, ValueError): + page_size = None + + if has_more is not None: + more = bool(has_more) + elif page_size is not None: + more = bool(next_cursor) and len(records) >= page_size + else: + more = next_cursor is not None and next_cursor != '' + return { + 'items': records, + 'count': len(records), + 'total': counted, + # Reported only when it leads somewhere. GoHighLevel carries cursors on the records, so + # the last page has one too, and an agent that trusts ``next`` over ``has_more`` would + # spend a request discovering the empty page. The two fields must not disagree. + 'next': next_cursor if more else None, + 'has_more': more, + } + + +# --------------------------------------------------------------------------- +# Response helpers +# --------------------------------------------------------------------------- + + +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 normalize_success(payload: Any) -> bool: + """Read a delete or status response's success flag across all three spellings. + + ``succeded`` (one "e") is the real field on contacts, opportunities, users and locations + deletes; ``success`` on calendars, businesses and conversations; ``succeeded`` on + ``DELETE /calendars/events/{eventId}``. Six v3 DTOs declare two of them at once, so read + every spelling unconditionally rather than switching on the version. A 2xx with no flag + at all counts as success, because most endpoints send none. + """ + if not isinstance(payload, dict): + return True + for key in _SUCCESS_KEYS: + if key in payload: + return bool(payload[key]) + return True + + +def bulk_succeeded(payload: Any) -> bool: + """Whether a bulk write actually succeeded for every item. + + Bulk endpoints answer 2xx with a top-level success flag alongside per-item failures, so + the flag alone is inconclusive: check ``errorCount`` and the per-item ``type`` too. + """ + if not isinstance(payload, dict): + return True + if not normalize_success(payload): + return False + try: + if int(payload.get('errorCount') or 0) > 0: + return False + except (TypeError, ValueError): + return False + for item in payload.get('responses') or []: + if isinstance(item, dict) and item.get('type') == 'error': + return False + return True + + +def date_keyed(payload: Any) -> dict: + """Keep only the ``YYYY-MM-DD`` keys of a date-keyed map. + + ``GET /calendars/{calendarId}/free-slots`` answers a map keyed by date rather than a + list, and ``traceId`` can arrive as a sibling key, so filter rather than iterating every + key. + """ + if not isinstance(payload, dict): + return {} + return {k: v for k, v in payload.items() if isinstance(k, str) and _DATE_KEY_RE.match(k)} + + +def split_custom_fields(value: Any) -> dict: + """Project GoHighLevel's custom-field array into ``{field_id: value}``. + + Custom fields are an array of objects, not top-level keys, and the object differs by + direction and by resource: contacts write ``{id, key, field_value}`` and read + ``{id, value}``, opportunities write ``field_value`` and read ``fieldValue``. A GET + response therefore cannot be fed back into a PUT unchanged. The value is polymorphic + across nine declared types (string, number, string array, and a file object keyed by + random UUID), so it is passed through untouched rather than coerced. + """ + if not isinstance(value, list): + return {} + out: dict = {} + for entry in value: + if not isinstance(entry, dict): + continue + key = entry.get('id') or entry.get('key') or entry.get('fieldKey') + if not isinstance(key, str) or not key: + continue + out[key] = None + for name in ('field_value', 'fieldValue', 'value'): + if name in entry: + out[key] = entry[name] + break + return out diff --git a/nodes/src/nodes/tool_gohighlevel/requirements.txt b/nodes/src/nodes/tool_gohighlevel/requirements.txt new file mode 100644 index 000000000..365ee776b --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/requirements.txt @@ -0,0 +1,2 @@ +requests +tenacity diff --git a/nodes/src/nodes/tool_gohighlevel/services.json b/nodes/src/nodes/tool_gohighlevel/services.json new file mode 100644 index 000000000..544365a22 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/services.json @@ -0,0 +1,83 @@ +{ + "title": "GoHighLevel", + "protocol": "tool_gohighlevel://", + "classType": ["tool"], + "capabilities": ["invoke"], + "register": "filter", + "node": "python", + "path": "nodes.tool_gohighlevel", + "prefix": "gohighlevel", + "icon": "gohighlevel.svg", + "documentation": "https://docs.rocketride.org", + "description": ["Exposes the GoHighLevel (LeadConnector) v2 REST API as agent tools.", "Covers contacts, notes, tasks, opportunities, pipelines, conversations, messages,", "calendars, appointments, custom fields, custom values, tags, businesses and users."], + "tile": ["GoHighLevel${parameters.gohighlevel.locationId ? ': ' + parameters.gohighlevel.locationId : ''}"], + "lanes": {}, + "preconfig": { + "default": "default", + "profiles": { + "default": { + "title": "GoHighLevel", + "privateIntegrationToken": "", + "locationId": "", + "readOnly": false, + "toolGroups": [], + "allowRawRequest": true + } + } + }, + "fields": { + "gohighlevel.privateIntegrationToken": { + "type": "string", + "title": "Private Integration Token", + "description": "Sub-account Private Integration Token (Settings -> Private Integrations), sent as Authorization: Bearer. It starts with pit-. Agency tokens, OAuth access tokens and v1 API keys are not supported: one token reaches exactly one sub-account, so N sub-accounts need N node instances.", + "default": "", + "secure": true, + "ui": { + "ui:widget": "ApiKeyWidget" + } + }, + "gohighlevel.locationId": { + "type": "string", + "title": "Location ID", + "description": "The sub-account (location) this node operates on, from Settings -> Business Profile. The token is opaque, so the location cannot be derived from it. Without this value GoHighLevel answers 403 with a message that blames the token rather than the missing parameter.", + "default": "" + }, + "gohighlevel.readOnly": { + "type": "boolean", + "title": "Read-only mode", + "description": "When enabled, every create, update and delete tool is hidden from the agent rather than merely refused, and the generic request tool only accepts GET. Safe for agents that should only inspect the account.", + "default": false, + "enum": [ + [true, "Yes: read only"], + [false, "No: allow writes"] + ] + }, + "gohighlevel.toolGroups": { + "type": "array", + "title": "Tool groups", + "description": "Which groups of GoHighLevel tools this node publishes to the agent. This node implements 101 tools across 17 groups, far more than an LLM can choose between reliably, so only the listed groups are exposed. Leave this empty for the recommended set: 74 tools across appointments, calendars, contact_notes, contact_tasks, contacts, conversations, custom_fields, messages, opportunities, pipelines and users, which is everything an agent needs to run lead nurture and appointment booking end to end. Use all to publish all 101. Available groups: appointment_notes, appointments, businesses, calendar_groups, calendars, contact_notes, contact_tasks, contacts, conversations, custom_fields, custom_values, location_tags, locations, messages, opportunities, pipelines, users.", + "items": { + "type": "string" + }, + "default": [], + "optional": true + }, + "gohighlevel.allowRawRequest": { + "type": "boolean", + "title": "Allow raw API requests", + "description": "Publishes the generic request tool, which can call any GoHighLevel v2 endpoint by method and path. It uses the same authentication, version header, 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": "GoHighLevel", + "properties": ["type", "gohighlevel.privateIntegrationToken", "gohighlevel.locationId", "gohighlevel.readOnly", "gohighlevel.toolGroups", "gohighlevel.allowRawRequest"] + } + ] +} diff --git a/nodes/src/nodes/tool_gohighlevel/tool_groups.py b/nodes/src/nodes/tool_gohighlevel/tool_groups.py new file mode 100644 index 000000000..be942aa57 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tool_groups.py @@ -0,0 +1,167 @@ +# ============================================================================= +# 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 GoHighLevel node. + +This node implements 101 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 ``gohighlevel.toolGroups`` config field. Tools that +change state are tagged too, and are dropped from the published set when the node is +in read-only mode. Both filters happen in ``IInstance._collect_tool_methods()``, so a +tool that is not published is invisible to ``tool.query`` and rejected by +``tool.invoke`` alike. + +This module is the single source of truth for the group names. ``services.json`` does +not repeat the default set: its ``toolGroups`` default is empty, which ``normalize_groups`` +resolves to ``DEFAULT_GROUPS`` here. +""" + +from __future__ import annotations + +from typing import Callable + +from rocketlib import tool_function + +#: Every group this node implements. +ALL_GROUPS = frozenset( + { + 'appointment_notes', + 'appointments', + 'businesses', + 'calendar_groups', + 'calendars', + 'contact_notes', + 'contact_tasks', + 'contacts', + 'conversations', + 'custom_fields', + 'custom_values', + 'location_tags', + 'locations', + 'messages', + 'opportunities', + 'pipelines', + 'users', + } +) + +#: Published when the operator has not chosen otherwise: the set an agent needs to +#: run lead nurture and appointment booking end to end, at 74 of the 101 tools. +#: ``users`` is in here despite being administrative because it is 3 read-only tools +#: and it is the only way to resolve the user ids that ``assignedTo``, ``followers`` +#: and ``assignedUserId`` need across contacts, opportunities, tasks and appointments. +DEFAULT_GROUPS = frozenset( + { + 'appointments', + 'calendars', + 'contact_notes', + 'contact_tasks', + 'contacts', + 'conversations', + 'custom_fields', + 'messages', + 'opportunities', + 'pipelines', + 'users', + } +) + +#: The generic escape-hatch tool, gated by ``gohighlevel.allowRawRequest`` instead +#: of by a tool group. +RAW_REQUEST_TOOL = 'request' + + +def group_names(raw) -> list[str]: + """The non-empty names in a configured ``toolGroups`` value, as the operator typed them. + + Accepts a list or a comma-separated string; anything else yields no names. This is + the shared front end of ``normalize_groups`` and ``unknown_groups`` so the runtime + selection and the editor warning can never disagree about what was configured. + """ + if isinstance(raw, str): + raw = raw.split(',') + if not isinstance(raw, (list, tuple, set, frozenset)): + return [] + return [str(g).strip() for g in raw if str(g).strip()] + + +def normalize_groups(raw) -> frozenset: + """Turn the configured ``toolGroups`` value into a set of known group names. + + Matching is case-insensitive, unknown names are ignored (they are surfaced as a + warning by ``IGlobal.validateConfig``), and ``all`` means every implemented group. + An empty or missing value falls back to the defaults. + """ + names = {name.lower() for name in group_names(raw)} + 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 unknown_groups(raw) -> list[str]: + """Configured group names this node does not implement. + + Matches exactly what ``normalize_groups`` accepts, so a name that works at runtime + is never reported as unknown in the editor, and a typo in the comma-separated string + form is caught rather than skipped. Names come back as the operator typed them. + """ + known = ALL_GROUPS | {'all', '*'} + return sorted({name for name in group_names(raw) if name.lower() not in known}) + + +def gohighlevel_tool( + *, group: str, writes: bool = False, input_schema=None, description=None, output_schema=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. + An unknown group name raises here, at import time, so a typo is a module-load + failure rather than a tool that silently never reaches the agent. + + ``writes=True`` marks a tool that creates, updates or deletes something. It is + stamped as ``__gohighlevel_writes__`` and read by the same filter, which drops + write tools from the published set when ``gohighlevel.readOnly`` is on. That is a + deliberate deviation from tool_pipedrive, which publishes its write tools in + read-only mode and refuses them at ``tool.invoke``: an agent cannot know in advance + that a published tool is blocked, so it spends a turn discovering it, and roughly + 40 tools it can only ever fail on are 40 tools' worth of wasted context. Hiding + beats refusing. ``GoHighLevelToolsBase._require_write`` still guards invoke, both + as defence in depth and because the raw ``request`` tool carries no group at all. + """ + if group not in ALL_GROUPS: + raise ValueError(f'gohighlevel_tool: unknown group "{group}"') + + def decorator(fn: Callable) -> Callable: + fn = tool_function(input_schema=input_schema, description=description, output_schema=output_schema)(fn) + fn.__gohighlevel_group__ = group + fn.__gohighlevel_writes__ = bool(writes) + return fn + + return decorator diff --git a/nodes/src/nodes/tool_gohighlevel/tools/__init__.py b/nodes/src/nodes/tool_gohighlevel/tools/__init__.py new file mode 100644 index 000000000..f534e23d7 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/__init__.py @@ -0,0 +1,100 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +GoHighLevel tool mixins, one module per resource group. + +``IInstance`` composes every mixin; which of their tools are actually published is +decided at runtime by the ``gohighlevel.toolGroups`` and ``gohighlevel.readOnly`` +config fields. + +This module is the composition registry. A new group module is registered by adding +its import, one entry to :data:`ALL_MIXINS` and one entry to ``__all__``. +""" + +from __future__ import annotations + +from ._base import GoHighLevelToolsBase +from .appointment_notes import AppointmentNotesMixin +from .appointments import AppointmentsMixin +from .businesses import BusinessesMixin +from .calendar_groups import CalendarGroupsMixin +from .calendars import CalendarsMixin +from .contact_notes import ContactNotesMixin +from .contact_tasks import ContactTasksMixin +from .contacts import ContactsMixin +from .conversations import ConversationsMixin +from .custom_fields import CustomFieldsMixin +from .custom_values import CustomValuesMixin +from .location_tags import LocationTagsMixin +from .locations import LocationsMixin +from .messages import MessagesMixin +from .opportunities import OpportunitiesMixin +from .pipelines import PipelinesMixin +from .users import UsersMixin + +#: Order matters only for readability: the mixins do not override each other. Kept +#: alphabetical so it can be read against the ``IInstance`` bases at a glance. +ALL_MIXINS = ( + AppointmentNotesMixin, + AppointmentsMixin, + BusinessesMixin, + CalendarGroupsMixin, + CalendarsMixin, + ContactNotesMixin, + ContactTasksMixin, + ContactsMixin, + ConversationsMixin, + CustomFieldsMixin, + CustomValuesMixin, + LocationTagsMixin, + LocationsMixin, + MessagesMixin, + OpportunitiesMixin, + PipelinesMixin, + UsersMixin, +) + +__all__ = [ + 'ALL_MIXINS', + 'AppointmentNotesMixin', + 'AppointmentsMixin', + 'BusinessesMixin', + 'CalendarGroupsMixin', + 'CalendarsMixin', + 'ContactNotesMixin', + 'ContactsMixin', + 'ContactTasksMixin', + 'ConversationsMixin', + 'CustomFieldsMixin', + 'CustomValuesMixin', + 'GoHighLevelToolsBase', + 'LocationsMixin', + 'LocationTagsMixin', + 'MessagesMixin', + 'OpportunitiesMixin', + 'PipelinesMixin', + 'UsersMixin', +] diff --git a/nodes/src/nodes/tool_gohighlevel/tools/_base.py b/nodes/src/nodes/tool_gohighlevel/tools/_base.py new file mode 100644 index 000000000..88808f59d --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/_base.py @@ -0,0 +1,796 @@ +# ============================================================================= +# 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 GoHighLevel tool mixins. + +Holds the credential and location accessors, the read-only gate, the ``Version`` resolver, +the pagination builders and the small schema helpers every group module uses, so the tool +definitions themselves stay readable. + +Three things here differ from the Pipedrive node this was ported from, and each is forced by +the API rather than chosen: + +1. Record ids are opaque strings of roughly 20 to 24 characters, not integers, so + :func:`require_id` validates a non-empty string. An integer check would reject every real + GoHighLevel id. +2. There is no single pagination style. The one ``PAGING()`` builder became ten, one per + style in the design brief, each paired with a function that turns the agent-supplied + arguments into the query or body fragment that style actually sends. GoHighLevel ignores + an unrecognised pagination parameter instead of rejecting it, so mixing styles re-fetches + page one forever rather than failing. A tool picks one style and stays on it. Each of the + ten renderers returns ``(fragment, limit)``: the page size travels back to the tool so it + can be handed to :func:`paginated` as ``requested_limit``, which is what keeps ``has_more`` + honest. +3. Unknown properties are rejected with a 422, so a hallucinated parameter name is a hard + API failure carrying vendor wording the agent cannot act on. :func:`args_of` validates the + argument names against the schema the agent was shown and names the valid ones instead. +""" + +from __future__ import annotations + +from typing import Any, Iterable + +from ai.common.utils import normalize_tool_input, require_str, validate_tool_input_schema + +from ..gohighlevel_client import ( + DEFAULT_PAGE_LIMIT, + PAGE_MINIMUMS, + call, + call_envelope, + clamp_limit, + normalize_success, + page_limit, + paginated, + version_for, +) + +TOOL_NAME = 'tool_gohighlevel' + +#: Guidance for writing custom fields, in terms of this node's own input and output rather +#: than GoHighLevel's wire shape. Defined once because every module that writes custom fields +#: needs the same sentence, and because the direction mismatch it describes is the easiest way +#: for an agent to get a write rejected: read tools here return a map, write tools take an +#: array, and the two are not interchangeable. +CUSTOM_FIELDS_DESC = ( + 'The value is an array of {"id": "", "field_value": } objects, never an object ' + 'map: GoHighLevel rejects a map. Get field ids from custom_field_list. Read tools in this node ' + 'return custom fields the other way round, as one object keyed by field id ' + '({"": }), so convert that object back into the array form before sending it: ' + 'a record from a get tool cannot be passed here unchanged.' +) + +#: Appended to the description of every tool that takes the ``extra`` escape hatch. +EXTRA_DESC = ( + 'Any additional GoHighLevel API fields to send verbatim, merged into the request body after ' + f'the typed parameters. Custom fields go here under customFields. {CUSTOM_FIELDS_DESC} ' + 'Do not send tags here. On update and upsert GoHighLevel replaces every tag on the record ' + 'instead of adding to the ones already on it, which is why these tools have no tags ' + 'parameter, and sending tags in extra is rejected before the request goes out. Use ' + 'contact_tags_add and contact_tags_remove to change tags.' +) + +#: Keys the ``extra`` escape hatch must never carry, mapped to what to do instead. +#: +#: ``extra`` exists so an agent can reach a field the typed schema does not cover, but a field +#: a tool deliberately refuses is not such a field. ``tags`` is destructive on update and +#: upsert: GoHighLevel replaces the whole tag array rather than appending to it, so one call +#: carrying tags wipes every tag the agent did not list. The tools advertise that they cannot +#: touch tags, so the guarantee is enforced here rather than merely requested in prose. +EXTRA_FORBIDDEN: dict[str, str] = { + 'tags': ( + 'GoHighLevel replaces every tag on the record rather than adding to them, so sending tags ' + 'on an update or upsert deletes the ones you did not list. Use contact_tags_add and ' + 'contact_tags_remove instead.' + ), +} + + +# --------------------------------------------------------------------------- +# 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 (several + calendar routes answer a bare list and free-slots answers a map keyed by date), 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 MIXED_ARR(description: str) -> dict: + """Array property with no declared item type. + + The cursor arrays are heterogeneous: ``startAfter`` and ``searchAfter`` are both + ``[epoch_milliseconds, id]``, so declaring string items would invite the agent to + stringify the timestamp. + """ + return {'type': 'array', 'description': description} + + +def EXTRA() -> dict: + return OBJ(EXTRA_DESC) + + +# --------------------------------------------------------------------------- +# Output schemas +# +# ``gohighlevel_tool(output_schema=...)`` reaches ``tool_function``, which emits it as +# ``outputSchema`` on the published descriptor, so this is the one channel that tells an agent +# what comes back without it having to call the tool first. Every list tool returns the +# :func:`paginated` envelope, which is this node's own invention rather than anything +# GoHighLevel sends, so an agent has no way to guess it. Pass one of these on every tool. +# --------------------------------------------------------------------------- + + +def LIST_OUTPUT(next_description: str, *, items: str = 'The records on this page.') -> dict: + """Output schema for a tool that returns the :func:`paginated` envelope. + + ``next_description`` says what the cursor is for this one endpoint and how to send it back, + because there is no shared answer: it is a ``{"startAfter", "startAfterId"}`` pair on the + contacts list, an opaque array on the searches, an integer offset on the offset styles and + null on the endpoints that return everything at once. + """ + return { + 'type': 'object', + 'properties': { + 'items': {'type': 'array', 'items': {'type': 'object'}, 'description': items}, + 'count': INT('How many records are in items. This is the page, not the whole result.'), + 'total': { + 'description': ( + 'Total matching records, when GoHighLevel reports one. Most endpoints report none ' + 'and leave this null, so null means unknown rather than zero.' + ) + }, + 'next': {'description': next_description}, + 'has_more': BOOL('Whether another page is expected. Ask for the next page only while this is true.'), + }, + 'required': ['items', 'count', 'has_more'], + } + + +def RECORD_OUTPUT(description: str = 'The record, trimmed to the fields this node returns.') -> dict: + """Output schema for a tool that returns one record.""" + return OBJ(description) + + +# --------------------------------------------------------------------------- +# Argument helpers +# --------------------------------------------------------------------------- + + +def args_of(args: Any, input_schema: dict | None = None, tool: str = '') -> dict: + """Normalise agent-supplied tool input to a plain dict, and reject undeclared parameters. + + Pass ``input_schema`` and ``tool`` to get the name check. It is worth having here in a way + it was not on the Pipedrive node: GoHighLevel answers 422 for a property it does not know, + with wording like ``property locationId should not exist``, which reads to an agent as a + server fault rather than as its own mistake. Validating locally turns that into a message + naming the parameters this tool accepts. + + In a tool body prefer :meth:`GoHighLevelToolsBase._args`, which looks the schema up from + the decorated method so the two can never drift apart. + """ + out = normalize_tool_input(args, tool_name=TOOL_NAME) + if input_schema: + validate_tool_input_schema(input_schema, out, tool_name=tool or TOOL_NAME) + return out + + +def body_from( + args: dict, + keys: Iterable[str], + *, + extra_key: str = 'extra', + tool: str = '', + forbidden: dict[str, str] | None = None, +) -> 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, and so nothing undeclared reaches the API: GoHighLevel rejects an unknown + property with a 422 rather than ignoring it. + + ``extra`` is merged verbatim, which is the point of it, so it is also a way around every + field a tool deliberately leaves out. :data:`EXTRA_FORBIDDEN` names the fields where that + is destructive rather than merely undeclared, and they raise here with the safe tool named. + Pass ``forbidden={}`` on a tool that genuinely owns one of those fields. + """ + body = {k: args[k] for k in keys if args.get(k) is not None} + extra = args.get(extra_key) + if not isinstance(extra, dict): + return body + blocked = EXTRA_FORBIDDEN if forbidden is None else forbidden + for key, reason in blocked.items(): + if key in extra: + label = f'{tool}: ' if tool else '' + raise ValueError(f'{label}"{key}" cannot be sent through "{extra_key}". {reason}') + 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) -> str: + """Read a required record id. + + GoHighLevel ids are opaque strings of roughly 20 to 24 characters, so this is a string + check. The Pipedrive node validates ids as integers, which would reject every real id + here. + """ + return require_str(args, key, tool_name=tool) + + +def require_text(args: dict, key: str, tool: str) -> str: + return require_str(args, key, tool_name=tool) + + +# --------------------------------------------------------------------------- +# Pagination +# +# Ten styles, one section per style, lettered as in the design brief. Each style is a schema +# builder that supplies the properties an agent sees, plus the function that renders those +# arguments into the query or body fragment the endpoint accepts. The page size is per +# endpoint (20 for appointment notes, 100 for contacts, 500 for the message export), so every +# builder takes the tool name and reads its own ceiling. +# +# The parameter names below are the ones the API actually takes, which are not uniform: the +# contacts list pages on startAfter plus startAfterId, POST /contacts/search calls the same +# idea searchAfter and calls the page size pageLimit rather than limit, and +# GET /opportunities/search takes the sub-account as location_id in snake case while every +# other endpoint on this surface spells it locationId. An unrecognised pagination parameter is +# ignored rather than rejected, so getting a name wrong re-fetches page one forever. +# +# Every renderer returns ``(fragment, limit)`` and always sends a limit, even when the agent +# supplied none. Both halves matter. Sending it makes the page size a number the tool knows +# instead of an undocumented server default, and returning it lets the tool pass +# ``requested_limit=`` to paginated(), which is the only way has_more can be right: these +# cursors are carried on the records rather than on the response root, so a cursor exists on +# the last page too and cursor presence alone claims another page on every listing that fits +# in one. +# --------------------------------------------------------------------------- + + +def _limit(tool: str, note: str = '') -> dict: + """Limit property whose stated range is the one this endpoint actually accepts.""" + floor = PAGE_MINIMUMS.get(tool, 1) + ceiling = page_limit(tool) + sent = max(floor, min(DEFAULT_PAGE_LIMIT, ceiling)) + text = f'Number of records to return ({floor}-{ceiling}). {sent} is sent when this is omitted.' + return INT(f'{text} {note}' if note else text) + + +def _int_arg(args: dict, key: str, floor: int = 0) -> int | None: + """Read one pagination argument as an int, or None when it is absent or unusable.""" + value = args.get(key) + if value is None: + return None + try: + return max(floor, int(value)) + except (TypeError, ValueError): + return None + + +# -- style A: twin cursor (GET /contacts/, GET /opportunities/search) ------- + + +def PAGING_START_AFTER(tool: str) -> dict: + """Style A schema properties: the ``startAfter`` plus ``startAfterId`` twin cursor.""" + return { + 'startAfter': INT('Cursor: the startAfter value from the previous call, which is epoch milliseconds.'), + 'startAfterId': STR('Cursor: the startAfterId value from the previous call, which is a record id.'), + 'limit': _limit(tool), + } + + +def start_after_params(args: dict, tool: str) -> tuple[dict, int]: + """Style A query parameters, plus the page size sent. + + ``startAfter`` and ``startAfterId`` are the names both endpoints on this style take, and + both must be sent together. This builder covers paging only: on + ``GET /opportunities/search`` the tool supplies the sub-account itself as ``location_id``, + in snake case, because that endpoint rejects the camelCase ``locationId`` with a 422. + """ + limit = clamp_limit(tool, args.get('limit')) + params = params_from(args, ('startAfter', 'startAfterId')) + params['limit'] = limit + return params, limit + + +# -- style B: body cursor (POST /contacts/search) --------------------------- + + +def PAGING_SEARCH_AFTER(tool: str) -> dict: + """Style B schema properties: the ``searchAfter`` body cursor.""" + return { + 'searchAfter': MIXED_ARR( + 'Cursor: the next value from the previous call, passed back unchanged. GoHighLevel puts ' + 'it on the last record of the page rather than on the response root, and this node ' + 'surfaces it under next.' + ), + 'limit': _limit(tool, 'Sent to the API as pageLimit, which is what this endpoint calls it.'), + } + + +def search_after_body(args: dict, tool: str) -> tuple[dict, int]: + """Style B body fragment, plus the page size sent. + + ``POST /contacts/search`` names the cursor ``searchAfter`` and the page size ``pageLimit``. + It does not accept ``limit``, and it ignores ``startAfter``. + """ + limit = clamp_limit(tool, args.get('limit')) + body = params_from(args, ('searchAfter',)) + body['pageLimit'] = limit + return body, limit + + +# -- style C: body cursor plus page number (POST /opportunities/search) ----- + + +def PAGING_SEARCH_AFTER_PAGE(tool: str) -> dict: + """Style C schema properties: the ``searchAfter`` body cursor alongside a page number.""" + return { + 'searchAfter': MIXED_ARR('Cursor: the next value from the previous call.'), + 'page': INT( + 'Page number. GoHighLevel documents this as 1-based on the v2 GET form of this path ' + 'and 0-based on the v3 spec for the same path, so prefer searchAfter for deep paging.' + ), + 'limit': _limit(tool), + } + + +def search_after_page_body(args: dict, tool: str) -> tuple[dict, int]: + """Style C body fragment, plus the page size sent. + + ``POST /opportunities/search`` takes ``searchAfter`` with ``limit``, not ``pageLimit``. + """ + limit = clamp_limit(tool, args.get('limit')) + body = params_from(args, ('searchAfter',)) + page = _int_arg(args, 'page') + if page is not None: + body['page'] = page + body['limit'] = limit + return body, limit + + +# -- style D: offset, declared as strings (GET /businesses/, GET /users/search) -- + + +def PAGING_SKIP_TEXT(tool: str) -> dict: + """Style D schema properties: ``skip`` plus ``limit``, which this endpoint types as strings.""" + return { + 'skip': INT('Number of records to skip, 0-based (default 0).'), + 'limit': _limit(tool), + } + + +def skip_text_params(args: dict, tool: str) -> tuple[dict, int]: + """Style D query parameters, plus the page size sent. + + Both values are stringified because the endpoint declares them as strings. The returned + limit stays an int, since it is compared against a record count rather than sent. + """ + limit = clamp_limit(tool, args.get('limit')) + params: dict = {'limit': str(limit)} + skip = _int_arg(args, 'skip') + if skip is not None: + params['skip'] = str(skip) + return params, limit + + +# -- style E: offset in the body as numbers (POST /locations/{id}/tasks/search) -- + + +def PAGING_SKIP_BODY(tool: str) -> dict: + """Style E schema properties: ``skip`` plus ``limit`` sent in the body as numbers.""" + return { + 'skip': INT('Number of records to skip, 0-based (default 0).'), + 'limit': _limit(tool), + } + + +def skip_body(args: dict, tool: str) -> tuple[dict, int]: + """Style E body fragment, plus the page size sent. Both go on the body as numbers.""" + limit = clamp_limit(tool, args.get('limit')) + body: dict = {'limit': limit} + skip = _int_arg(args, 'skip') + if skip is not None: + body['skip'] = skip + return body, limit + + +# -- style F: offset, both required (GET /calendars/appointments/{id}/notes) -- + + +def PAGING_OFFSET(tool: str) -> dict: + """Style F schema properties: ``limit`` plus ``offset``, both required by the endpoint.""" + return { + 'offset': INT('Number of records to skip, 0-based. Required by this endpoint, sent as 0 when omitted.'), + 'limit': _limit(tool, 'Required by this endpoint and capped low, so it is always sent.'), + } + + +def offset_params(args: dict, tool: str) -> tuple[dict, int]: + """Style F query parameters, plus the page size sent. + + Both are declared required with no server-side default, so both are always sent. + """ + limit = clamp_limit(tool, args.get('limit')) + return {'offset': _int_arg(args, 'offset') or 0, 'limit': limit}, limit + + +# -- style G: optional skip offset (calendar notifications, schedules, resources) -- + + +def PAGING_SKIP(tool: str) -> dict: + """Style G schema properties: optional ``skip`` plus ``limit`` query parameters.""" + return { + 'skip': INT('Number of records to skip, 0-based (default 0).'), + 'limit': _limit(tool), + } + + +def skip_params(args: dict, tool: str) -> tuple[dict, int]: + """Style G query parameters, plus the page size sent.""" + limit = clamp_limit(tool, args.get('limit')) + params: dict = {'limit': limit} + skip = _int_arg(args, 'skip') + if skip is not None: + params['skip'] = skip + return params, limit + + +# -- style H: keyset on the last message (GET /conversations/{id}/messages) -- + + +def PAGING_LAST_MESSAGE_ID(tool: str) -> dict: + """Style H schema properties: the ``lastMessageId`` keyset cursor.""" + return { + 'lastMessageId': STR('Cursor: the next value from the previous call, which is the id of its last message.'), + 'limit': _limit(tool), + } + + +def last_message_id_params(args: dict, tool: str) -> tuple[dict, int]: + """Style H query parameters, plus the page size sent.""" + limit = clamp_limit(tool, args.get('limit')) + params = params_from(args, ('lastMessageId',)) + params['limit'] = limit + return params, limit + + +# -- style I: date cursor (GET /conversations/search) ----------------------- + + +def PAGING_START_AFTER_DATE(tool: str) -> dict: + """Style I schema properties: the ``startAfterDate`` cursor. + + The response carries no cursor field, so the value is the sort value of the last record on + the previous page and the tool derives it from that record. + """ + return { + 'startAfterDate': STR('Cursor: the next value from the previous call, taken from its last record.'), + 'limit': _limit(tool), + } + + +def start_after_date_params(args: dict, tool: str) -> tuple[dict, int]: + """Style I query parameters, plus the page size sent.""" + limit = clamp_limit(tool, args.get('limit')) + params = params_from(args, ('startAfterDate',)) + params['limit'] = limit + return params, limit + + +# -- style J: opaque cursor (GET /conversations/messages/export) ------------ + + +def PAGING_CURSOR(tool: str) -> dict: + """Style J schema properties: the opaque ``cursor`` token.""" + return { + 'cursor': STR('Cursor: the next value from the previous call. It is opaque, so pass it back unchanged.'), + 'limit': _limit(tool), + } + + +def cursor_params(args: dict, tool: str) -> tuple[dict, int]: + """Style J query parameters, plus the page size sent.""" + limit = clamp_limit(tool, args.get('limit')) + params = params_from(args, ('cursor',)) + params['limit'] = limit + return params, limit + + +# --------------------------------------------------------------------------- +# Response helpers +# --------------------------------------------------------------------------- + + +def items_of(payload: Any, key: str) -> list: + """Records out of a response, under the envelope key that endpoint happens to use. + + GoHighLevel has no shared envelope: every endpoint names its own top-level key and a few + calendar routes answer a bare array, so the key is hardcoded per tool and a list payload + is handed back untouched. + """ + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + value = payload.get(key) + if isinstance(value, list): + return value + return [] + + +def record_of(payload: Any, key: str | tuple[str, ...] = '') -> Any: + """Single record out of a response, unwrapping the envelope key when there is one. + + ``GET /contacts/{contactId}`` answers ``{"contact": {...}}`` while + ``POST /calendars/events/appointments`` answers the appointment bare, so the key is + applied only when the payload actually carries it. + + Several keys may be named, and they are tried in order. That is not tidiness. On + ``GET /calendars/events/appointments/{eventId}`` the published spec declares ``event`` and + the live API answers ``appointment``, so a tool that names either one alone hands the whole + envelope to its cleaner, the cleaner matches none of the fields it looks for, and the tool + returns an empty record with no error at all. Name every key there is evidence for, and + take the evidence from the spec or from a live capture rather than from the resource name. + """ + if not isinstance(payload, dict): + return payload + for name in (key,) if isinstance(key, str) else key: + if name and isinstance(payload.get(name), dict): + return payload[name] + return payload + + +def start_after_cursor(records: Iterable[Any]) -> dict | None: + """Style A cursor: the ``startAfter`` pair carried by the last record of a page. + + Read it from the records rather than from ``meta.nextPageUrl``, whose documented example + leaks a localhost address. + """ + for record in reversed(list(records or [])): + if not isinstance(record, dict): + continue + pair = record.get('startAfter') + if isinstance(pair, (list, tuple)) and len(pair) >= 2: + return {'startAfter': pair[0], 'startAfterId': pair[1]} + return None + + +def search_after_cursor(records: Iterable[Any]) -> Any: + """Styles B, C and E cursor: the ``searchAfter`` value on the last element of the page. + + It sits on the last array element, not on the response root. + """ + for record in reversed(list(records or [])): + if isinstance(record, dict) and record.get('searchAfter') is not None: + return record['searchAfter'] + return None + + +def next_cursor_of(payload: Any) -> Any: + """Style J cursor: ``nextCursor``, which is absent as often as it is null at the end.""" + if not isinstance(payload, dict): + return None + return payload.get('nextCursor') + + +def next_offset(current: Any, limit: int, count: int) -> int | None: + """Next offset for an offset-paged style, or None when the page came back short.""" + if count < limit: + return None + try: + start = max(0, int(current or 0)) + except (TypeError, ValueError): + start = 0 + return start + count + + +# --------------------------------------------------------------------------- +# Mixin base +# --------------------------------------------------------------------------- + + +class GoHighLevelToolsBase: + """Credential access, request helpers, and the read-only gate. + + Every group mixin inherits from this; ``IInstance`` supplies ``IGlobal``. + """ + + # -- credentials and scope -------------------------------------------- + + def _token(self) -> str: + return self.IGlobal.token + + def _location(self) -> str: + """Configured sub-account id. + + Nearly every operation needs it, but it cannot be injected centrally: it goes to the + query string, to a snake_case query string (``GET /opportunities/search`` takes + ``location_id`` and rejects ``locationId`` with a 422), to the request body, or to the + path, depending on the operation. Each tool places it itself. + """ + return self.IGlobal.location_id + + 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') + + # -- version ---------------------------------------------------------- + + def _version_for(self, path: str, version: str | None = None) -> str: + """Value of the ``Version`` header for a path, with an optional per-call override. + + /calendars and /conversations are on 2021-04-15 and everything else is on 2021-07-28, + which :func:`version_for` derives from the path. A mixin covering a path that map does + not classify declares a module-level constant and passes ``version=`` at the call + site. Do not put such an override on the class: ``IInstance`` composes every mixin + into a single type, so a class attribute set by one mixin would silently apply to all + of them. + """ + return version or version_for(path) + + # -- requests --------------------------------------------------------- + + def _call(self, method: str, path: str, *, version: str | None = None, **kwargs: Any) -> Any: + return call(self._token(), method, path, version=self._version_for(path, version), **kwargs) + + def _call_envelope(self, method: str, path: str, *, version: str | None = None, **kwargs: Any) -> Any: + return call_envelope(self._token(), method, path, version=self._version_for(path, version), **kwargs) + + def _fetch( + self, + method: str, + path: str, + *, + key: str, + params: dict | None = None, + body: dict | None = None, + ) -> tuple[Any, list]: + """Issue a list request and return the parsed payload together with its raw records. + + Cursor extraction differs by style and several cursors live on the records rather than + on the payload, so a tool that pages takes the raw records from here, cleans them, and + assembles its own :func:`paginated` result. + """ + payload = self._call(method, path, params=params, body=body) + return payload, items_of(payload, key) + + def _list(self, path: str, *, key: str, cleaner, params: dict | None = None, total_key: str = '') -> dict: + """GET a collection that answers with everything in one response. + + This covers the unpaginated style only, which is most of the calendar and location + surface. A paginated tool calls :meth:`_fetch` and builds its own cursor, because no + single helper can serve ten pagination styles without guessing. + """ + payload, records = self._fetch('GET', path, key=key, params=params) + total = payload.get(total_key) if total_key and isinstance(payload, dict) else None + return paginated([cleaner(record) for record in records], total=total) + + def _get(self, path: str, cleaner, *, key: str | tuple[str, ...] = '', params: dict | None = None) -> dict: + return cleaner(record_of(self._call('GET', path, params=params), key)) + + def _write( + self, + method: str, + path: str, + cleaner, + *, + key: str | tuple[str, ...] = '', + body: dict | None = None, + params: dict | None = None, + ) -> dict: + self._require_write() + return cleaner(record_of(self._call(method, path, body=body, params=params), key)) + + def _delete(self, path: str, *, body: dict | None = None, params: dict | None = None) -> dict: + """DELETE a record and report the outcome. + + ``body`` is not vestigial: five operations on this surface declare a required JSON + body on a DELETE. The success flag is spelled three different ways across the API, so + it is read through :func:`normalize_success` rather than by name, and the raw payload + stays reachable under ``data``. + """ + self._require_write() + payload = self._call('DELETE', path, body=body, params=params) + return {'deleted': normalize_success(payload), 'data': payload} + + # -- arguments -------------------------------------------------------- + + def _args(self, args: Any, tool: str) -> dict: + """Normalise tool input and reject parameters the named tool does not declare. + + The schema comes from the ``@gohighlevel_tool`` decorator on that method, so the + allowed set can never drift from the one the agent was shown. Prefer this over the + bare :func:`args_of` inside a tool body. + """ + return args_of(args, _declared_schema(type(self), tool), tool) + + +def _declared_schema(owner: type, tool: str) -> dict | None: + """Input schema a decorated tool method declares, or None when it declares none. + + Raises when ``tool`` names no method at all. The distinction matters: ``tool`` is a + hand-typed literal repeated at several call sites per tool, and a lookup that returned + None for a name that does not exist would turn the undeclared-parameter guard off without + a word. That guard is the reason a hallucinated parameter comes back as a message naming + the parameters this tool takes instead of a GoHighLevel 422 saying a property should not + exist, so losing it silently is worse than losing it loudly. Failing here makes a typo a + hard error on the first invoke of that tool. + """ + if not hasattr(owner, tool): + raise ValueError( + f'{tool}: no such tool method on {owner.__name__}. The name passed to _args must match the ' + f'method name exactly, otherwise the undeclared-parameter check is skipped.' + ) + meta = getattr(getattr(owner, tool), '__tool_meta__', None) + if isinstance(meta, dict) and isinstance(meta.get('input_schema'), dict): + return meta['input_schema'] + return None diff --git a/nodes/src/nodes/tool_gohighlevel/tools/appointment_notes.py b/nodes/src/nodes/tool_gohighlevel/tools/appointment_notes.py new file mode 100644 index 000000000..34071a03f --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/appointment_notes.py @@ -0,0 +1,202 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Appointment note tools: the notes attached to one calendar appointment.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + LIST_OUTPUT, + PAGING_OFFSET, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + offset_params, + require_id, + require_text, + schema, +) + +#: Fields kept from an appointment note. GetNoteSchema declares exactly these six and marks none of +#: them required, so every one is optional on the way out. +#: +#: ``dateAdded`` is ISO 8601 in UTC with milliseconds and a trailing Z, which is a third date format +#: inside the calendars area: the appointment itself carries a numeric offset instead, and the +#: window parameters on the event reads are epoch milliseconds. +_NOTE_READ_KEYS = ('id', 'body', 'userId', 'contactId', 'dateAdded', 'createdBy') + +#: Body fields shared by appointment_notes_create and appointment_notes_update. NotesDTO declares +#: these two and nothing else, on both operations, so the two writes share one tuple and one props +#: dict. There is no ``extra`` escape hatch for the same reason as on the appointment writes: the +#: DTO is fully covered here, and GoHighLevel rejects an undeclared property rather than ignoring it. +_NOTE_WRITE_KEYS = ('body', 'userId') + +_NOTE_WRITE_PROPS = { + 'body': STR('Text of the note, up to 5000 characters.'), + 'userId': STR( + 'Id of the user the note is attributed to. Get user ids from user_list_by_location. Leave it out ' + 'to let GoHighLevel attribute the note to the owner of the token.' + ), +} + +_NOTE_RECORD_OUTPUT = RECORD_OUTPUT( + 'The note, trimmed to the fields this node returns. dateAdded is ISO 8601 in UTC with a trailing Z, ' + 'unlike the offset-bearing times on the appointment itself.' +) + + +def _clean_note(note: Any) -> dict: + """Trim an appointment note to the fields this node returns.""" + if not isinstance(note, dict): + return {} + return {key: note[key] for key in _NOTE_READ_KEYS if key in note} + + +class AppointmentNotesMixin(GoHighLevelToolsBase): + """Tools for the ``appointment_notes`` group.""" + + @gohighlevel_tool( + group='appointment_notes', + input_schema=schema( + required=['appointmentId'], + appointmentId=STR('Id of the appointment. Get appointment ids from appointment_list.'), + **PAGING_OFFSET('appointment_notes_list'), + ), + description=( + 'List the notes written on one appointment. This one pages by offset and caps the page at 20 ' + 'records, which is the lowest cap in this node: next is the offset to ask for next, and it is null ' + 'once GoHighLevel says there is nothing after this page. GoHighLevel reports no total here, so total ' + 'is always null.' + ), + output_schema=LIST_OUTPUT( + 'Offset for the next page: pass it back as the offset parameter. Null when GoHighLevel reports no ' + 'more notes after this page.', + items='The notes on this page, each trimmed to the fields this node returns.', + ), + ) + def appointment_notes_list(self, args): + args = self._args(args, 'appointment_notes_list') + appointment_id = require_id(args, 'appointmentId', 'appointment_notes_list') + params, limit = offset_params(args, 'appointment_notes_list') + payload, records = self._fetch( + 'GET', + f'/calendars/appointments/{appointment_id}/notes', + key='notes', + params=params, + ) + # This is the one endpoint in the calendars area that states whether another page exists, so + # its own hasMore wins over the short-page rule. The cursor is derived from the offset that + # was actually sent rather than from the argument, which may have been absent or unusable. + has_more = payload.get('hasMore') if isinstance(payload, dict) else None + next_offset = params['offset'] + len(records) if records else None + return paginated( + [_clean_note(record) for record in records], + next_cursor=next_offset, + has_more=has_more, + requested_limit=limit, + ) + + @gohighlevel_tool( + group='appointment_notes', + writes=True, + input_schema=schema( + required=['appointmentId', 'body'], + appointmentId=STR('Id of the appointment to write the note on.'), + **_NOTE_WRITE_PROPS, + ), + description=( + 'Write a note on an appointment. These notes belong to the appointment, not to the contact: use ' + 'contact_notes_create for a note that should stay on the contact record after the appointment is over.' + ), + output_schema=_NOTE_RECORD_OUTPUT, + ) + def appointment_notes_create(self, args): + args = self._args(args, 'appointment_notes_create') + appointment_id = require_id(args, 'appointmentId', 'appointment_notes_create') + require_text(args, 'body', 'appointment_notes_create') + body = body_from(args, _NOTE_WRITE_KEYS, tool='appointment_notes_create') + return self._write( + 'POST', + f'/calendars/appointments/{appointment_id}/notes', + _clean_note, + key='note', + body=body, + ) + + @gohighlevel_tool( + group='appointment_notes', + writes=True, + input_schema=schema( + required=['appointmentId', 'noteId', 'body'], + appointmentId=STR('Id of the appointment the note is on.'), + noteId=STR('Id of the note to change. Get note ids from appointment_notes_list.'), + **_NOTE_WRITE_PROPS, + ), + description=( + 'Replace the text of a note on an appointment. This is not a partial update: body is required and ' + 'the note text becomes exactly what you send, so read the note first when you mean to append to it.' + ), + output_schema=_NOTE_RECORD_OUTPUT, + ) + def appointment_notes_update(self, args): + args = self._args(args, 'appointment_notes_update') + appointment_id = require_id(args, 'appointmentId', 'appointment_notes_update') + note_id = require_id(args, 'noteId', 'appointment_notes_update') + require_text(args, 'body', 'appointment_notes_update') + body = body_from(args, _NOTE_WRITE_KEYS, tool='appointment_notes_update') + # noteId is in the path template but is missing from the declared parameters of both this + # operation and the delete below, which is a defect in the published spec rather than a real + # quirk: the rendered vendor documentation does list it. Substituted here by hand. + return self._write( + 'PUT', + f'/calendars/appointments/{appointment_id}/notes/{note_id}', + _clean_note, + key='note', + body=body, + ) + + @gohighlevel_tool( + group='appointment_notes', + writes=True, + input_schema=schema( + required=['appointmentId', 'noteId'], + appointmentId=STR('Id of the appointment the note is on.'), + noteId=STR('Id of the note to delete.'), + ), + description='Delete a note from an appointment.', + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def appointment_notes_delete(self, args): + args = self._args(args, 'appointment_notes_delete') + appointment_id = require_id(args, 'appointmentId', 'appointment_notes_delete') + note_id = require_id(args, 'noteId', 'appointment_notes_delete') + return self._delete(f'/calendars/appointments/{appointment_id}/notes/{note_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/appointments.py b/nodes/src/nodes/tool_gohighlevel/tools/appointments.py new file mode 100644 index 000000000..8fda99aa7 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/appointments.py @@ -0,0 +1,558 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Appointment tools: calendar events, the appointment record, free slots and blocked slots.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import date_keyed +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + ENUM, + INT, + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + params_from, + require_id, + require_text, + schema, +) + +#: Fields kept from a calendar event. +#: +#: One tuple covers three response shapes on purpose. GET /calendars/events and +#: GET /calendars/blocked-slots both answer CalendarEventDTO, the appointment writes answer +#: AppointmentSchemaResponse and the block-slot writes answer BlockedSlotSuccessfulResponseDto, +#: and the latter two are subsets of the first apart from meetingLocationType. Keeping one tuple +#: means a blocked slot and an appointment come back looking alike, which is what they are on +#: the wire: a blocked slot is a calendar event with no contact. +#: +#: startTime, endTime, dateAdded and dateUpdated are passed through untouched. The spec declares +#: all four as objects and sends ISO 8601 strings, and the vendor docs repo has an open bug for +#: appointment responses missing dateAdded and dateUpdated entirely, so nothing here may assume +#: a type or a presence. +_EVENT_READ_KEYS = ( + 'id', + 'locationId', + 'calendarId', + 'contactId', + 'groupId', + 'title', + 'appointmentStatus', + 'assignedUserId', + 'users', + 'address', + 'meetingLocationType', + 'meetingLocationId', + 'notes', + 'description', + 'isRecurring', + 'rrule', + 'masterEventId', + 'startTime', + 'endTime', + 'dateAdded', + 'dateUpdated', + 'assignedResources', + 'createdBy', +) + +#: Body fields shared by appointment_create and appointment_update. +#: +#: ``locationId`` is absent because the node supplies it from config on the create, and because +#: AppointmentEditSchema has no such property at all. ``contactId`` is absent for the same +#: reason: it is a create-only field, so an appointment cannot be moved to another contact. +#: +#: There is no ``extra`` escape hatch on this module. Both appointment DTOs are fully enumerated +#: here, GoHighLevel rejects an undeclared property with a 4xx rather than ignoring it, and the +#: shared EXTRA_DESC text is about contact custom fields and tags, neither of which exists on a +#: calendar endpoint. Offering it would advertise fields that can only fail. +_APPOINTMENT_WRITE_KEYS = ( + 'calendarId', + 'title', + 'startTime', + 'endTime', + 'appointmentStatus', + 'assignedUserId', + 'address', + 'description', + 'meetingLocationType', + 'meetingLocationId', + 'overrideLocationConfig', + 'ignoreDateRange', + 'ignoreFreeSlotValidation', + 'toNotify', + 'rrule', +) + +#: How an appointment carries its timezone, stated once because create, update and the free-slots +#: read all need it. This is the single most expensive thing to get wrong here: there is no +#: timezone field anywhere on an appointment, so a start time written without an offset is read +#: in whatever zone GoHighLevel picks and the booking silently lands in the wrong hour. +_APPOINTMENT_TIMEZONE_DESC = ( + 'Appointments carry no timezone field: the numeric offset inside startTime is the only timezone ' + 'information the appointment has. Take a slot string from appointment_free_slots_list and send it ' + 'back unchanged rather than converting it or dropping the offset.' +) + +_APPOINTMENT_WRITE_PROPS = { + 'calendarId': STR('Id of the calendar to book on. Get calendar ids from calendar_list.'), + 'title': STR('Title of the appointment, as it shows on the calendar.'), + 'startTime': STR( + 'Start time as ISO 8601 carrying a numeric offset, for example "2021-06-23T03:30:00+05:30". ' + f'{_APPOINTMENT_TIMEZONE_DESC}' + ), + 'endTime': STR( + 'End time, in the same form as startTime. Leave it out to let GoHighLevel derive it from the ' + 'slot duration configured on the calendar.' + ), + 'appointmentStatus': ENUM( + 'Status of the appointment. Reads can also return "active" and "completed", which GoHighLevel ' + 'sets itself and does not accept here.', + ('new', 'confirmed', 'cancelled', 'showed', 'noshow', 'invalid'), + ), + 'assignedUserId': STR('Id of the user who owns the appointment. Get user ids from user_list_by_location.'), + 'address': STR('Appointment address: a street address, a meeting URL or a phone number.'), + 'description': STR('Longer description of the appointment.'), + 'meetingLocationType': ENUM( + 'Where the meeting happens. Passing address sets this to "custom" unless you say otherwise.', + ('custom', 'zoom', 'gmeet', 'phone', 'address', 'ms_teams', 'google'), + ), + 'meetingLocationId': STR( + 'Id of a meeting location already configured on the calendar. The ids are on the calendar ' + 'record under locationConfigurations and teamMembers[].locationConfigurations.' + ), + 'overrideLocationConfig': BOOL( + 'Whether to override the meeting-location configuration on the calendar. Send true when you ' + 'pass meetingLocationType on its own and false when you pass meetingLocationId on its own.' + ), + 'ignoreDateRange': BOOL( + 'Whether to ignore the minimum scheduling notice and the bookable date range on the calendar.' + ), + 'ignoreFreeSlotValidation': BOOL( + 'Whether to book the time even when it is not a free slot. This also overrides ignoreDateRange, ' + 'and rrule is applied only when it is true.' + ), + 'toNotify': BOOL('Whether the sub-account automations run for this appointment. Send false to book quietly.'), + 'rrule': STR( + 'Recurrence rule in iCalendar (RFC 5545) form, for example "RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5". ' + 'GoHighLevel applies it only when ignoreFreeSlotValidation is true, and it derives each instance ' + 'from startTime rather than from a DTSTART line.' + ), +} + +#: Body keys and schema properties for appointment_create only. ``contactId`` is required there and +#: undeclared on the update DTO, so each operation sends what its own DTO declares. +_APPOINTMENT_CREATE_KEYS = _APPOINTMENT_WRITE_KEYS + ('contactId',) +_APPOINTMENT_CREATE_PROPS = { + **_APPOINTMENT_WRITE_PROPS, + 'contactId': STR( + 'Id of the contact the appointment is booked for. Get contact ids from contact_search. An ' + 'appointment cannot be moved to another contact afterwards, so this cannot be changed by ' + 'appointment_update.' + ), +} + +#: Body fields shared by blocked_slot_create and blocked_slot_update. ``locationId`` is supplied +#: from config on both. +_BLOCKED_SLOT_WRITE_KEYS = ('calendarId', 'assignedUserId', 'title', 'startTime', 'endTime') + +#: What GoHighLevel says about the two owner fields on a blocked slot, stated once because the +#: create and the update contradict themselves in the same way. Both request schemas list +#: ``calendarId`` under ``required`` while the field description on both fields says either +#: calendarId or assignedUserId can be set and not both. This node requires one of the two rather +#: than requiring calendarId: refusing the user-owned form locally would make a documented request +#: impossible, and there is no live evidence either way because the trial sub-account has no +#: calendars. +_BLOCKED_SLOT_OWNER_DESC = ( + 'Pass either calendarId or assignedUserId. GoHighLevel documents that only one of the two may be ' + 'set, while its own schema marks calendarId required, so if a request carrying only assignedUserId ' + 'is rejected, send calendarId instead.' +) + +_BLOCKED_SLOT_WRITE_PROPS = { + 'calendarId': STR(f'Id of the calendar whose time is blocked. {_BLOCKED_SLOT_OWNER_DESC}'), + 'assignedUserId': STR(f'Id of the user whose time is blocked. {_BLOCKED_SLOT_OWNER_DESC}'), + 'title': STR('Title of the block, as it shows on the calendar.'), + 'startTime': STR( + 'Start of the block as ISO 8601 carrying a numeric offset, for example "2021-06-23T03:30:00+05:30". ' + 'A blocked slot carries no timezone field either, so the offset is the only timezone it has.' + ), + 'endTime': STR('End of the block, in the same form as startTime.'), +} + +#: The three parameters that say whose events to read. Both window endpoints declare all three +#: optional and then reject a request carrying none of them. +_EVENT_SCOPE_KEYS = ('calendarId', 'userId', 'groupId') + +_EVENT_SCOPE_DESC = ( + 'Say whose events to read with exactly one of calendarId, userId or groupId. GoHighLevel declares all ' + 'three optional and then requires one of them, and it documents no behaviour for sending more than one.' +) + +#: Schema properties shared by the two window-bounded reads. +#: +#: startTime and endTime here are epoch milliseconds declared as strings, which is neither the +#: offset-bearing ISO string an appointment body takes nor the epoch number free-slots takes. Three +#: spellings of the same instant across four endpoints in one group, so each one says its own. +_EVENT_WINDOW_PROPS = { + 'calendarId': STR(f'Id of the calendar to read. {_EVENT_SCOPE_DESC}'), + 'userId': STR(f'Id of the user who owns the events. {_EVENT_SCOPE_DESC}'), + 'groupId': STR(f'Id of the calendar group to read. {_EVENT_SCOPE_DESC}'), + 'startTime': STR('Start of the window, in epoch milliseconds, for example "1680373800000".'), + 'endTime': STR('End of the window, in epoch milliseconds, for example "1680978599999".'), +} + +_DAY_MS = 24 * 60 * 60 * 1000 + +#: The free-slots window GoHighLevel accepts, stated verbatim in the parameter descriptions of both +#: startDate and endDate: "Date range cannot be more than 31 days". Checked locally so a too-wide +#: window costs nothing instead of a request against a measured 25-request/10s budget. +_FREE_SLOTS_MAX_RANGE_MS = 31 * _DAY_MS + +#: Envelope keys ``GET /calendars/events/appointments/{eventId}`` has been seen to use. +#: +#: The live API answers ``{"appointment": {...}, "traceId": ...}``. Both published specs declare +#: ``event`` instead, and neither spelling appears on the sibling create and update, which answer +#: the record bare. The wire spelling is listed first because it is what a real call returns, the +#: spec spelling second so a vendor correction does not break this the other way round. +#: +#: This is the only route in the group that wraps at all, so the tuple stays here rather than +#: being applied across the module: the create and the update are live-confirmed bare, and naming +#: a key they do not send would only invite the same guess in reverse. +_APPOINTMENT_GET_KEYS = ('appointment', 'event') + +_EVENT_ID_DESC = ( + 'Id of the event. For a recurring appointment this is either the id of the whole series or the id of ' + 'one instance, which is spelled "__". Pass the series id to ' + 'change every occurrence and an instance id to change one.' +) + +_EVENT_ITEMS = 'The calendar events in the window, each trimmed to the fields this node returns.' + +_EVENT_RECORD_OUTPUT = RECORD_OUTPUT( + 'The calendar event, trimmed to the fields this node returns. Times are ISO 8601 strings carrying a ' + 'numeric offset, and there is no separate timezone field.' +) + + +def _clean_event(event: Any) -> dict: + """Trim a calendar event, an appointment or a blocked slot to the fields this node returns.""" + if not isinstance(event, dict): + return {} + return {key: event[key] for key in _EVENT_READ_KEYS if key in event} + + +def _require_any_of(values: dict, keys: tuple[str, ...], tool: str, note: str = '') -> None: + """Fail locally when a request carries none of the fields its endpoint needs. + + A copy of the helper in ``contacts.py``, which says it belongs in ``_base.py`` as soon as a + second module needs one. This is that second module, with four call sites of its own, and the + two copies should be collapsed into ``_base`` when the modules are wired together. + """ + if any(values.get(key) is not None for key in keys): + return + listed = ', '.join(f'"{key}"' for key in keys) + message = f'{tool}: pass at least one of {listed}.' + raise ValueError(f'{message} {note}' if note else message) + + +def _require_epoch_ms(args: dict, key: str, tool: str) -> int: + """Read a required epoch-millisecond timestamp, whether it arrives as a number or as a string. + + The four time-window parameters in this group are declared as strings on /calendars/events and + /calendars/blocked-slots and as numbers on free-slots, for the same unit. Accepting either form + and letting each tool render what its own endpoint declares is cheaper than making the agent + track which of the two it is talking to. + """ + value = args.get(key) + if not isinstance(value, bool) and isinstance(value, (int, str)): + try: + return int(value) + except (TypeError, ValueError): + pass + raise ValueError(f'{tool}: "{key}" is required and must be a time in epoch milliseconds, for example 1680373800000') + + +def _free_slot_range(args: dict, tool: str) -> tuple[int, int]: + """Read the free-slots window and enforce the 31-day cap before spending a request on it. + + The cap is the vendor's own, stated verbatim in the parameter descriptions of both startDate and + endDate: "Date range cannot be more than 31 days". Checking it here turns a rejected request into + a message naming the width that was asked for, and saves a call against a measured + 25-request/10s budget. + """ + start = _require_epoch_ms(args, 'startDate', tool) + end = _require_epoch_ms(args, 'endDate', tool) + if end < start: + raise ValueError(f'{tool}: "endDate" is before "startDate". Both are times in epoch milliseconds.') + if end - start > _FREE_SLOTS_MAX_RANGE_MS: + days = (end - start) // _DAY_MS + raise ValueError( + f'{tool}: GoHighLevel allows a range of at most 31 days and this one spans {days}. ' + f'Ask for a narrower range, or walk the period 31 days at a time.' + ) + return start, end + + +def _window_params(args: dict, tool: str) -> dict: + """Query parameters for the two reads bounded by a time window rather than by a cursor.""" + params = params_from(args, _EVENT_SCOPE_KEYS) + _require_any_of(params, _EVENT_SCOPE_KEYS, tool, 'GoHighLevel expects exactly one of the three.') + # Sent as strings: both endpoints declare these two as strings even though they hold a number. + params['startTime'] = str(_require_epoch_ms(args, 'startTime', tool)) + params['endTime'] = str(_require_epoch_ms(args, 'endTime', tool)) + return params + + +class AppointmentsMixin(GoHighLevelToolsBase): + """Tools for the ``appointments`` group.""" + + # -- appointments ------------------------------------------------------ + + @gohighlevel_tool( + group='appointments', + input_schema=schema(required=['startTime', 'endTime'], **_EVENT_WINDOW_PROPS), + description=( + 'List the appointments and other events on a calendar, a user or a calendar group inside a time ' + 'window. The window is the only bound this endpoint has: it takes no page size and no cursor, so ' + 'every event it finds comes back in one response and next is always null. Ask for a narrower window ' + 'rather than a page. Blocked time does not appear here, it comes from blocked_slot_list. Times come ' + 'back as ISO 8601 strings carrying a numeric offset.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every event in the window in one response, so there is no next ' + 'page. Narrow startTime and endTime to get less.', + items=_EVENT_ITEMS, + ), + ) + def appointment_list(self, args): + args = self._args(args, 'appointment_list') + params = _window_params(args, 'appointment_list') + params['locationId'] = self._location() + return self._list('/calendars/events', key='events', cleaner=_clean_event, params=params) + + @gohighlevel_tool( + group='appointments', + input_schema=schema(required=['eventId'], eventId=STR(_EVENT_ID_DESC)), + description=( + 'Get one appointment by id. Times come back as ISO 8601 strings carrying a numeric offset, which is ' + 'the form appointment_create and appointment_update take. The same appointment read through ' + 'contact_appointments_list comes back as a naive "YYYY-MM-DD HH:mm:ss" string with no zone at all, so ' + 'do not compare the two as text.' + ), + output_schema=_EVENT_RECORD_OUTPUT, + ) + def appointment_get(self, args): + args = self._args(args, 'appointment_get') + event_id = require_id(args, 'eventId', 'appointment_get') + return self._get(f'/calendars/events/appointments/{event_id}', _clean_event, key=_APPOINTMENT_GET_KEYS) + + @gohighlevel_tool( + group='appointments', + writes=True, + input_schema=schema( + required=['calendarId', 'contactId', 'startTime'], + **_APPOINTMENT_CREATE_PROPS, + ), + description=( + 'Book an appointment for a contact on a calendar. The reliable flow is appointment_free_slots_list ' + 'first, then send one of the slot strings it returned back here as startTime unchanged: ' + f'{_APPOINTMENT_TIMEZONE_DESC} endTime is optional and GoHighLevel derives it from the slot duration ' + 'on the calendar. A start time that is not a free slot is rejected unless ' + 'ignoreFreeSlotValidation is true.' + ), + output_schema=_EVENT_RECORD_OUTPUT, + ) + def appointment_create(self, args): + args = self._args(args, 'appointment_create') + # Checked here rather than left to the schema: "required" in an input schema is advisory in + # this node, and GoHighLevel answers a missing body field with a 400 that does not name it. + require_id(args, 'calendarId', 'appointment_create') + require_id(args, 'contactId', 'appointment_create') + require_text(args, 'startTime', 'appointment_create') + body = body_from(args, _APPOINTMENT_CREATE_KEYS, tool='appointment_create') + body['locationId'] = self._location() + return self._write('POST', '/calendars/events/appointments', _clean_event, body=body) + + @gohighlevel_tool( + group='appointments', + writes=True, + input_schema=schema( + required=['eventId'], + eventId=STR(_EVENT_ID_DESC), + **_APPOINTMENT_WRITE_PROPS, + ), + description=( + 'Update an appointment: reschedule it, reassign it, or change its status. Only the fields you pass ' + 'are changed. The contact cannot be changed, because GoHighLevel does not accept contactId on an ' + f'update even though appointment_create requires it. {_APPOINTMENT_TIMEZONE_DESC} To cancel an ' + 'appointment rather than remove it, set appointmentStatus to "cancelled": appointment_delete deletes ' + 'the record outright.' + ), + output_schema=_EVENT_RECORD_OUTPUT, + ) + def appointment_update(self, args): + args = self._args(args, 'appointment_update') + event_id = require_id(args, 'eventId', 'appointment_update') + body = body_from(args, _APPOINTMENT_WRITE_KEYS, tool='appointment_update') + return self._write('PUT', f'/calendars/events/appointments/{event_id}', _clean_event, body=body) + + @gohighlevel_tool( + group='appointments', + writes=True, + input_schema=schema(required=['eventId'], eventId=STR(_EVENT_ID_DESC)), + description=( + 'Delete a calendar event by id. This is the only delete in the calendar-event family, so it removes ' + 'an appointment and a blocked slot alike. Setting appointmentStatus to "cancelled" through ' + 'appointment_update keeps the record and its history, which is usually what a cancellation means.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def appointment_delete(self, args): + args = self._args(args, 'appointment_delete') + event_id = require_id(args, 'eventId', 'appointment_delete') + # This DELETE declares a required JSON body whose schema has no properties, so an empty + # object is sent rather than nothing. + return self._delete(f'/calendars/events/{event_id}', body={}) + + # -- free slots -------------------------------------------------------- + + @gohighlevel_tool( + group='appointments', + input_schema=schema( + required=['calendarId', 'startDate', 'endDate'], + calendarId=STR('Id of the calendar to read availability from. Get calendar ids from calendar_list.'), + startDate=INT('Start of the range, in epoch milliseconds, for example 1680373800000.'), + endDate=INT( + 'End of the range, in epoch milliseconds. It may be at most 31 days after startDate, which this ' + 'tool checks before sending.' + ), + timezone=STR( + 'IANA timezone name the slots are returned in, for example "America/Los_Angeles". Leave it out ' + 'to get the timezone configured on the calendar. Set it to the contact timezone when you are ' + 'booking on their behalf, because the offsets in the answer are what the booking will carry.' + ), + userId=STR('Read availability for one user on the calendar rather than for the whole calendar.'), + userIds=ARR('Read availability for several users on the calendar, by user id.'), + ), + description=( + 'Find the times a calendar can be booked, before calling appointment_create. The answer is not a ' + 'list: it is an object keyed by date ("YYYY-MM-DD"), and each date holds {"slots": [...]} whose ' + 'entries are ISO 8601 strings carrying a numeric offset. Send one of those strings back as the ' + 'startTime of appointment_create exactly as it arrived. The range may span at most 31 days, so walk ' + 'a longer period 31 days at a time.' + ), + output_schema=RECORD_OUTPUT( + 'An object keyed by date in "YYYY-MM-DD" form, each value holding a slots array of ISO 8601 strings ' + 'carrying a numeric offset. A date with no availability is absent rather than empty, and an empty ' + 'object means nothing is bookable in the range. Keys that are not dates, such as the traceId ' + 'GoHighLevel puts beside them, are dropped.' + ), + ) + def appointment_free_slots_list(self, args): + args = self._args(args, 'appointment_free_slots_list') + calendar_id = require_id(args, 'calendarId', 'appointment_free_slots_list') + start, end = _free_slot_range(args, 'appointment_free_slots_list') + params = params_from(args, ('timezone', 'userId', 'userIds')) + # Sent as numbers: this endpoint names the window startDate and endDate and declares both as + # numbers, while /calendars/events names the same unit startTime and endTime and declares + # both as strings. + params['startDate'] = start + params['endDate'] = end + return self._get(f'/calendars/{calendar_id}/free-slots', date_keyed, params=params) + + # -- blocked slots ----------------------------------------------------- + + @gohighlevel_tool( + group='appointments', + input_schema=schema(required=['startTime', 'endTime'], **_EVENT_WINDOW_PROPS), + description=( + 'List the blocked time on a calendar, a user or a calendar group inside a time window: the periods ' + 'that are deliberately unavailable rather than booked. Like appointment_list it has no page size and ' + 'no cursor, so everything in the window comes back at once and next is always null. Blocked slots ' + 'come back in the same shape as appointments, without a contact.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every blocked slot in the window in one response, so there is no ' + 'next page. Narrow startTime and endTime to get less.', + items='The blocked slots in the window, each trimmed to the fields this node returns.', + ), + ) + def blocked_slot_list(self, args): + args = self._args(args, 'blocked_slot_list') + params = _window_params(args, 'blocked_slot_list') + params['locationId'] = self._location() + # Blocked slots come back under "events", not under "blockedSlots": this endpoint shares its + # response schema with /calendars/events. + return self._list('/calendars/blocked-slots', key='events', cleaner=_clean_event, params=params) + + @gohighlevel_tool( + group='appointments', + writes=True, + input_schema=schema(**_BLOCKED_SLOT_WRITE_PROPS), + description=( + 'Block a period on a calendar or on a user so it cannot be booked. ' + f'{_BLOCKED_SLOT_OWNER_DESC} Use appointment_delete to remove the block again: GoHighLevel has no ' + 'dedicated block-slot delete.' + ), + output_schema=RECORD_OUTPUT('The blocked slot, trimmed to the fields this node returns.'), + ) + def blocked_slot_create(self, args): + args = self._args(args, 'blocked_slot_create') + body = body_from(args, _BLOCKED_SLOT_WRITE_KEYS, tool='blocked_slot_create') + _require_any_of(body, ('calendarId', 'assignedUserId'), 'blocked_slot_create', _BLOCKED_SLOT_OWNER_DESC) + body['locationId'] = self._location() + return self._write('POST', '/calendars/events/block-slots', _clean_event, body=body) + + @gohighlevel_tool( + group='appointments', + writes=True, + input_schema=schema( + required=['eventId'], + eventId=STR(_EVENT_ID_DESC), + **_BLOCKED_SLOT_WRITE_PROPS, + ), + description=( + f'Move or retitle a blocked slot. Only the fields you pass are changed. {_BLOCKED_SLOT_OWNER_DESC}' + ), + output_schema=RECORD_OUTPUT('The blocked slot after the change, trimmed to the fields this node returns.'), + ) + def blocked_slot_update(self, args): + args = self._args(args, 'blocked_slot_update') + event_id = require_id(args, 'eventId', 'blocked_slot_update') + body = body_from(args, _BLOCKED_SLOT_WRITE_KEYS, tool='blocked_slot_update') + _require_any_of(body, ('calendarId', 'assignedUserId'), 'blocked_slot_update', _BLOCKED_SLOT_OWNER_DESC) + body['locationId'] = self._location() + return self._write('PUT', f'/calendars/events/block-slots/{event_id}', _clean_event, body=body) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/businesses.py b/nodes/src/nodes/tool_gohighlevel/tools/businesses.py new file mode 100644 index 000000000..edde1b5e8 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/businesses.py @@ -0,0 +1,241 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Business tools: the business records a sub-account groups its contacts under.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + LIST_OUTPUT, + PAGING_SKIP_TEXT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + next_offset, + require_id, + schema, + skip_text_params, +) + +#: Fields kept from a business record. ``BusinessDto`` declares seventeen and this keeps all +#: of them, so the projection is an ordering and a guard against future additions rather than +#: a trim: the record is small, and the two audit fields are objects rather than ids +#: (``{name, role}``), so there is nothing here worth dropping for size. +_BUSINESS_READ_KEYS = ( + 'id', + 'name', + 'locationId', + 'phone', + 'email', + 'website', + 'address', + 'city', + 'state', + 'postalCode', + 'country', + 'description', + 'createdBy', + 'updatedBy', + 'createdAt', + 'updatedAt', +) + +#: Body fields accepted by business_create and business_update. +#: +#: One tuple serves both because CreateBusinessDto and UpdateBusinessDto declare exactly the +#: same ten fields. They differ only in what is required: create requires ``name``, update +#: requires nothing. That is the whole of the create-versus-update drift here, and it is +#: expressed by the ``required`` list on each schema rather than by a second key tuple. +#: +#: ``locationId`` is absent deliberately. Create takes it and the node supplies it from +#: config; UpdateBusinessDto does not declare it at all, and GoHighLevel answers an +#: undeclared property with a 422 rather than ignoring it. +_BUSINESS_WRITE_KEYS = ( + 'name', + 'phone', + 'email', + 'website', + 'address', + 'city', + 'state', + 'postalCode', + 'country', + 'description', +) + +#: There is no ``extra`` escape hatch on either write tool, which is a departure from the +#: contact tools and is deliberate. ``extra`` exists to reach a field the typed schema does +#: not cover, and here the typed schema covers both DTOs completely. Businesses carry no +#: custom fields and no tags. Anything else an agent could put in ``extra`` is a property +#: GoHighLevel has never declared, and it answers those with a 422, so the escape hatch could +#: only ever produce a hard vendor error. +_BUSINESS_WRITE_PROPS = { + 'name': STR('Name of the business.'), + 'phone': STR('Phone number in E.164 form, for example "+18888888888".'), + 'email': STR('Email address.'), + 'website': STR('Website URL.'), + 'address': STR('Street address.'), + 'city': STR('City.'), + 'state': STR('State or region.'), + 'postalCode': STR('Postal code.'), + 'country': STR('Country, which GoHighLevel takes as free text here rather than as a 2-letter code.'), + 'description': STR('Free-text description of the business.'), +} + +_BUSINESS_RECORD_OUTPUT = RECORD_OUTPUT('The business, trimmed to the fields this node returns.') + +_BUSINESS_ITEMS = 'The businesses on this page, each trimmed to the fields this node returns.' + + +def _clean_business(business: Any) -> dict: + """Trim a business record to the fields this node returns.""" + if not isinstance(business, dict): + return {} + return {key: business[key] for key in _BUSINESS_READ_KEYS if key in business} + + +def _business_write_result(payload: Any) -> dict: + """Business record out of a create or update response, under whichever key it arrived on. + + Both writes are documented as answering ``{"success": ..., "buiseness": {...}}``, with the + record key misspelled. The misspelling is read first because it is what the spec declares, + the correct spelling is read second so a vendor fix does not turn every write into an empty + record, and a bare record is accepted last because several neighbouring endpoints on this + surface answer unwrapped. + """ + if isinstance(payload, dict): + for key in ('buiseness', 'business'): + record = payload.get(key) + if isinstance(record, dict): + return _clean_business(record) + return _clean_business(payload) + + +class BusinessesMixin(GoHighLevelToolsBase): + """Tools for the ``businesses`` group.""" + + @gohighlevel_tool( + group='businesses', + input_schema=schema(**PAGING_SKIP_TEXT('business_list')), + description=( + 'List the businesses on the configured sub-account. A business is a company record inside one ' + 'sub-account that contacts can be grouped under, not a sub-account of its own, and it is the id ' + 'contact_list_by_business takes. This endpoint offers no filtering at all, so narrow the result ' + 'yourself after reading it. Returns businesses under items. This one pages by offset rather than ' + 'by cursor: next is the skip value for the following page, and it is null once a page comes back ' + 'shorter than the limit that was asked for.' + ), + output_schema=LIST_OUTPUT( + 'Offset for the next page: pass it back as the skip parameter. Null once a page comes back shorter ' + 'than the limit asked for, which means there is no next page.', + items=_BUSINESS_ITEMS, + ), + ) + def business_list(self, args): + args = self._args(args, 'business_list') + params, limit = skip_text_params(args, 'business_list') + params['locationId'] = self._location() + # Live-confirmed as the one endpoint on this surface that wraps its payload with + # "success": true alongside the records, which is why the records are read by their + # envelope key rather than treated as the whole body. + _, records = self._fetch('GET', '/businesses/', key='businesses', params=params) + return paginated( + [_clean_business(record) for record in records], + next_cursor=next_offset(args.get('skip'), limit, len(records)), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='businesses', + input_schema=schema(required=['businessId'], businessId=STR('Id of the business.')), + description=( + 'Get one business by id. Returns the same fields business_list returns, so call this only when you ' + 'already have an id and not as a way to search: there is no business search endpoint.' + ), + output_schema=_BUSINESS_RECORD_OUTPUT, + ) + def business_get(self, args): + args = self._args(args, 'business_get') + business_id = require_id(args, 'businessId', 'business_get') + return self._get(f'/businesses/{business_id}', _clean_business, key='business') + + @gohighlevel_tool( + group='businesses', + writes=True, + input_schema=schema(required=['name'], **_BUSINESS_WRITE_PROPS), + description=( + 'Create a business on the configured sub-account. Only name is required. Creating a business does ' + 'not move any contact into it: GoHighLevel assigns contacts to a business in its own UI, and this ' + 'node has no tool that sets a contact businessId.' + ), + output_schema=_BUSINESS_RECORD_OUTPUT, + ) + def business_create(self, args): + args = self._args(args, 'business_create') + body = body_from(args, _BUSINESS_WRITE_KEYS) + body['locationId'] = self._location() + return self._write('POST', '/businesses/', _business_write_result, body=body) + + @gohighlevel_tool( + group='businesses', + writes=True, + input_schema=schema( + required=['businessId'], + businessId=STR('Id of the business to update.'), + **_BUSINESS_WRITE_PROPS, + ), + description=( + 'Update a business. Only the fields you pass are changed, and every field is optional here, ' + 'including name. The sub-account a business belongs to cannot be changed.' + ), + output_schema=_BUSINESS_RECORD_OUTPUT, + ) + def business_update(self, args): + args = self._args(args, 'business_update') + business_id = require_id(args, 'businessId', 'business_update') + body = body_from(args, _BUSINESS_WRITE_KEYS) + return self._write('PUT', f'/businesses/{business_id}', _business_write_result, body=body) + + @gohighlevel_tool( + group='businesses', + writes=True, + input_schema=schema(required=['businessId'], businessId=STR('Id of the business to delete.')), + description=( + 'Delete a business by id. GoHighLevel does not document what happens to the contacts grouped under ' + 'it, so read them with contact_list_by_business first if that matters.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def business_delete(self, args): + args = self._args(args, 'business_delete') + business_id = require_id(args, 'businessId', 'business_delete') + return self._delete(f'/businesses/{business_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/calendar_groups.py b/nodes/src/nodes/tool_gohighlevel/tools/calendar_groups.py new file mode 100644 index 000000000..0eac2c431 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/calendar_groups.py @@ -0,0 +1,253 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Calendar group tools: the folders calendars are filed under, and their shared booking page.""" + +from __future__ import annotations + +from typing import Any + +from ai.common.utils import require_bool + +from ..gohighlevel_client import normalize_success +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + passthrough, + require_id, + require_text, + schema, +) + +# Every path here starts with /calendars, so ``version_for`` already sends Version 2021-04-15 +# and nothing in this module passes ``version=``. v3 is a header value rather than a URL +# prefix, and this node does not send it. + +#: Body fields shared by calendar_group_create and calendar_group_update. +#: +#: All three are required by both operations, which is the unusual part: GroupUpdateDTO marks +#: name, description and slug required, so PUT /calendars/groups/{groupId} is a whole-record +#: replacement rather than a patch. There is no get-by-id for a group either, so the values to +#: resend come from calendar_group_list. calendar_group_update states that in its description. +#: +#: ``locationId`` is absent: the node supplies it from config on create, and the update body +#: does not declare it. ``isActive`` is create-only for the same reason, and changing it later +#: is what calendar_group_status_set is for. +#: +#: There is no ``extra`` on these tools, unlike the calendar ones. The group body is four +#: fields wide and all four are typed here, so ``extra`` could only carry a property +#: GoHighLevel does not declare, which is a 422 rather than a no-op. +_GROUP_WRITE_KEYS = ('name', 'description', 'slug') + +_GROUP_WRITE_PROPS = { + 'name': STR('Name of the group, as it shows in the GoHighLevel UI.'), + 'description': STR( + 'Description of the group. GoHighLevel requires the field on both the create and the update, so pass an ' + 'empty string when the group needs no description.' + ), + 'slug': STR( + 'URL slug for the group booking page, for example "15-mins". Check it with calendar_group_slug_check ' + 'first: GoHighLevel rejects a slug that is already taken in this sub-account.' + ), +} + +_GROUP_RECORD_OUTPUT = RECORD_OUTPUT( + 'The group as GoHighLevel returns it: id, locationId, name, description, slug and isActive. The id is under ' + '"id", not "groupId".' +) + + +def _group_body(args: dict, keys: tuple[str, ...], tool: str) -> dict: + """Assemble a group create or update body, with the three fields both operations require. + + The local argument check only rejects parameters a tool does not declare, so a required one + that was left out still reaches GoHighLevel and comes back as vendor validation text. Name + and slug are checked as non-empty strings; ``description`` is only checked for presence, + because a group with nothing to say about it still has to send the field. + """ + body = body_from(args, keys, tool=tool) + require_text(args, 'name', tool) + require_text(args, 'slug', tool) + if not isinstance(body.get('description'), str): + raise ValueError( + f'{tool}: "description" is required. GoHighLevel declares it on both the create and the update body, ' + f'so pass an empty string when the group needs no description.' + ) + return body + + +def _flag_result(payload: Any) -> dict: + """Outcome of a call whose whole answer is a success flag. + + The flag is spelled ``succeded``, ``succeeded`` or ``success`` depending on the endpoint, + so it is read through normalize_success rather than by name. + """ + return {'ok': normalize_success(payload)} + + +def _slug_result(payload: Any) -> dict: + """Answer of the slug check, kept as three states rather than two. + + ``available`` stays None when GoHighLevel sends no flag. Coercing that to False would tell + the agent the slug is taken, which is a different claim from not knowing. + """ + if not isinstance(payload, dict): + return {'available': None} + return {'available': payload.get('available')} + + +class CalendarGroupsMixin(GoHighLevelToolsBase): + """Tools for the ``calendar_groups`` group.""" + + @gohighlevel_tool( + group='calendar_groups', + input_schema=schema(), + description=( + 'List the calendar groups in the configured sub-account. A group is a folder of calendars with its own ' + 'booking page, and its id is what calendar_list filters on and what a calendar carries as groupId. This ' + 'is also the only way to read one group: GoHighLevel has no get-by-id for a group, so read the record ' + 'from here before calendar_group_update, which needs every field resent. The whole set comes back in ' + 'one response, so there is no cursor, and a sub-account with no groups returns an empty list rather ' + 'than an error.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every group in one response, so there is no next page.', + items='The calendar groups, each carrying id, locationId, name, description, slug and isActive.', + ), + ) + def calendar_group_list(self, args): + args = self._args(args, 'calendar_group_list') + params = {'locationId': self._location()} + return self._list('/calendars/groups', key='groups', cleaner=passthrough, params=params) + + @gohighlevel_tool( + group='calendar_groups', + writes=True, + input_schema=schema( + required=['name', 'description', 'slug'], + isActive=BOOL('Whether the group is live. Set it to false to create it hidden.'), + **_GROUP_WRITE_PROPS, + ), + description=( + 'Create a calendar group in the configured sub-account. Name, description and slug are all required by ' + 'GoHighLevel, so send an empty description rather than omitting it. Call calendar_group_slug_check ' + 'first if the slug matters: a taken slug is rejected. Put a calendar in the group afterwards by sending ' + 'the new group id as groupId to calendar_create or calendar_update.' + ), + output_schema=_GROUP_RECORD_OUTPUT, + ) + def calendar_group_create(self, args): + args = self._args(args, 'calendar_group_create') + body = _group_body(args, _GROUP_WRITE_KEYS + ('isActive',), 'calendar_group_create') + body['locationId'] = self._location() + return self._write('POST', '/calendars/groups', passthrough, key='group', body=body) + + @gohighlevel_tool( + group='calendar_groups', + writes=True, + input_schema=schema( + required=['groupId', 'name', 'description', 'slug'], + groupId=STR('Id of the group to update.'), + **_GROUP_WRITE_PROPS, + ), + description=( + 'Replace the name, description and slug of a calendar group. This is not a partial update: GoHighLevel ' + 'requires all three on every call, so read the group from calendar_group_list and resend the fields you ' + 'are not changing, or they are overwritten with whatever you pass. It cannot change isActive; use ' + 'calendar_group_status_set for that.' + ), + output_schema=_GROUP_RECORD_OUTPUT, + ) + def calendar_group_update(self, args): + args = self._args(args, 'calendar_group_update') + group_id = require_id(args, 'groupId', 'calendar_group_update') + body = _group_body(args, _GROUP_WRITE_KEYS, 'calendar_group_update') + return self._write('PUT', f'/calendars/groups/{group_id}', passthrough, key='group', body=body) + + @gohighlevel_tool( + group='calendar_groups', + writes=True, + input_schema=schema( + required=['groupId', 'isActive'], + groupId=STR('Id of the group.'), + isActive=BOOL('True to enable the group, false to disable it.'), + ), + description=( + 'Enable or disable a calendar group without deleting it. Disabling takes the group booking page down ' + 'and leaves the group and its calendars in place, so this is the reversible alternative to ' + 'calendar_group_delete. Returns only a success flag, not the group.' + ), + output_schema=RECORD_OUTPUT('ok is true when GoHighLevel reported the change as successful.'), + ) + def calendar_group_status_set(self, args): + args = self._args(args, 'calendar_group_status_set') + group_id = require_id(args, 'groupId', 'calendar_group_status_set') + # Read strictly rather than through body_from: isActive is the whole request, an absent + # one would send an empty body that GoHighLevel answers with a 400, and a coerced one + # would quietly disable the group when the agent meant to enable it. + body = {'isActive': require_bool(args, 'isActive', tool_name='calendar_group_status_set')} + return self._write('PUT', f'/calendars/groups/{group_id}/status', _flag_result, body=body) + + @gohighlevel_tool( + group='calendar_groups', + writes=True, + input_schema=schema(required=['groupId'], groupId=STR('Id of the group to delete.')), + description=( + 'Delete a calendar group. Use calendar_group_status_set with isActive false instead when the group ' + 'should only come off the booking page, since a delete cannot be undone.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def calendar_group_delete(self, args): + args = self._args(args, 'calendar_group_delete') + group_id = require_id(args, 'groupId', 'calendar_group_delete') + return self._delete(f'/calendars/groups/{group_id}') + + @gohighlevel_tool( + group='calendar_groups', + input_schema=schema(required=['slug'], slug=STR('The slug to check, for example "15-mins".')), + description=( + 'Check whether a calendar group slug is free in the configured sub-account, before creating or renaming ' + 'a group. It changes nothing. It does check group slugs only: there is no equivalent for a calendar ' + 'slug. GoHighLevel puts this behind its calendar group write permission even though it only reads, so ' + 'a token without that permission gets a 401 here.' + ), + output_schema=RECORD_OUTPUT( + 'available is true when the slug is free and false when it is taken. It is null when GoHighLevel ' + 'answered without the flag, which means unknown rather than taken.' + ), + ) + def calendar_group_slug_check(self, args): + args = self._args(args, 'calendar_group_slug_check') + slug = require_text(args, 'slug', 'calendar_group_slug_check') + body = {'locationId': self._location(), 'slug': slug} + return _slug_result(self._call('POST', '/calendars/groups/validate-slug', body=body)) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/calendars.py b/nodes/src/nodes/tool_gohighlevel/tools/calendars.py new file mode 100644 index 000000000..4cf5afc04 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/calendars.py @@ -0,0 +1,382 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Calendar tools: the bookable calendar record and the scheduling configuration on it.""" + +from __future__ import annotations + +from typing import Any + +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + ENUM, + INT, + LIST_OUTPUT, + MIXED_ARR, + NUM, + OBJ, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +# Nothing here passes ``version=``. Every path in this module starts with /calendars, which +# ``version_for`` already maps to 2021-04-15. Do not send v3: it is a Version header value +# rather than a URL prefix, it changes the appointment schemas, and this node pins v2. + +#: Fields kept from a calendar record in a listing. +#: +#: A CalendarDTO carries roughly 55 configuration fields, and GET /calendars/ has no +#: pagination at all, so a sub-account with twenty calendars would otherwise spend a thousand +#: fields on a question the agent asked to find one id. The listing is therefore an index and +#: calendar_get is the detail view: it returns the record untrimmed, because every field on it +#: is a setting somebody chose and there is no way to guess which one is being looked for. +_CALENDAR_SUMMARY_KEYS = ( + 'id', + 'locationId', + 'name', + 'description', + 'slug', + 'widgetSlug', + 'groupId', + 'calendarType', + 'eventType', + 'isActive', + 'slotDuration', + 'slotDurationUnit', +) + +#: Body fields accepted by calendar_create and calendar_update. +#: +#: This is the scheduling half of CalendarCreateDTO: what the calendar is, who is on it, how +#: long a slot is and when it can be booked. The booking-widget, payment and notification-email +#: half is reachable through ``extra`` instead, named field by field in :data:`_CALENDAR_EXTRA`. +#: Typing all 55 fields would put a schema larger than the rest of this node's tools combined +#: in front of an agent that mostly wants to read calendars. +#: +#: ``locationId`` is absent: the node supplies it from config on create, and CalendarUpdateDTO +#: does not declare it at all. ``notifications`` and ``meetingLocation`` are absent because +#: GoHighLevel flags both deprecated in the spec, in favour of the calendar notification +#: endpoints (not in this PR) and of ``locationConfigurations``. +_CALENDAR_WRITE_KEYS = ( + 'name', + 'description', + 'slug', + 'groupId', + 'isActive', + 'eventType', + 'eventTitle', + 'teamMembers', + 'locationConfigurations', + 'slotDuration', + 'slotDurationUnit', + 'slotInterval', + 'slotIntervalUnit', + 'slotBuffer', + 'preBuffer', + 'preBufferUnit', + 'appoinmentPerSlot', + 'appoinmentPerDay', + 'allowBookingAfter', + 'allowBookingAfterUnit', + 'allowBookingFor', + 'allowBookingForUnit', + 'openHours', + 'availabilityType', + 'availabilities', + 'enableRecurring', + 'recurring', + 'formId', + 'stickyContact', + 'autoConfirm', + 'allowReschedule', + 'allowCancellation', + 'notes', +) + +#: Fields CalendarCreateDTO declares and CalendarUpdateDTO does not. +#: +#: ``calendarType`` is the interesting one: the kind of calendar is fixed at creation, and the +#: update body has no property for it. ``slotBufferUnit`` looks like an oversight in the vendor +#: spec (``preBufferUnit`` survives on update and its twin does not), but GoHighLevel answers a +#: property it does not declare with a 422 rather than ignoring it, so update does not offer it. +#: Same policy as contacts: when the two DTOs disagree, each operation sends what its own DTO +#: declares. +_CALENDAR_CREATE_ONLY_KEYS = ('calendarType', 'slotBufferUnit') + +#: The ``extra`` escape hatch for calendars. +#: +#: Deliberately not :func:`EXTRA`, whose text is about contacts: it tells the agent to send +#: customFields and warns it off tags, and a calendar has neither. Sending either here would be +#: a 422 rather than a no-op, so the shared wording would be actively misleading. +_CALENDAR_EXTRA = OBJ( + 'Any further GoHighLevel calendar fields to send verbatim, merged into the request body after the typed ' + 'parameters. The typed parameters cover the scheduling configuration; the booking-widget and payment fields ' + 'go here, namely widgetType, widgetSlug, eventColor, pixelId, formSubmitType, formSubmitRedirectURL, ' + 'formSubmitThanksMessage, guestType, consentLabel, calendarCoverImage, lookBusyConfig, isLivePaymentMode, ' + 'googleInvitationEmails, alertEmail, shouldSendAlertEmailsToAssignedMember, shouldAssignContactToTeamMember ' + 'and shouldSkipAssigningContactForExisting. GoHighLevel rejects a property it does not declare with a 422, ' + 'so send only names from the GoHighLevel calendar create and update bodies.' +) + +_CALENDAR_WRITE_PROPS = { + 'name': STR('Name of the calendar, as it shows in the GoHighLevel UI and on the booking page.'), + 'description': STR('Description of the calendar.'), + 'slug': STR( + 'URL slug for this calendar booking page. There is no endpoint that checks whether a calendar slug is ' + 'free: calendar_group_slug_check validates a group slug, not this one.' + ), + 'groupId': STR('Id of the calendar group this calendar belongs to. Get group ids from calendar_group_list.'), + 'isActive': BOOL('Whether the calendar is live. Set it to false to leave the calendar as a draft.'), + 'eventType': ENUM( + 'How a round robin calendar spreads bookings across its team members.', + ('RoundRobin_OptimizeForAvailability', 'RoundRobin_OptimizeForEqualDistribution'), + ), + 'eventTitle': STR('Title given to a booked appointment. GoHighLevel templates it, for example "{{contact.name}}".'), + 'teamMembers': MIXED_ARR( + 'Users who take bookings on this calendar, each an object of the form {"userId": "", "priority": 0.5, ' + '"isPrimary": false, "locationConfigurations": [...]}. priority is 0, 0.5 or 1. GoHighLevel requires team ' + 'members on round_robin, collective, class_booking and service_booking calendars, a personal calendar takes ' + 'exactly one, and a collective calendar needs exactly one member marked isPrimary. Get user ids from ' + 'user_list_by_location.' + ), + 'locationConfigurations': MIXED_ARR( + 'Where the meeting happens, each an object of the form {"kind": "physical", "location": "
"}. kind is ' + 'one of custom, zoom_conference, google_conference, inbound_call, outbound_call, physical, booker or ' + 'ms_teams_conference, and the three conference kinds take no location. This replaces meetingLocation, which ' + 'GoHighLevel has deprecated.' + ), + 'slotDuration': NUM('How long one booking slot is, in slotDurationUnit. GoHighLevel defaults it to 30 minutes.'), + 'slotDurationUnit': ENUM('Unit for slotDuration.', ('mins', 'hours')), + 'slotInterval': NUM( + 'Gap between the start of one bookable slot and the start of the next, in slotIntervalUnit. This is what ' + 'spaces the slots shown on the booking page, and it is separate from how long a meeting lasts.' + ), + 'slotIntervalUnit': ENUM('Unit for slotInterval.', ('mins', 'hours')), + 'slotBuffer': NUM('Padding kept free after an appointment, in slotBufferUnit.'), + 'preBuffer': NUM('Padding kept free before an appointment, in preBufferUnit.'), + 'preBufferUnit': ENUM('Unit for preBuffer.', ('mins', 'hours')), + 'appoinmentPerSlot': NUM( + 'Maximum bookings per slot per team member, or seats per slot on a class booking calendar. The missing "t" ' + 'is how GoHighLevel spells the field, and this node sends it exactly as it is.' + ), + 'appoinmentPerDay': NUM( + 'Maximum bookings allowed on one day. Misspelled the same way in the GoHighLevel API and sent as it is.' + ), + 'allowBookingAfter': NUM('Minimum notice before a slot can be booked, in allowBookingAfterUnit.'), + 'allowBookingAfterUnit': ENUM('Unit for allowBookingAfter.', ('hours', 'days', 'weeks', 'months')), + 'allowBookingFor': NUM('How far into the future booking is allowed, in allowBookingForUnit.'), + 'allowBookingForUnit': ENUM('Unit for allowBookingFor.', ('days', 'weeks', 'months')), + 'openHours': MIXED_ARR( + 'Standard weekly availability, each entry an object of the form {"daysOfTheWeek": [1, 2], "hours": ' + '[{"openHour": 9, "openMinute": 0, "closeHour": 17, "closeMinute": 0}]}. Days run 0 to 6 with Sunday as 0, ' + 'and the hours are 24-hour local time. Use availabilities for one-off dates instead.' + ), + 'availabilityType': INT( + 'Which availability the calendar honours: 1 for the custom availabilities only, 0 for the open hours only. ' + 'Leave it unset and GoHighLevel uses both.' + ), + 'availabilities': MIXED_ARR( + 'Availability for specific dates, each an object of the form {"date": "2023-09-24T00:00:00.000Z", "hours": ' + '[...], "deleted": false}, where the date is midnight local time written with a Z. On calendar_update an ' + 'entry that changes or removes an existing custom availability must also carry its "id".' + ), + 'enableRecurring': BOOL( + 'Whether bookings on this calendar repeat. GoHighLevel only allows it on a calendar with exactly one team ' + 'member.' + ), + 'recurring': OBJ( + 'Recurrence rules, as an object of the form {"freq": "WEEKLY", "count": 4, "bookingOption": "skip", ' + '"bookingOverlapDefaultStatus": "confirmed"}. freq is DAILY, WEEKLY or MONTHLY, count is at most 24, ' + 'bookingOption is skip, continue or book_next and says what to do when a repeat slot is taken, and ' + 'bookingOverlapDefaultStatus is confirmed or new.' + ), + 'formId': STR('Id of the form shown on the booking page.'), + 'stickyContact': BOOL('Whether the booking page prefills from the contact who last used that browser.'), + 'autoConfirm': BOOL('Whether a new booking is confirmed straight away rather than left for review.'), + 'allowReschedule': BOOL('Whether the person who booked can reschedule from their confirmation.'), + 'allowCancellation': BOOL('Whether the person who booked can cancel from their confirmation.'), + 'notes': STR( + 'Internal notes about the calendar itself, shown in the GoHighLevel UI. These are not appointment notes, ' + 'which belong to a booked appointment and live in the appointment_notes tool group.' + ), + 'extra': _CALENDAR_EXTRA, +} + +#: Body keys and schema properties for calendar_create, which adds the two fields the update +#: body does not declare. +_CALENDAR_CREATE_KEYS = _CALENDAR_WRITE_KEYS + _CALENDAR_CREATE_ONLY_KEYS +_CALENDAR_CREATE_PROPS = { + **_CALENDAR_WRITE_PROPS, + 'calendarType': ENUM( + 'What kind of calendar this is. It is fixed once the calendar exists: calendar_update cannot change it, so ' + 'a different kind means a new calendar.', + ('round_robin', 'event', 'class_booking', 'collective', 'service_booking', 'personal'), + ), + 'slotBufferUnit': ENUM( + 'Unit for slotBuffer. GoHighLevel accepts it only when the calendar is created, so calendar_update does ' + 'not offer it.', + ('mins', 'hours'), + ), +} + +_CALENDAR_RECORD_OUTPUT = RECORD_OUTPUT( + 'The whole calendar record as GoHighLevel returns it, which is the scheduling configuration in full: the ' + 'identifiers, the team members, the slot sizing, the open hours and the custom availabilities. The id is under ' + '"id", not "calendarId".' +) + + +def _calendar_summary(calendar: Any) -> dict: + """Trim a calendar record to what identifies it in a listing. + + Detail is calendar_get's job. See :data:`_CALENDAR_SUMMARY_KEYS` for why the list route + does not return whole records. + """ + if not isinstance(calendar, dict): + return {} + return {key: calendar[key] for key in _CALENDAR_SUMMARY_KEYS if key in calendar} + + +class CalendarsMixin(GoHighLevelToolsBase): + """Tools for the ``calendars`` group.""" + + @gohighlevel_tool( + group='calendars', + input_schema=schema( + groupId=STR('Only return calendars in this calendar group. Get group ids from calendar_group_list.'), + showDrafted=BOOL( + 'Whether to include calendars that are not live yet. GoHighLevel includes them by default.' + ), + ), + description=( + 'List the calendars in the configured sub-account. Start here to resolve the calendarId that the ' + 'appointment tools need to read free slots, list events and book. The whole set comes back in one ' + 'response: this endpoint has no pagination, no filter beyond groupId and no search. Records are ' + 'summaries carrying the id, name, slug, group, type and slot duration; call calendar_get for the ' + 'rest of a calendar configuration. A sub-account with no calendars returns an empty list rather ' + 'than an error.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every calendar in one response, so there is no next page.', + items=( + 'The calendars in the sub-account, each trimmed to id, locationId, name, description, slug, ' + 'widgetSlug, groupId, calendarType, eventType, isActive and the slot duration.' + ), + ), + ) + def calendar_list(self, args): + args = self._args(args, 'calendar_list') + params = params_from(args, ('groupId', 'showDrafted')) + params['locationId'] = self._location() + return self._list('/calendars/', key='calendars', cleaner=_calendar_summary, params=params) + + @gohighlevel_tool( + group='calendars', + input_schema=schema(required=['calendarId'], calendarId=STR('Id of the calendar.')), + description=( + 'Get one calendar by id, with its whole configuration: team members, slot sizing, booking window, open ' + 'hours and custom availabilities. Use it before calendar_update, since an update only changes the ' + 'fields it is given and this is the only way to see what the others hold. calendar_list returns ' + 'summaries rather than this.' + ), + output_schema=_CALENDAR_RECORD_OUTPUT, + ) + def calendar_get(self, args): + args = self._args(args, 'calendar_get') + calendar_id = require_id(args, 'calendarId', 'calendar_get') + return self._get(f'/calendars/{calendar_id}', passthrough, key='calendar') + + @gohighlevel_tool( + group='calendars', + writes=True, + input_schema=schema(required=['name'], **_CALENDAR_CREATE_PROPS), + description=( + 'Create a calendar in the configured sub-account. Only name is required, and everything else falls back ' + 'to a GoHighLevel default, but a calendar left on the defaults takes 30 minute bookings around the ' + 'clock: set calendarType, teamMembers and openHours to get something usable. calendarType and ' + 'slotBufferUnit can only be set here, because the update body does not accept them. GoHighLevel ' + 'requires team members on round_robin, collective, class_booking and service_booking calendars.' + ), + output_schema=_CALENDAR_RECORD_OUTPUT, + ) + def calendar_create(self, args): + args = self._args(args, 'calendar_create') + # The name is checked here rather than left to the API. The schema marks it required, but + # the local argument check only rejects parameters that were not declared, so a create + # without a name would otherwise reach GoHighLevel and come back as vendor validation text. + body = body_from(args, _CALENDAR_CREATE_KEYS, tool='calendar_create') + body['name'] = require_text(args, 'name', 'calendar_create') + body['locationId'] = self._location() + return self._write('POST', '/calendars/', passthrough, key='calendar', body=body) + + @gohighlevel_tool( + group='calendars', + writes=True, + input_schema=schema( + required=['calendarId'], + calendarId=STR('Id of the calendar to update.'), + **_CALENDAR_WRITE_PROPS, + ), + description=( + 'Update a calendar. Only the fields you pass are changed, but the array fields are replaced whole ' + 'rather than merged: sending teamMembers, openHours, availabilities or locationConfigurations discards ' + 'what was there, so read the calendar with calendar_get first and send the full array back with your ' + 'change in it. An entry in availabilities that edits an existing custom date must carry its id. ' + 'GoHighLevel does not accept calendarType or slotBufferUnit on an update, so this tool does not offer ' + 'them even though calendar_create does.' + ), + output_schema=_CALENDAR_RECORD_OUTPUT, + ) + def calendar_update(self, args): + args = self._args(args, 'calendar_update') + calendar_id = require_id(args, 'calendarId', 'calendar_update') + body = body_from(args, _CALENDAR_WRITE_KEYS, tool='calendar_update') + return self._write('PUT', f'/calendars/{calendar_id}', passthrough, key='calendar', body=body) + + @gohighlevel_tool( + group='calendars', + writes=True, + input_schema=schema(required=['calendarId'], calendarId=STR('Id of the calendar to delete.')), + description=( + 'Delete a calendar by id. This takes its appointments with it and there is no undo, so confirm the ' + 'calendar with calendar_get first. To take a calendar off the booking page without destroying it, call ' + 'calendar_update with isActive false instead.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def calendar_delete(self, args): + args = self._args(args, 'calendar_delete') + calendar_id = require_id(args, 'calendarId', 'calendar_delete') + return self._delete(f'/calendars/{calendar_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/contact_notes.py b/nodes/src/nodes/tool_gohighlevel/tools/contact_notes.py new file mode 100644 index 000000000..4dd2fda63 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/contact_notes.py @@ -0,0 +1,203 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Contact note tools: the free-text notes attached to one contact.""" + +from __future__ import annotations + +from typing import Any + +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + require_id, + require_text, + schema, +) + +#: Fields kept from a note record. +#: +#: ``dateUpdated`` is not in the vendor response schema and is kept anyway: the declared +#: response schemas on this surface are documented as unreliable, the design brief records +#: notes as carrying both ISO-8601 timestamps, and a key the payload does not have is simply +#: absent from the projection rather than an error. +_NOTE_READ_KEYS = ( + 'id', + 'contactId', + 'body', + 'title', + 'color', + 'pinned', + 'userId', + 'dateAdded', + 'dateUpdated', +) + +#: Body fields accepted by contact_notes_create and contact_notes_update. +#: +#: NotesDTO and UpdateNoteDTO declare exactly the same five properties, so unlike contacts +#: there is no create-versus-update drift here and one tuple serves both. They differ only in +#: what they require: create requires ``body``, update requires nothing. +_NOTE_WRITE_KEYS = ('body', 'title', 'color', 'pinned', 'userId') + +_NOTE_WRITE_PROPS = { + 'body': STR('Text of the note. This is the note itself, not a summary of it.'), + 'title': STR('Short heading shown above the note in the GoHighLevel UI.'), + 'color': STR('Colour of the note in the GoHighLevel UI, as a hex string, for example "#FFAA00".'), + 'pinned': BOOL('Whether to pin the note to the top of the contact note list.'), + 'userId': STR( + 'Id of the GoHighLevel user to record as the note author. Get user ids from user_list_by_location. ' + 'Omit it to let GoHighLevel attribute the note to whoever the token belongs to.' + ), +} + +_NOTE_RECORD_OUTPUT = RECORD_OUTPUT('The note, trimmed to the fields this node returns.') + + +def _clean_note(note: Any) -> dict: + """Trim a note record to the fields this node returns.""" + if not isinstance(note, dict): + return {} + return {key: note[key] for key in _NOTE_READ_KEYS if key in note} + + +class ContactNotesMixin(GoHighLevelToolsBase): + """Tools for the ``contact_notes`` group.""" + + @gohighlevel_tool( + group='contact_notes', + input_schema=schema(required=['contactId'], contactId=STR('Id of the contact.')), + description=( + 'List the notes on a contact. Read this before writing a new note, so a call summary is appended to ' + 'the history rather than repeating what is already there. Returns notes under items, newest first as ' + 'GoHighLevel orders them. The whole list comes back in one response, so there is no cursor and no ' + 'limit parameter: a contact with hundreds of notes returns hundreds of notes.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every note in one response, so there is no next page.', + items='The notes on the contact, each trimmed to the fields this node returns.', + ), + ) + def contact_notes_list(self, args): + args = self._args(args, 'contact_notes_list') + contact_id = require_id(args, 'contactId', 'contact_notes_list') + return self._list(f'/contacts/{contact_id}/notes', key='notes', cleaner=_clean_note) + + @gohighlevel_tool( + group='contact_notes', + input_schema=schema( + required=['contactId', 'noteId'], + contactId=STR('Id of the contact the note belongs to.'), + noteId=STR('Id of the note. Get note ids from contact_notes_list.'), + ), + description=( + 'Get one note by id. Both ids are needed: a note is addressed through its contact, so a note id on ' + 'its own does not identify anything. Use contact_notes_list when you have the contact but not the ' + 'note id.' + ), + output_schema=_NOTE_RECORD_OUTPUT, + ) + def contact_notes_get(self, args): + args = self._args(args, 'contact_notes_get') + contact_id = require_id(args, 'contactId', 'contact_notes_get') + # The path template spells this one "{id}". It is exposed as noteId because a parameter + # called "id" beside contactId reads as the contact's own id, and a path placeholder is + # never sent as a named parameter, so the vendor never sees this name. + note_id = require_id(args, 'noteId', 'contact_notes_get') + return self._get(f'/contacts/{contact_id}/notes/{note_id}', _clean_note, key='note') + + @gohighlevel_tool( + group='contact_notes', + writes=True, + input_schema=schema( + required=['contactId', 'body'], + contactId=STR('Id of the contact to attach the note to.'), + **_NOTE_WRITE_PROPS, + ), + description=( + 'Write a note on a contact. This is the right place for a call summary, a research finding or ' + 'anything a human should read on the record later, and it is the only durable free-text store on a ' + 'contact: everything else on the contact is a typed field. body is required. Notes are never ' + 'deduplicated by GoHighLevel, so list the existing notes first if the same summary may already be ' + 'there.' + ), + output_schema=_NOTE_RECORD_OUTPUT, + ) + def contact_notes_create(self, args): + args = self._args(args, 'contact_notes_create') + contact_id = require_id(args, 'contactId', 'contact_notes_create') + require_text(args, 'body', 'contact_notes_create') + body = body_from(args, _NOTE_WRITE_KEYS) + return self._write('POST', f'/contacts/{contact_id}/notes', _clean_note, key='note', body=body) + + @gohighlevel_tool( + group='contact_notes', + writes=True, + input_schema=schema( + required=['contactId', 'noteId'], + contactId=STR('Id of the contact the note belongs to.'), + noteId=STR('Id of the note to update. Get note ids from contact_notes_list.'), + **_NOTE_WRITE_PROPS, + ), + description=( + 'Update a note. Only the fields you pass are changed, and body replaces the whole note text rather ' + 'than appending to it: read the note with contact_notes_get first when you mean to add to it. Prefer ' + 'contact_notes_create for a new observation, so the history stays intact.' + ), + output_schema=_NOTE_RECORD_OUTPUT, + ) + def contact_notes_update(self, args): + args = self._args(args, 'contact_notes_update') + contact_id = require_id(args, 'contactId', 'contact_notes_update') + note_id = require_id(args, 'noteId', 'contact_notes_update') + body = body_from(args, _NOTE_WRITE_KEYS) + return self._write('PUT', f'/contacts/{contact_id}/notes/{note_id}', _clean_note, key='note', body=body) + + @gohighlevel_tool( + group='contact_notes', + writes=True, + input_schema=schema( + required=['contactId', 'noteId'], + contactId=STR('Id of the contact the note belongs to.'), + noteId=STR('Id of the note to delete. Get note ids from contact_notes_list.'), + ), + description=( + 'Delete a note. The note text is gone afterwards and there is no undo, so read it with ' + 'contact_notes_get first if it may still be wanted.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def contact_notes_delete(self, args): + args = self._args(args, 'contact_notes_delete') + contact_id = require_id(args, 'contactId', 'contact_notes_delete') + note_id = require_id(args, 'noteId', 'contact_notes_delete') + return self._delete(f'/contacts/{contact_id}/notes/{note_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/contact_tasks.py b/nodes/src/nodes/tool_gohighlevel/tools/contact_tasks.py new file mode 100644 index 000000000..b432c35b8 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/contact_tasks.py @@ -0,0 +1,274 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Contact task tools: the to-dos attached to one contact.""" + +from __future__ import annotations + +from typing import Any + +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + require_id, + require_text, + schema, +) + +#: Fields kept from a task record, across both routes that return one. +#: +#: ``GET /contacts/{contactId}/tasks`` returns the seven fields the vendor schema declares. +#: ``POST /locations/{locationId}/tasks/search`` returns the same entities with a dozen more, +#: live-confirmed: locationId, businessId, deleted, status, statusGroup, the two timestamps and +#: two embedded detail objects. Both are listed here so the projection serves either route. +#: +#: ``createdAt`` and ``updatedAt`` are deliberately absent. The search route sends them +#: alongside ``dateAdded`` and ``dateUpdated`` carrying byte-identical values, so keeping both +#: pairs would double the timestamps in every record for nothing. ``searchAfter`` is absent for +#: the same reason it is on contacts: it is a pagination cursor riding on the record, and it +#: belongs to the envelope this node builds rather than to the task. +_TASK_READ_KEYS = ( + 'id', + 'contactId', + 'locationId', + 'businessId', + 'title', + 'body', + 'dueDate', + 'completed', + 'status', + 'statusGroup', + 'assignedTo', + 'assignedToUserDetails', + 'contactDetails', + 'deleted', + 'dateAdded', + 'dateUpdated', +) + +#: Body fields accepted by contact_tasks_create and contact_tasks_update. +#: +#: CreateTaskParams and UpdateTaskBody declare the same five properties, so one tuple serves +#: both. They differ only in what they require: create requires title, dueDate and completed, +#: update requires nothing. +_TASK_WRITE_KEYS = ('title', 'body', 'dueDate', 'completed', 'assignedTo') + +#: How this node describes the due date, stated once because three tools mention it. +_DUE_DATE_DESC = ( + 'When the task is due, as an ISO 8601 UTC timestamp, for example "2026-08-01T15:00:00Z". Reads return it ' + 'with milliseconds ("2026-08-01T15:00:00.000Z"); either form is accepted on a write.' +) + +_TASK_WRITE_PROPS = { + 'title': STR('Short title of the task. This is what shows in the GoHighLevel task list.'), + 'body': STR('Longer description of what the task involves.'), + 'dueDate': STR(_DUE_DATE_DESC), + 'completed': BOOL('Whether the task is already done.'), + 'assignedTo': STR( + 'Id of the GoHighLevel user the task is for. Get user ids from user_list_by_location. An unassigned ' + 'task is still created, but nobody is notified about it.' + ), +} + +#: What a task read returns for its id, stated once because four tools return a task. +_TASK_ID_NOTE = ( + 'id is normalised by this node: the contact-scoped routes return it as id and the location-wide task search ' + 'returns the same task as _id, and both arrive here as id.' +) + +_TASK_RECORD_OUTPUT = RECORD_OUTPUT(f'The task, trimmed to the fields this node returns. {_TASK_ID_NOTE}') + + +def _clean_task(task: Any) -> dict: + """Trim a task record and give it one id field whichever route it came from. + + GoHighLevel returns the same task keyed ``id`` from ``GET /contacts/{contactId}/tasks`` + and keyed ``_id`` from ``POST /locations/{locationId}/tasks/search``, live-confirmed on + both. An agent that read a task from one route and passed its id to a tool on the other + would otherwise be handing over a record with no id in it at all, so the underscore form + is folded into ``id`` here rather than surfaced as a second field to reason about. + """ + if not isinstance(task, dict): + return {} + out = {key: task[key] for key in _TASK_READ_KEYS if key in task} + if out.get('id') is None: + underscored = task.get('_id') + if underscored is not None: + out['id'] = underscored + return out + + +class ContactTasksMixin(GoHighLevelToolsBase): + """Tools for the ``contact_tasks`` group.""" + + @gohighlevel_tool( + group='contact_tasks', + input_schema=schema(required=['contactId'], contactId=STR('Id of the contact.')), + description=( + 'List the tasks on a contact, open and completed alike. There is no filter and no cursor: this ' + 'endpoint returns every task on the contact in one response, so read completed on each record to ' + 'tell the outstanding ones apart. Returns tasks under items. Two fields the location-wide task ' + 'search reports, status and statusGroup, are absent here, so completed is the only progress signal ' + 'this route gives.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every task in one response, so there is no next page.', + items=f'The tasks on the contact, each trimmed to the fields this node returns. {_TASK_ID_NOTE}', + ), + ) + def contact_tasks_list(self, args): + args = self._args(args, 'contact_tasks_list') + contact_id = require_id(args, 'contactId', 'contact_tasks_list') + return self._list(f'/contacts/{contact_id}/tasks', key='tasks', cleaner=_clean_task) + + @gohighlevel_tool( + group='contact_tasks', + input_schema=schema( + required=['contactId', 'taskId'], + contactId=STR('Id of the contact the task belongs to.'), + taskId=STR('Id of the task. Get task ids from contact_tasks_list.'), + ), + description=( + 'Get one task by id. Both ids are needed: a task is addressed through its contact, so a task id on ' + 'its own does not identify anything. Use contact_tasks_list when you have the contact but not the ' + 'task id.' + ), + output_schema=_TASK_RECORD_OUTPUT, + ) + def contact_tasks_get(self, args): + args = self._args(args, 'contact_tasks_get') + contact_id = require_id(args, 'contactId', 'contact_tasks_get') + task_id = require_id(args, 'taskId', 'contact_tasks_get') + return self._get(f'/contacts/{contact_id}/tasks/{task_id}', _clean_task, key='task') + + @gohighlevel_tool( + group='contact_tasks', + writes=True, + input_schema=schema( + required=['contactId', 'title', 'dueDate'], + contactId=STR('Id of the contact to attach the task to.'), + **_TASK_WRITE_PROPS, + ), + description=( + 'Create a task on a contact: a follow-up call, a document to send, anything a person has to do ' + 'next. title and dueDate are both required by GoHighLevel, so there is no way to file an undated ' + 'reminder here. completed is required by the API as well and is sent as false when you omit it, ' + 'which is what a new task almost always means; pass it as true only to log something already done. ' + 'Set assignedTo to make the task somebody in particular.' + ), + output_schema=_TASK_RECORD_OUTPUT, + ) + def contact_tasks_create(self, args): + args = self._args(args, 'contact_tasks_create') + contact_id = require_id(args, 'contactId', 'contact_tasks_create') + require_text(args, 'title', 'contact_tasks_create') + require_text(args, 'dueDate', 'contact_tasks_create') + body = body_from(args, _TASK_WRITE_KEYS) + # CreateTaskParams requires completed, so something has to be sent. False is the only + # defensible default for a task being created, and defaulting it locally is better than + # forwarding a vendor 422 that reads as though the whole request was malformed. + body.setdefault('completed', False) + return self._write('POST', f'/contacts/{contact_id}/tasks', _clean_task, key='task', body=body) + + @gohighlevel_tool( + group='contact_tasks', + writes=True, + input_schema=schema( + required=['contactId', 'taskId'], + contactId=STR('Id of the contact the task belongs to.'), + taskId=STR('Id of the task to update. Get task ids from contact_tasks_list.'), + **_TASK_WRITE_PROPS, + ), + description=( + 'Update a task. Only the fields you pass are changed, so this is how to reschedule a due date, ' + 'reword a title or hand the task to a different user. Every field is optional here, including the ' + 'three contact_tasks_create insists on. To do nothing but tick a task off, use ' + 'contact_tasks_set_completed, which needs no other field.' + ), + output_schema=_TASK_RECORD_OUTPUT, + ) + def contact_tasks_update(self, args): + args = self._args(args, 'contact_tasks_update') + contact_id = require_id(args, 'contactId', 'contact_tasks_update') + task_id = require_id(args, 'taskId', 'contact_tasks_update') + body = body_from(args, _TASK_WRITE_KEYS) + return self._write('PUT', f'/contacts/{contact_id}/tasks/{task_id}', _clean_task, key='task', body=body) + + @gohighlevel_tool( + group='contact_tasks', + writes=True, + input_schema=schema( + required=['contactId', 'taskId', 'completed'], + contactId=STR('Id of the contact the task belongs to.'), + taskId=STR('Id of the task. Get task ids from contact_tasks_list.'), + completed=BOOL('True to mark the task done, false to reopen it.'), + ), + description=( + 'Mark a task done, or reopen one, without touching its title, due date or assignee. This is the ' + 'tool for closing out a follow-up: contact_tasks_update can set the same field but takes the whole ' + 'task body with it. Returns the task as it now stands.' + ), + output_schema=_TASK_RECORD_OUTPUT, + ) + def contact_tasks_set_completed(self, args): + args = self._args(args, 'contact_tasks_set_completed') + contact_id = require_id(args, 'contactId', 'contact_tasks_set_completed') + task_id = require_id(args, 'taskId', 'contact_tasks_set_completed') + completed = args.get('completed') + # The whole point of this tool is the flag, so an absent or string-typed one is caught + # here. UpdateTaskStatusParams declares it required and typed boolean, and "false" as a + # string is the shape an agent most often reaches for. + if not isinstance(completed, bool): + raise ValueError('contact_tasks_set_completed: "completed" is required and must be true or false.') + body = {'completed': completed} + path = f'/contacts/{contact_id}/tasks/{task_id}/completed' + return self._write('PUT', path, _clean_task, key='task', body=body) + + @gohighlevel_tool( + group='contact_tasks', + writes=True, + input_schema=schema( + required=['contactId', 'taskId'], + contactId=STR('Id of the contact the task belongs to.'), + taskId=STR('Id of the task to delete. Get task ids from contact_tasks_list.'), + ), + description=( + 'Delete a task. There is no undo. Prefer contact_tasks_set_completed for work that is finished: ' + 'a completed task stays on the contact as a record that it happened, a deleted one does not.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def contact_tasks_delete(self, args): + args = self._args(args, 'contact_tasks_delete') + contact_id = require_id(args, 'contactId', 'contact_tasks_delete') + task_id = require_id(args, 'taskId', 'contact_tasks_delete') + return self._delete(f'/contacts/{contact_id}/tasks/{task_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/contacts.py b/nodes/src/nodes/tool_gohighlevel/tools/contacts.py new file mode 100644 index 000000000..11f4c6fb3 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/contacts.py @@ -0,0 +1,732 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Contact tools: the contact record plus its tags, followers, workflows and appointments.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import normalize_success, paginated, split_custom_fields +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + CUSTOM_FIELDS_DESC, + EXTRA, + LIST_OUTPUT, + MIXED_ARR, + OBJ, + PAGING_SEARCH_AFTER, + PAGING_SKIP_TEXT, + PAGING_START_AFTER, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + next_offset, + params_from, + passthrough, + record_of, + require_id, + schema, + search_after_body, + search_after_cursor, + skip_text_params, + start_after_cursor, + start_after_params, +) + +#: Fields kept from a contact record. The payload is not enormous, but it carries several +#: pairs of near-duplicate keys and a pagination cursor that belongs to the envelope rather +#: than to the record, and dropping those makes what is left readable. +#: +#: Both casing variants are kept deliberately. The two list routes disagree about which key +#: holds the display casing: GET /contacts/ returns ``firstName`` lowercased with the cased +#: value in ``firstNameRaw``, while POST /contacts/search returns ``firstName`` cased with the +#: lowercased value in ``firstNameLowerCase``. Keeping only one of them loses the real name on +#: one of the two routes. +_CONTACT_READ_KEYS = ( + 'id', + 'locationId', + 'contactName', + 'firstName', + 'lastName', + 'firstNameRaw', + 'lastNameRaw', + 'name', + 'email', + 'additionalEmails', + 'phone', + 'additionalPhones', + 'companyName', + 'businessName', + 'businessId', + 'type', + 'source', + 'assignedTo', + 'address', + 'address1', + 'city', + 'state', + 'postalCode', + 'country', + 'website', + 'timezone', + 'dnd', + 'dndSettings', + 'inboundDndSettings', + 'dateOfBirth', + 'dateAdded', + 'dateUpdated', + 'lastActivity', + 'tags', + 'followers', + 'opportunities', + 'attributionSource', + 'lastAttributionSource', + 'attributions', + 'profilePhoto', +) + +#: Body fields shared by the three write operations. contact_create extends this set (see +#: :data:`_CONTACT_CREATE_KEYS`); contact_upsert uses it as-is; contact_update narrows it. +#: +#: ``tags`` is deliberately absent here. Sending it replaces every tag on the contact rather +#: than adding to them, so it is destructive on update and on an upsert that resolves to an +#: update, and tag changes go through contact_tags_add and contact_tags_remove instead. +#: ``extra`` cannot smuggle it back in on those two either: :data:`EXTRA_FORBIDDEN` rejects it +#: before the request goes out. Only contact_create accepts tags, because a contact being +#: created has none to lose. +#: +#: ``locationId`` is absent too: the node supplies it from config on the two operations that +#: take it, and UpdateContactDto has no such property at all. +_CONTACT_WRITE_KEYS = ( + 'firstName', + 'lastName', + 'name', + 'email', + 'phone', + 'gender', + 'companyName', + 'address1', + 'city', + 'state', + 'postalCode', + 'country', + 'website', + 'timezone', + 'source', + 'assignedTo', + 'dnd', + 'dndSettings', + 'inboundDndSettings', + 'dateOfBirth', + 'customFields', +) + +#: Fields CreateContactDto and UpsertContactDto declare and UpdateContactDto does not. +#: +#: Verified against the vendor spec: both create bodies list ``gender`` and ``companyName``, +#: the update body lists neither. GoHighLevel rejects an undeclared property with a 422 rather +#: than ignoring it, so offering them on contact_update would show the agent two parameters +#: whose only outcome is a hard vendor error, which is exactly what the local argument check +#: exists to prevent. Update therefore gets its own key tuple and its own schema. +#: +#: This is the create-versus-update policy for the whole node: when the two DTOs disagree, +#: each operation sends what its own DTO declares. Several modules still to be written +#: (opportunities, appointments, tasks, notes) have the same drift. +_CREATE_ONLY_KEYS = ('gender', 'companyName') + +#: Fields a create carries that mean the request is about a specific person. +#: +#: GoHighLevel rejects a create whose only content is the sub-account id, live-confirmed as an +#: HTTP 400 whose body claims 422, so this list is checked locally to turn that into a message +#: naming what to send. +_CONTACT_IDENTIFYING_KEYS = ('firstName', 'lastName', 'name', 'email', 'phone', 'companyName') + +_CONTACT_WRITE_PROPS = { + 'firstName': STR('First name.'), + 'lastName': STR('Last name.'), + 'name': STR('Full name. Set this instead of firstName and lastName when you only have one string.'), + 'email': STR('Primary email address.'), + 'phone': STR('Primary phone number in E.164 form, for example "+18888888888".'), + 'gender': STR('Gender, for example "male".'), + 'companyName': STR('Company the contact belongs to.'), + 'address1': STR('Street address.'), + 'city': STR('City.'), + 'state': STR('State or region, for example "AL".'), + 'postalCode': STR('Postal code.'), + 'country': STR('2-letter country code, for example "US".'), + 'website': STR('Website URL.'), + 'timezone': STR('IANA timezone name, for example "America/Chihuahua".'), + 'source': STR('Where the contact came from. Free text that shows in the GoHighLevel UI.'), + 'assignedTo': STR('Id of the user the contact is assigned to. Get user ids from user_list_by_location.'), + 'dnd': BOOL('Whether to stop all outbound messaging to this contact.'), + 'dndSettings': OBJ( + 'Per-channel do-not-disturb settings, keyed by channel (Call, Email, SMS, WhatsApp, GMB, FB). ' + 'Each value is an object with status, message and code.' + ), + 'inboundDndSettings': OBJ('Inbound do-not-disturb settings, under an "all" key holding status and message.'), + 'dateOfBirth': STR('Birth date. YYYY-MM-DD is accepted, as are MM/DD/YYYY and several other separators.'), + 'customFields': MIXED_ARR(f'Custom field values to set. {CUSTOM_FIELDS_DESC}'), + 'extra': EXTRA(), +} + +#: Body keys and schema properties for contact_create only. +#: +#: ``tags`` is safe here and nowhere else. GoHighLevel replaces the whole tag array rather than +#: appending to it, which is destructive on update and on an upsert that resolves to an update, +#: but a contact being created has no tags to lose. Tagging at create time also saves a request +#: against a measured 25-request/10s budget, so the alternative (create, then contact_tags_add) +#: is worse for the common "new lead arrives tagged" flow. +_CONTACT_CREATE_KEYS = _CONTACT_WRITE_KEYS + ('tags',) +_CONTACT_CREATE_PROPS = { + **_CONTACT_WRITE_PROPS, + 'tags': ARR( + 'Tags to apply to the new contact. Safe on create because the contact has no tags yet. ' + 'Use contact_tags_add on an existing contact: sending tags to contact_update or ' + 'contact_upsert replaces every tag rather than adding to them.' + ), +} + +#: Body keys and schema properties for contact_update, which is the create set minus the two +#: fields UpdateContactDto does not declare. +_CONTACT_UPDATE_KEYS = tuple(key for key in _CONTACT_WRITE_KEYS if key not in _CREATE_ONLY_KEYS) +_CONTACT_UPDATE_PROPS = {key: prop for key, prop in _CONTACT_WRITE_PROPS.items() if key not in _CREATE_ONLY_KEYS} + +#: What a contact read returns for custom fields, stated once because three tools return one. +_CONTACT_CUSTOM_FIELDS_READ = ( + 'Custom field values come back as one object keyed by field id, or verbatim when GoHighLevel ' + 'sends them in a shape this node does not recognise. Neither is the array shape contact_create ' + 'and contact_update take.' +) + +_CONTACT_RECORD_OUTPUT = RECORD_OUTPUT( + f'The contact, trimmed to the fields this node returns. {_CONTACT_CUSTOM_FIELDS_READ}' +) + +_CONTACT_ITEMS = 'The contacts on this page, each trimmed to the fields this node returns.' + + +def _clean_contact(contact: Any) -> dict: + """Trim a contact record and project its custom fields into a dict keyed by field id. + + The projection is a best effort, not a filter. GoHighLevel's declared response schemas are + unreliable and the custom-field shape already differs by route, so a value the projection + does not recognise is passed through as it arrived rather than dropped: losing data + silently is worse than handing the agent a shape it has to look at. + """ + if not isinstance(contact, dict): + return {} + out = {key: contact[key] for key in _CONTACT_READ_KEYS if key in contact} + raw = contact.get('customFields') + if raw is not None: + custom = split_custom_fields(raw) + out['customFields'] = custom or raw + return out + + +def _contact_total(payload: Any) -> Any: + """Total record count for a contacts list, under whichever key that route reports it. + + The three contact list routes name it three ways and none of them always sends it, so this + returns None rather than a synthesised number when it is absent. + """ + if not isinstance(payload, dict): + return None + meta = payload.get('meta') + if isinstance(meta, dict) and meta.get('total') is not None: + return meta['total'] + for key in ('total', 'count'): + if payload.get(key) is not None: + return payload[key] + return None + + +def _require_any_of(values: dict, keys: tuple[str, ...], tool: str, note: str = '') -> None: + """Fail locally when a request carries none of the fields its endpoint needs. + + Several GoHighLevel operations declare every field optional and then reject the request + that supplies none of them, with vendor wording the agent cannot act on. Checking here + costs nothing and gives back a message naming the parameters that would satisfy it. + + ``values`` is the assembled body or query fragment rather than the raw arguments, so a + field supplied through the ``extra`` escape hatch counts. + + This belongs in ``_base.py`` once a second module needs it: the remaining modules have at + least one more at-least-one-of endpoint (calendar events want exactly one of userId, + calendarId or groupId). + """ + if any(values.get(key) is not None for key in keys): + return + listed = ', '.join(f'"{key}"' for key in keys) + message = f'{tool}: pass at least one of {listed}.' + raise ValueError(f'{message} {note}' if note else message) + + +def _tag_result(payload: Any) -> dict: + """Tags reported back by a tag write, which is the contact's whole list rather than a delta.""" + if not isinstance(payload, dict): + return {'tags': []} + return {'tags': payload.get('tags') or []} + + +def _follower_result(payload: Any) -> dict: + """Followers reported back by a follower write, plus the delta when the API states one.""" + if not isinstance(payload, dict): + return {'followers': []} + out: dict = {'followers': payload.get('followers') or []} + for key in ('followersAdded', 'followersRemoved'): + if key in payload: + out[key] = payload[key] or [] + return out + + +def _flag_result(payload: Any) -> dict: + """Outcome of a call whose whole answer is a success flag. + + The flag is spelled ``succeded``, ``succeeded`` or ``success`` depending on the endpoint, + so it is read through normalize_success rather than by name. + """ + return {'ok': normalize_success(payload)} + + +def _upsert_result(payload: Any) -> dict: + """Upsert response: the contact, plus whether it was created rather than updated.""" + if not isinstance(payload, dict): + return {'contact': _clean_contact(payload), 'new': None} + return {'contact': _clean_contact(payload.get('contact')), 'new': payload.get('new')} + + +class ContactsMixin(GoHighLevelToolsBase): + """Tools for the ``contacts`` group.""" + + # -- the contact record ------------------------------------------------ + + @gohighlevel_tool( + group='contacts', + input_schema=schema( + **PAGING_START_AFTER('contact_list'), + query=STR('Free-text search across the contact record.'), + ), + description=( + 'List contacts in the configured sub-account. The only narrowing this endpoint offers is the ' + 'free-text query parameter: it cannot filter on a named field, and GoHighLevel marks it deprecated ' + 'in favour of contact_search. Reach for contact_search whenever you know anything about the contact ' + 'you want (email, phone, name, tag) and use this one to scan the whole sub-account. Returns contacts ' + 'under items. next holds {"startAfter", "startAfterId"}: pass those two values back as the startAfter ' + 'and startAfterId parameters, and ask for another page only while has_more is true.' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: an object holding startAfter and startAfterId, taken from the last record ' + 'of this page. Pass both back as the startAfter and startAfterId parameters. Null when the page came ' + 'back short, which means there is no next page.', + items=_CONTACT_ITEMS, + ), + ) + def contact_list(self, args): + args = self._args(args, 'contact_list') + params, limit = start_after_params(args, 'contact_list') + params.update(params_from(args, ('query',))) + params['locationId'] = self._location() + payload, records = self._fetch('GET', '/contacts/', key='contacts', params=params) + return paginated( + [_clean_contact(record) for record in records], + total=_contact_total(payload), + next_cursor=start_after_cursor(records), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='contacts', + input_schema=schema( + **PAGING_SEARCH_AFTER('contact_search'), + filters=MIXED_ARR( + 'Filter clauses, each an object of the form ' + '{"field": "email", "operator": "eq", "value": "a@b.com"}. The operators confirmed to work are ' + '"eq" for an exact match and "contains" for a substring match; GoHighLevel publishes no list of ' + 'the rest. Omit this to walk every contact in the sub-account.' + ), + sort=MIXED_ARR('Sort clauses, each an object naming a field and a direction.'), + ), + description=( + 'Find contacts in the configured sub-account by email, phone, name, tag or any other field. This is ' + 'the successor to contact_list and the only contacts read that can filter, so prefer it for every ' + 'lookup. Omit filters to walk the whole sub-account. Returns contacts under items. next holds an ' + 'opaque searchAfter value: pass it back unchanged as the searchAfter parameter, and ask for another ' + 'page only while has_more is true. Only "eq" and "contains" are confirmed to work, and GoHighLevel ' + 'publishes no schema for this endpoint, so an unfamiliar filter shape is likely to be rejected ' + 'rather than ignored.' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: the opaque searchAfter value carried by the last record of this page. ' + 'Pass it back unchanged as the searchAfter parameter. Null when the page came back short, which ' + 'means there is no next page.', + items=_CONTACT_ITEMS, + ), + ) + def contact_search(self, args): + args = self._args(args, 'contact_search') + body, limit = search_after_body(args, 'contact_search') + body.update(body_from(args, ('filters', 'sort'))) + body['locationId'] = self._location() + payload, records = self._fetch('POST', '/contacts/search', key='contacts', body=body) + return paginated( + [_clean_contact(record) for record in records], + total=_contact_total(payload), + next_cursor=search_after_cursor(records), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='contacts', + input_schema=schema(required=['contactId'], contactId=STR('Id of the contact.')), + description=( + 'Get one contact by id. The record carries the contact tags and followers inline, and those are the ' + 'only way to read them: GoHighLevel has no endpoint that lists a contact tags, followers, campaigns ' + f'or workflows. {_CONTACT_CUSTOM_FIELDS_READ}' + ), + output_schema=_CONTACT_RECORD_OUTPUT, + ) + def contact_get(self, args): + args = self._args(args, 'contact_get') + contact_id = require_id(args, 'contactId', 'contact_get') + return self._get(f'/contacts/{contact_id}', _clean_contact, key='contact') + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema(**_CONTACT_CREATE_PROPS), + description=( + 'Create a contact in the configured sub-account. Pass at least one identifying field: a request ' + 'carrying nothing but the sub-account id is rejected. Tags can be set here, because a contact being ' + 'created has none to lose; on an existing contact use contact_tags_add instead, since GoHighLevel ' + 'replaces the whole tag array rather than adding to it. Use contact_upsert instead when the contact ' + 'may already exist.' + ), + output_schema=_CONTACT_RECORD_OUTPUT, + ) + def contact_create(self, args): + args = self._args(args, 'contact_create') + body = body_from(args, _CONTACT_CREATE_KEYS, tool='contact_create', forbidden={}) + _require_any_of( + body, + _CONTACT_IDENTIFYING_KEYS, + 'contact_create', + 'GoHighLevel rejects a create carrying nothing but the sub-account id.', + ) + body['locationId'] = self._location() + return self._write('POST', '/contacts/', _clean_contact, key='contact', body=body) + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId'], + contactId=STR('Id of the contact to update.'), + **_CONTACT_UPDATE_PROPS, + ), + description=( + 'Update a contact. Only the fields you pass are changed. Tags cannot be changed here: the underlying ' + 'field replaces every tag on the contact rather than adding to them, so this tool does not accept it ' + 'and contact_tags_add and contact_tags_remove are the safe route. Sending customFields replaces only ' + 'the fields you name. GoHighLevel does not accept gender or companyName on an update, so this tool ' + 'does not offer them even though contact_create does.' + ), + output_schema=_CONTACT_RECORD_OUTPUT, + ) + def contact_update(self, args): + args = self._args(args, 'contact_update') + contact_id = require_id(args, 'contactId', 'contact_update') + body = body_from(args, _CONTACT_UPDATE_KEYS, tool='contact_update') + return self._write('PUT', f'/contacts/{contact_id}', _clean_contact, key='contact', body=body) + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema(required=['contactId'], contactId=STR('Id of the contact to delete.')), + description='Delete a contact by id.', + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def contact_delete(self, args): + args = self._args(args, 'contact_delete') + contact_id = require_id(args, 'contactId', 'contact_delete') + return self._delete(f'/contacts/{contact_id}') + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + createNewIfDuplicateAllowed=BOOL( + 'Whether to create a second contact when the email or phone already belongs to one, instead of ' + 'updating the existing contact.' + ), + **_CONTACT_WRITE_PROPS, + ), + description=( + 'Create a contact, or update the existing one when its email or phone already belongs to a contact. ' + 'Returns the contact plus new, which is true when a contact was created rather than updated. Which of ' + 'the two happens depends on the sub-account Allow Duplicate Contact setting, so the same request can ' + 'behave differently in different sub-accounts, and when an email matches one contact and a phone ' + 'matches another only the first is touched. Call contact_duplicate_check first when you need to know ' + 'in advance.' + ), + output_schema=RECORD_OUTPUT( + 'The contact under contact, trimmed to the fields this node returns, plus new, which is true when a ' + f'contact was created rather than updated. {_CONTACT_CUSTOM_FIELDS_READ}' + ), + ) + def contact_upsert(self, args): + args = self._args(args, 'contact_upsert') + body = body_from(args, _CONTACT_WRITE_KEYS + ('createNewIfDuplicateAllowed',), tool='contact_upsert') + body['locationId'] = self._location() + return self._write('POST', '/contacts/upsert', _upsert_result, body=body) + + @gohighlevel_tool( + group='contacts', + input_schema=schema( + number=STR('Phone number to look for, in E.164 form, for example "+18888888888".'), + email=STR('Email address to look for.'), + ), + description=( + 'Check whether a phone number or email already belongs to a contact, before creating one. Pass at ' + 'least one of number or email. Returns found, plus the matching contact when there is one. ' + 'GoHighLevel publishes no response schema for this endpoint, so treat found as the reliable part.' + ), + output_schema=RECORD_OUTPUT( + 'found is true when the number or email already belongs to a contact, and contact holds that ' + 'contact when there is one, trimmed to the fields this node returns.' + ), + ) + def contact_duplicate_check(self, args): + args = self._args(args, 'contact_duplicate_check') + params = params_from(args, ('number', 'email')) + _require_any_of(params, ('number', 'email'), 'contact_duplicate_check') + params['locationId'] = self._location() + payload = self._call('GET', '/contacts/search/duplicate', params=params) + contact = _clean_contact(record_of(payload, 'contact')) + return {'found': bool(contact.get('id')), 'contact': contact} + + @gohighlevel_tool( + group='contacts', + input_schema=schema( + required=['businessId'], + businessId=STR( + 'Id of the business. It is in the business URL in the GoHighLevel UI. business_list returns ' + 'business ids too, but it belongs to the "businesses" tool group, which this node does not ' + 'publish unless gohighlevel.toolGroups names it.' + ), + query=STR('Free-text search across the contact record.'), + **PAGING_SKIP_TEXT('contact_list_by_business'), + ), + description=( + 'List the contacts assigned to a business. This one pages by offset rather than by cursor: next is ' + 'the skip value for the following page, and it is null once a page comes back shorter than the limit ' + 'that was asked for.' + ), + output_schema=LIST_OUTPUT( + 'Offset for the next page: pass it back as the skip parameter. Null once a page comes back shorter ' + 'than the limit asked for, which means there is no next page.', + items=_CONTACT_ITEMS, + ), + ) + def contact_list_by_business(self, args): + args = self._args(args, 'contact_list_by_business') + business_id = require_id(args, 'businessId', 'contact_list_by_business') + params, limit = skip_text_params(args, 'contact_list_by_business') + params.update(params_from(args, ('query',))) + params['locationId'] = self._location() + payload, records = self._fetch('GET', f'/contacts/business/{business_id}', key='contacts', params=params) + return paginated( + [_clean_contact(record) for record in records], + total=_contact_total(payload), + next_cursor=next_offset(args.get('skip'), limit, len(records)), + requested_limit=limit, + ) + + # -- tags -------------------------------------------------------------- + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'tags'], + contactId=STR('Id of the contact.'), + tags=ARR('Tag names to add. Names that do not exist yet are created on the sub-account.'), + ), + description=( + 'Add tags to a contact. Returns the contact whole tag list, not just the additions. Use this rather ' + 'than contact_update, whose tags field would replace every tag the contact already has. A tag name ' + 'that does not exist yet is defined on the whole sub-account as a side effect, and ' + 'contact_tags_remove takes it off the contact without removing that definition: only ' + 'location_tags_delete does. Tagging many contacts with generated names therefore grows the ' + 'sub-account tag list permanently, so reuse the names location_tags_list already reports.' + ), + output_schema=RECORD_OUTPUT('The contact whole tag list after the change, under tags, not just the additions.'), + ) + def contact_tags_add(self, args): + args = self._args(args, 'contact_tags_add') + contact_id = require_id(args, 'contactId', 'contact_tags_add') + body = body_from(args, ('tags',)) + return self._write('POST', f'/contacts/{contact_id}/tags', _tag_result, body=body) + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'tags'], + contactId=STR('Id of the contact.'), + tags=ARR('Tag names to remove.'), + ), + description=( + 'Remove tags from a contact. Returns the tags the contact still has. There is no endpoint that lists ' + 'a contact tags, so read them from the record contact_get returns. This takes the tag off the contact ' + 'only. A name that contact_tags_add defined on the sub-account stays defined afterwards and keeps ' + 'showing up in location_tags_list; location_tags_delete is the only tool that removes the definition.' + ), + output_schema=RECORD_OUTPUT('The tags the contact still has after the change, under tags.'), + ) + def contact_tags_remove(self, args): + args = self._args(args, 'contact_tags_remove') + contact_id = require_id(args, 'contactId', 'contact_tags_remove') + body = body_from(args, ('tags',)) + return self._write('DELETE', f'/contacts/{contact_id}/tags', _tag_result, body=body) + + # -- followers --------------------------------------------------------- + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'followers'], + contactId=STR('Id of the contact.'), + followers=ARR('User ids to add as followers. Get user ids from user_list_by_location.'), + ), + description=( + 'Add users as followers of a contact. There is no endpoint that lists a contact followers, so read ' + 'the followers array on the record contact_get returns.' + ), + output_schema=RECORD_OUTPUT( + 'The contact whole follower list after the change, under followers, plus followersAdded when ' + 'GoHighLevel reports which ids it added.' + ), + ) + def contact_followers_add(self, args): + args = self._args(args, 'contact_followers_add') + contact_id = require_id(args, 'contactId', 'contact_followers_add') + body = body_from(args, ('followers',)) + return self._write('POST', f'/contacts/{contact_id}/followers', _follower_result, body=body) + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'followers'], + contactId=STR('Id of the contact.'), + followers=ARR('User ids to remove as followers.'), + ), + description='Remove followers from a contact, by user id.', + output_schema=RECORD_OUTPUT( + 'The contact whole follower list after the change, under followers, plus followersRemoved when ' + 'GoHighLevel reports which ids it removed.' + ), + ) + def contact_followers_remove(self, args): + args = self._args(args, 'contact_followers_remove') + contact_id = require_id(args, 'contactId', 'contact_followers_remove') + body = body_from(args, ('followers',)) + return self._write('DELETE', f'/contacts/{contact_id}/followers', _follower_result, body=body) + + # -- workflows --------------------------------------------------------- + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'workflowId'], + contactId=STR('Id of the contact.'), + workflowId=STR('Id of the workflow.'), + eventStartTime=STR( + 'When the workflow should first run, as an ISO 8601 timestamp carrying an offset, for example ' + '"2021-06-23T03:30:00+01:00".' + ), + ), + description=( + 'Enrol a contact in a workflow. GoHighLevel has no endpoint that lists workflows or the workflows a ' + 'contact is already in, so the workflowId has to come from the workflow URL in the GoHighLevel UI.' + ), + output_schema=RECORD_OUTPUT('ok is true when GoHighLevel reported the enrolment as successful.'), + ) + def contact_workflow_add(self, args): + args = self._args(args, 'contact_workflow_add') + contact_id = require_id(args, 'contactId', 'contact_workflow_add') + workflow_id = require_id(args, 'workflowId', 'contact_workflow_add') + body = body_from(args, ('eventStartTime',)) + return self._write('POST', f'/contacts/{contact_id}/workflow/{workflow_id}', _flag_result, body=body) + + @gohighlevel_tool( + group='contacts', + writes=True, + input_schema=schema( + required=['contactId', 'workflowId'], + contactId=STR('Id of the contact.'), + workflowId=STR('Id of the workflow to remove the contact from.'), + ), + description=( + 'Remove a contact from a workflow. GoHighLevel has no endpoint that lists the workflows a contact is ' + 'in, so the workflowId has to come from the GoHighLevel UI or from the call that added it.' + ), + output_schema=RECORD_OUTPUT('ok is true when GoHighLevel reported the removal as successful.'), + ) + def contact_workflow_remove(self, args): + args = self._args(args, 'contact_workflow_remove') + contact_id = require_id(args, 'contactId', 'contact_workflow_remove') + workflow_id = require_id(args, 'workflowId', 'contact_workflow_remove') + # This DELETE declares a required JSON body, so an empty object is sent rather than nothing. + return self._write('DELETE', f'/contacts/{contact_id}/workflow/{workflow_id}', _flag_result, body={}) + + # -- related records --------------------------------------------------- + + @gohighlevel_tool( + group='contacts', + input_schema=schema(required=['contactId'], contactId=STR('Id of the contact.')), + description=( + 'List the calendar appointments booked for a contact. Returns appointments under items, and the ' + 'whole list comes back in one response, so there is no cursor. Times here are naive local strings ' + 'formatted "YYYY-MM-DD HH:mm:ss" with no timezone, while the same appointment read through ' + 'appointment_get carries a numeric UTC offset instead, so compare the two with care.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every appointment in one response, so there is no next page.', + items='The appointments booked for the contact, as GoHighLevel returns them.', + ), + ) + def contact_appointments_list(self, args): + args = self._args(args, 'contact_appointments_list') + contact_id = require_id(args, 'contactId', 'contact_appointments_list') + return self._list(f'/contacts/{contact_id}/appointments', key='events', cleaner=passthrough) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/conversations.py b/nodes/src/nodes/tool_gohighlevel/tools/conversations.py new file mode 100644 index 000000000..bff81e110 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/conversations.py @@ -0,0 +1,340 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Conversation tools: the thread a contact's messages hang off, and its read and starred state.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + ENUM, + INT, + LIST_OUTPUT, + OBJ, + PAGING_START_AFTER_DATE, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + params_from, + require_id, + schema, + start_after_date_params, +) + +#: Message-type values a conversation's last message can carry, verbatim from the +#: ``lastMessageType`` enum of ``GET /conversations/search``. +#: +#: The list is long and stays complete rather than being trimmed to the common values. +#: GoHighLevel validates an enum strictly and answers 422 for a value outside it, so a +#: shortened list would turn a legal filter into a hard vendor error the agent cannot act on. +_LAST_MESSAGE_TYPES = ( + 'TYPE_CALL', + 'TYPE_SMS', + 'TYPE_RCS', + 'TYPE_EMAIL', + 'TYPE_SMS_REVIEW_REQUEST', + 'TYPE_WEBCHAT', + 'TYPE_SMS_NO_SHOW_REQUEST', + 'TYPE_CAMPAIGN_SMS', + 'TYPE_CAMPAIGN_CALL', + 'TYPE_CAMPAIGN_EMAIL', + 'TYPE_CAMPAIGN_VOICEMAIL', + 'TYPE_FACEBOOK', + 'TYPE_CAMPAIGN_FACEBOOK', + 'TYPE_CAMPAIGN_MANUAL_CALL', + 'TYPE_CAMPAIGN_MANUAL_SMS', + 'TYPE_GMB', + 'TYPE_CAMPAIGN_GMB', + 'TYPE_REVIEW', + 'TYPE_INSTAGRAM', + 'TYPE_WHATSAPP', + 'TYPE_CUSTOM_SMS', + 'TYPE_CUSTOM_EMAIL', + 'TYPE_CUSTOM_PROVIDER_SMS', + 'TYPE_CUSTOM_PROVIDER_EMAIL', + 'TYPE_IVR_CALL', + 'TYPE_ACTIVITY_CONTACT', + 'TYPE_ACTIVITY_INVOICE', + 'TYPE_ACTIVITY_PAYMENT', + 'TYPE_ACTIVITY_OPPORTUNITY', + 'TYPE_LIVE_CHAT', + 'TYPE_LIVE_CHAT_INFO_MESSAGE', + 'TYPE_ACTIVITY_APPOINTMENT', + 'TYPE_FACEBOOK_COMMENT', + 'TYPE_INSTAGRAM_COMMENT', + 'TYPE_CUSTOM_CALL', + 'TYPE_INTERNAL_COMMENT', + 'TYPE_ACTIVITY_EMPLOYEE_ACTION_LOG', + 'TYPE_TIKTOK', + 'TYPE_TIKTOK_COMMENT', + 'TYPE_ACTIVITY_WHATSAPP', + 'TYPE_FORM_SUBMISSION', + 'TYPE_SMS_REACTION', +) + +#: Query keys conversation_search forwards, minus the ones the paging builder owns. +_SEARCH_KEYS = ( + 'contactId', + 'assignedTo', + 'followers', + 'mentions', + 'query', + 'sort', + 'sortBy', + 'status', + 'lastMessageType', + 'lastMessageAction', + 'lastMessageDirection', + 'startDate', + 'endDate', +) + +#: Body fields conversation_update accepts. +#: +#: There is no shared write-key tuple here, unlike contacts. CreateConversationDto declares +#: exactly ``locationId`` and ``contactId`` and UpdateConversationDto declares neither +#: ``contactId`` nor anything else the create takes, so the two operations share no field the +#: node does not supply itself. This is the same create-versus-update policy contacts.py sets +#: out: when the two DTOs disagree, each operation sends what its own DTO declares. +_CONVERSATION_UPDATE_KEYS = ('unreadCount', 'starred', 'feedback') + +_CONVERSATION_UPDATE_PROPS = { + 'unreadCount': INT( + 'Number of unread messages to record on the conversation. Set 0 to mark it read: GoHighLevel has no ' + 'mark-as-read endpoint, so writing this field is how reading is recorded.' + ), + 'starred': BOOL('Whether the conversation is starred.'), + 'feedback': OBJ('Feedback payload. GoHighLevel declares it as a bare object and documents no fields inside it.'), +} + +#: What a conversation read hands back, stated once because five tools return one. +#: +#: The ``type`` warning is not pedantry: the same field name is a string enum on the search +#: route and a small integer on the get route, so an agent that compares it across the two +#: gets a mismatch on identical data. +_CONVERSATION_SHAPE = ( + 'Conversation dates are epoch milliseconds, not the ISO 8601 timestamps the rest of this node returns. ' + 'The type field is a string like "TYPE_PHONE" on conversation_search and a number (1 Phone, 2 Email, ' + '3 Facebook Messenger, 4 Review, 5 Group SMS, 6 Internal Chat) on conversation_get, so do not compare ' + 'the two directly.' +) + +_CONVERSATION_RECORD_OUTPUT = RECORD_OUTPUT(f'The conversation, as GoHighLevel returns it. {_CONVERSATION_SHAPE}') + + +def _clean_conversation(conversation: Any) -> dict: + """Normalise a conversation record without projecting it onto a key allowlist. + + Contacts are trimmed because their payload carries near-duplicate keys and a pagination + cursor that belongs to the envelope. A conversation has neither: it is roughly fifteen + small scalars, and the four routes that return one each send a different subset of them. + A key allowlist would therefore have to be the union of four declared schemas, and those + schemas are already known to under-declare (a live search response carries ``dateAdded``, + which no declared conversation schema mentions). Dropping real data to enforce a list that + is provably incomplete buys nothing, so this only guarantees the dict. + """ + return conversation if isinstance(conversation, dict) else {} + + +def _total_of(payload: Any) -> Any: + """Total matching records, for the one conversations route that reports one. + + ``GET /conversations/search`` is live-confirmed to send ``total`` beside ``conversations``. + Returns None rather than a synthesised number anywhere it is absent. + """ + return payload.get('total') if isinstance(payload, dict) else None + + +def _search_cursor(records: Any, sort_by: Any) -> Any: + """Style I cursor: the sort value carried by the last conversation of a page. + + This endpoint sends no cursor field. ``startAfterDate`` has to be the sort value of the + last record, and which field holds that value depends on ``sortBy``. Only the default sort + has a field name that can be named from the spec: ``last_message_date`` reads + ``lastMessageDate``, declared on ConversationDto. The other four sort keys are snake_case + strings whose matching response field GoHighLevel documents nowhere, and inventing a + camelCase name for them would send a value the endpoint silently ignores, which re-fetches + page one forever rather than failing. Any other sort therefore returns None and the + listing stops after one page, which the tool description says outright. + """ + if sort_by not in (None, '', 'last_message_date'): + return None + for record in reversed(list(records or [])): + if isinstance(record, dict) and record.get('lastMessageDate') is not None: + return record['lastMessageDate'] + return None + + +class ConversationsMixin(GoHighLevelToolsBase): + """Tools for the ``conversations`` group.""" + + @gohighlevel_tool( + group='conversations', + input_schema=schema( + **PAGING_START_AFTER_DATE('conversation_search'), + contactId=STR('Only conversations belonging to this contact.'), + assignedTo=STR( + 'User ids the conversation is assigned to, comma separated. Pass "unassigned" for conversations ' + 'nobody owns. Get user ids from user_list_by_location.' + ), + followers=STR('User ids of followers, comma separated.'), + mentions=STR('User ids mentioned in the conversation, comma separated.'), + query=STR('Free-text search across the conversation and its contact.'), + sort=ENUM('Sort direction.', ('asc', 'desc')), + sortBy=ENUM( + 'Field to sort on. Paging works only on the default, last_message_date.', + ('last_manual_message_date', 'last_message_date', 'score_profile', 'overdue_at', 'due_at'), + ), + status=ENUM( + 'Read state to filter on. "recents" is accepted by the API but GoHighLevel documents no meaning ' + 'for it.', + ('all', 'read', 'unread', 'starred', 'recents'), + ), + lastMessageType=ENUM('Channel of the most recent message in the conversation.', _LAST_MESSAGE_TYPES), + lastMessageAction=ENUM( + 'Whether the last outbound message was sent by a person or by automation.', ('automated', 'manual') + ), + lastMessageDirection=ENUM('Direction of the most recent message.', ('inbound', 'outbound')), + startDate=INT('Earliest dateAdded to include, as epoch milliseconds.'), + endDate=INT('Latest dateAdded to include, as epoch milliseconds.'), + ), + description=( + 'Find conversations in the configured sub-account, by contact, owner, channel, read state or free ' + 'text. This is the only way to list conversations: GoHighLevel has no plain conversations list, and ' + 'no endpoint that counts unread threads, so status="unread" here is how you find them. Returns ' + 'conversations under items and, unusually for this API, a real total. next holds the value to pass ' + 'back as startAfterDate, and is reported only while sortBy is left at its default: on any other sort ' + 'GoHighLevel documents no field holding the sort value, so ask for a larger limit instead of paging. ' + f'{_CONVERSATION_SHAPE}' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: the sort value of the last conversation on this page. Pass it back as the ' + 'startAfterDate parameter. Null when the page came back short, and also null on any sortBy other ' + 'than last_message_date, where this endpoint cannot be paged.', + items='The conversations on this page, as GoHighLevel returns them.', + ), + ) + def conversation_search(self, args): + args = self._args(args, 'conversation_search') + params, limit = start_after_date_params(args, 'conversation_search') + params.update(params_from(args, _SEARCH_KEYS)) + params['locationId'] = self._location() + payload, records = self._fetch('GET', '/conversations/search', key='conversations', params=params) + return paginated( + [_clean_conversation(record) for record in records], + total=_total_of(payload), + next_cursor=_search_cursor(records, args.get('sortBy')), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='conversations', + input_schema=schema(required=['conversationId'], conversationId=STR('Id of the conversation.')), + description=( + 'Get one conversation by id, including how many of its messages are unread and who it is assigned ' + 'to. Get the messages themselves from message_list. This route sends fewer fields than ' + f'conversation_search does, and it does not send the contact name, email or phone. ' + f'{_CONVERSATION_SHAPE}' + ), + output_schema=_CONVERSATION_RECORD_OUTPUT, + ) + def conversation_get(self, args): + args = self._args(args, 'conversation_get') + conversation_id = require_id(args, 'conversationId', 'conversation_get') + # This route answers the conversation bare while create and update wrap it under + # "conversation". The key is named anyway: record_of applies it only when the payload + # actually carries it, so naming it costs nothing and covers the wrapped shape too. + return self._get(f'/conversations/{conversation_id}', _clean_conversation, key='conversation') + + @gohighlevel_tool( + group='conversations', + writes=True, + input_schema=schema( + required=['contactId'], + contactId=STR('Id of the contact the conversation belongs to. Get contact ids from contact_search.'), + ), + description=( + 'Open a conversation with a contact in the configured sub-account. Only needed when there is not one ' + 'already: message_send creates the conversation it needs on its own, and a contact that has ever ' + 'been messaged already has one, which conversation_search will find. A conversation created here ' + 'holds no messages.' + ), + output_schema=_CONVERSATION_RECORD_OUTPUT, + ) + def conversation_create(self, args): + args = self._args(args, 'conversation_create') + body = body_from(args, ('contactId',), tool='conversation_create') + body['locationId'] = self._location() + # The trailing slash is load-bearing: /conversations without it is not this route. + return self._write('POST', '/conversations/', _clean_conversation, key='conversation', body=body) + + @gohighlevel_tool( + group='conversations', + writes=True, + input_schema=schema( + required=['conversationId'], + conversationId=STR('Id of the conversation to update.'), + **_CONVERSATION_UPDATE_PROPS, + ), + description=( + 'Update a conversation read state, star or feedback. Only the fields you pass are changed. Setting ' + 'unreadCount to 0 is how a conversation is marked read: GoHighLevel has no mark-as-read endpoint ' + 'and no endpoint that reads unread counts back other than conversation_get and conversation_search. ' + 'The contact a conversation belongs to cannot be changed here.' + ), + output_schema=_CONVERSATION_RECORD_OUTPUT, + ) + def conversation_update(self, args): + args = self._args(args, 'conversation_update') + conversation_id = require_id(args, 'conversationId', 'conversation_update') + body = body_from(args, _CONVERSATION_UPDATE_KEYS, tool='conversation_update') + # UpdateConversationDto declares locationId required even though the id is in the path. + body['locationId'] = self._location() + return self._write( + 'PUT', f'/conversations/{conversation_id}', _clean_conversation, key='conversation', body=body + ) + + @gohighlevel_tool( + group='conversations', + writes=True, + input_schema=schema(required=['conversationId'], conversationId=STR('Id of the conversation to delete.')), + description=( + 'Delete a conversation and every message in it. The contact is not deleted. There is no undo and no ' + 'endpoint that restores one, so prefer conversation_update when the intent is only to clear an ' + 'unread badge.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def conversation_delete(self, args): + args = self._args(args, 'conversation_delete') + conversation_id = require_id(args, 'conversationId', 'conversation_delete') + return self._delete(f'/conversations/{conversation_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/custom_fields.py b/nodes/src/nodes/tool_gohighlevel/tools/custom_fields.py new file mode 100644 index 000000000..0279b7ae1 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/custom_fields.py @@ -0,0 +1,246 @@ +# ============================================================================= +# 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: the contact and opportunity field definitions on a sub-account.""" + +from __future__ import annotations + +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + ENUM, + EXTRA, + LIST_OUTPUT, + MIXED_ARR, + NUM, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + params_from, + passthrough, + require_id, + require_text, + schema, +) + +#: Sub-account path these tools hang off. The camelCase ``customFields`` segment is +#: load-bearing: GoHighLevel answers a mis-cased path with a 401 that reads like a credential +#: failure rather than with a 404, so this is built once instead of being spelled per tool. +_PATH = '/locations/{location}/customFields' + +#: Body fields shared by custom_field_create and custom_field_update. +#: +#: ``dataType`` is deliberately absent. CreateCustomFieldsDTO declares it and requires it; +#: UpdateCustomFieldsDTO declares it nowhere, and GoHighLevel rejects an undeclared property +#: with a 422 rather than ignoring it, so offering it on the update would show the agent a +#: parameter whose only outcome is a hard vendor error. Create gets its own key tuple and its +#: own schema, which is the same create-versus-update policy contacts.py sets out. +_CUSTOM_FIELD_WRITE_KEYS = ( + 'name', + 'placeholder', + 'model', + 'position', + 'acceptedFormat', + 'isMultipleFile', + 'maxNumberOfFiles', + 'textBoxListOptions', +) + +#: The three parameters whose names change between what you send and what you read back. +#: +#: Stated on every tool in this module that returns a record, because it is the one way to +#: lose data here without an error: a record read from this node fed back into an update +#: silently drops the picklist options and the file settings, since the update declares none +#: of the three names the read uses. +_FIELD_RENAME_NOTE = ( + 'Three parameters change name between the write and the read: textBoxListOptions comes back as ' + 'picklistOptions, isMultipleFile as isMultiFileAllowed and maxNumberOfFiles as maxFileLimit, so a ' + 'record read here cannot be sent back to custom_field_update unchanged.' +) + +_CUSTOM_FIELD_ITEMS = ( + f'The custom field definitions on the sub-account, as GoHighLevel returns them. Each carries id, ' + f'name, fieldKey, dataType, model and the display settings. {_FIELD_RENAME_NOTE}' +) + +_CUSTOM_FIELD_RECORD_OUTPUT = RECORD_OUTPUT( + f'The custom field definition, as GoHighLevel returns it. {_FIELD_RENAME_NOTE}' +) + +_CUSTOM_FIELD_WRITE_PROPS = { + 'name': STR('Name of the field as it shows in the GoHighLevel UI, for example "Pincode".'), + 'placeholder': STR('Placeholder text shown in the empty input.'), + 'model': ENUM( + 'Which record type the field belongs to. Defaults to contact when omitted.', + ('contact', 'opportunity'), + ), + 'position': NUM('Where the field sits in the form, 0-based. 0 when omitted.'), + 'acceptedFormat': ARR( + 'File extensions a FILE_UPLOAD field accepts, each with its leading dot, for example ' + '[".pdf", ".docx"]. Ignored by every other field type.' + ), + 'isMultipleFile': BOOL('Whether a FILE_UPLOAD field takes more than one file. Read back as isMultiFileAllowed.'), + 'maxNumberOfFiles': NUM('How many files a FILE_UPLOAD field takes. Read back as maxFileLimit.'), + 'textBoxListOptions': MIXED_ARR( + 'Options for a field that offers a fixed set of choices, each an object of the form ' + '{"label": "First", "prefillValue": ""}. Read back as picklistOptions, which is a plain array of ' + 'strings, so the two shapes are not interchangeable.' + ), + 'extra': EXTRA(), +} + +#: Body keys and schema properties for custom_field_create only, which is the shared set plus +#: the field type. GoHighLevel requires it on create and accepts it on nothing else. +_CUSTOM_FIELD_CREATE_KEYS = _CUSTOM_FIELD_WRITE_KEYS + ('dataType',) +_CUSTOM_FIELD_CREATE_PROPS = { + **_CUSTOM_FIELD_WRITE_PROPS, + 'dataType': STR( + 'Type of the field, for example "TEXT". GoHighLevel publishes no list of the accepted values for ' + 'this endpoint; the values it documents on its newer custom fields API are TEXT, LARGE_TEXT, ' + 'NUMERICAL, PHONE, MONETORY, CHECKBOX, SINGLE_OPTIONS, MULTIPLE_OPTIONS, DATE, TEXTBOX_LIST, ' + 'FILE_UPLOAD and RADIO. The type cannot be changed after the field exists.' + ), +} + + +class CustomFieldsMixin(GoHighLevelToolsBase): + """Tools for the ``custom_fields`` group.""" + + @gohighlevel_tool( + group='custom_fields', + input_schema=schema( + model=ENUM( + 'Which fields to return. Omit for every field on the sub-account.', + ('contact', 'opportunity', 'all'), + ), + ), + description=( + 'List the custom field definitions on the configured sub-account. Call this before any write that ' + 'sets a custom field: contact and opportunity writes address fields by id, and this is the only tool ' + 'that turns a field name an operator would recognise into that id. Returns the fields under items, ' + 'all of them in one response, so there is no cursor. A sub-account with no custom fields returns an ' + f'empty list rather than an error. {_FIELD_RENAME_NOTE}' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every custom field in one response, so there is no next page.', + items=_CUSTOM_FIELD_ITEMS, + ), + ) + def custom_field_list(self, args): + args = self._args(args, 'custom_field_list') + params = params_from(args, ('model',)) + path = _PATH.format(location=self._location()) + return self._list(path, key='customFields', cleaner=passthrough, params=params) + + @gohighlevel_tool( + group='custom_fields', + input_schema=schema( + required=['customFieldId'], + customFieldId=STR( + 'Id of the custom field, or its field key, for example "contact.first_name" or ' + '"opportunity.pipeline_id". Get either from custom_field_list.' + ), + ), + description=( + 'Get one custom field definition. This is the only tool in the group that accepts a field key in ' + 'place of an id, so use it to resolve a key you already have; custom_field_update and ' + f'custom_field_delete take the id only. {_FIELD_RENAME_NOTE}' + ), + output_schema=_CUSTOM_FIELD_RECORD_OUTPUT, + ) + def custom_field_get(self, args): + args = self._args(args, 'custom_field_get') + field_id = require_id(args, 'customFieldId', 'custom_field_get') + path = _PATH.format(location=self._location()) + return self._get(f'{path}/{field_id}', passthrough, key='customField') + + @gohighlevel_tool( + group='custom_fields', + writes=True, + input_schema=schema(required=['name', 'dataType'], **_CUSTOM_FIELD_CREATE_PROPS), + description=( + 'Create a custom field definition on the configured sub-account. This adds a field to every contact ' + 'or opportunity in the sub-account, so read custom_field_list first and reuse a field that already ' + 'covers what you need. name and dataType are both required, and dataType is the one setting ' + f'custom_field_update cannot change afterwards. {_FIELD_RENAME_NOTE}' + ), + output_schema=_CUSTOM_FIELD_RECORD_OUTPUT, + ) + def custom_field_create(self, args): + args = self._args(args, 'custom_field_create') + body = body_from(args, _CUSTOM_FIELD_CREATE_KEYS, tool='custom_field_create') + body['name'] = require_text(args, 'name', 'custom_field_create') + body['dataType'] = require_text(args, 'dataType', 'custom_field_create') + path = _PATH.format(location=self._location()) + return self._write('POST', path, passthrough, key='customField', body=body) + + @gohighlevel_tool( + group='custom_fields', + writes=True, + input_schema=schema( + required=['customFieldId', 'name'], + customFieldId=STR('Id of the custom field to update. A field key is not accepted here.'), + **_CUSTOM_FIELD_WRITE_PROPS, + ), + description=( + 'Update a custom field definition. GoHighLevel requires name on every update, so pass the field ' + 'current name when you are changing something else and do not mean to rename it. dataType is not ' + 'accepted on an update, so the type of an existing field cannot be changed here even though ' + 'custom_field_create sets one. This changes the field for every record in the sub-account, not for ' + f'one contact. {_FIELD_RENAME_NOTE}' + ), + output_schema=_CUSTOM_FIELD_RECORD_OUTPUT, + ) + def custom_field_update(self, args): + args = self._args(args, 'custom_field_update') + field_id = require_id(args, 'customFieldId', 'custom_field_update') + body = body_from(args, _CUSTOM_FIELD_WRITE_KEYS, tool='custom_field_update') + body['name'] = require_text(args, 'name', 'custom_field_update') + path = _PATH.format(location=self._location()) + return self._write('PUT', f'{path}/{field_id}', passthrough, key='customField', body=body) + + @gohighlevel_tool( + group='custom_fields', + writes=True, + input_schema=schema( + required=['customFieldId'], + customFieldId=STR('Id of the custom field to delete. A field key is not accepted here.'), + ), + description=( + 'Delete a custom field definition from the configured sub-account. This removes the field itself, ' + 'across every contact and opportunity in the sub-account, rather than clearing the value one record ' + 'holds, so it is not the way to blank a single contact field. Take the id from custom_field_list.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def custom_field_delete(self, args): + args = self._args(args, 'custom_field_delete') + field_id = require_id(args, 'customFieldId', 'custom_field_delete') + path = _PATH.format(location=self._location()) + return self._delete(f'{path}/{field_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/custom_values.py b/nodes/src/nodes/tool_gohighlevel/tools/custom_values.py new file mode 100644 index 000000000..506b06924 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/custom_values.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. +# ============================================================================= + +"""Custom value tools: the sub-account merge tags that fill placeholders in messages and templates.""" + +from __future__ import annotations + +from ..tool_groups import gohighlevel_tool +from ._base import ( + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + passthrough, + require_id, + require_text, + schema, +) + +#: Sub-account path these tools hang off. The camelCase ``customValues`` segment is +#: load-bearing: GoHighLevel answers ``/locations/{id}/customvalues`` with a 401 that reads +#: like a credential failure rather than with a 404. +_PATH = '/locations/{location}/customValues' + +#: Body fields shared by custom_value_create and custom_value_update. +#: +#: Both operations declare exactly these two and both declare both of them required, so the +#: update is a full replace rather than a patch. There is no ``extra`` escape hatch on either +#: tool: the DTO has no third property, and GoHighLevel rejects an undeclared one with a 422 +#: rather than ignoring it, so ``extra`` here could only ever turn a working call into an error. +_CUSTOM_VALUE_WRITE_KEYS = ('name', 'value') + +_CUSTOM_VALUE_WRITE_PROPS = { + 'name': STR( + 'Name of the custom value, for example "Support Email". GoHighLevel builds the merge tag out of it: ' + 'a value named "Custom Field" comes back with fieldKey "{{ custom_values.custom_field }}".' + ), + 'value': STR('Text the merge tag expands to.'), +} + +#: What a custom value read returns, stated once because four tools return one. +_CUSTOM_VALUE_SHAPE = ( + 'Each record carries id, name, value, locationId and fieldKey, where fieldKey is the whole merge tag ' + 'literal ("{{ custom_values.support_email }}") rather than a bare key, so it can be pasted into a ' + 'message or template as it stands.' +) + +_CUSTOM_VALUE_RECORD_OUTPUT = RECORD_OUTPUT(f'The custom value, as GoHighLevel returns it. {_CUSTOM_VALUE_SHAPE}') + + +class CustomValuesMixin(GoHighLevelToolsBase): + """Tools for the ``custom_values`` group.""" + + @gohighlevel_tool( + group='custom_values', + input_schema=schema(), + description=( + 'List the custom values on the configured sub-account. Custom values are sub-account wide merge ' + 'tags, one name and one string each, used to fill placeholders in messages and templates with ' + 'things like an office address or a support email. They are not per contact: contact data lives in ' + 'custom fields, which custom_field_list covers. Returns the values under items, all of them in one ' + f'response, so there is no cursor. A sub-account with none returns an empty list rather than an ' + f'error. {_CUSTOM_VALUE_SHAPE}' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every custom value in one response, so there is no next page.', + items=f'The custom values on the sub-account, as GoHighLevel returns them. {_CUSTOM_VALUE_SHAPE}', + ), + ) + def custom_value_list(self, args): + args = self._args(args, 'custom_value_list') + return self._list(_PATH.format(location=self._location()), key='customValues', cleaner=passthrough) + + @gohighlevel_tool( + group='custom_values', + input_schema=schema( + required=['customValueId'], + customValueId=STR('Id of the custom value. Get it from custom_value_list.'), + ), + description=( + 'Get one custom value by id. Use it to read the current value before custom_value_update replaces ' + f'it, since that update overwrites both fields. {_CUSTOM_VALUE_SHAPE}' + ), + output_schema=_CUSTOM_VALUE_RECORD_OUTPUT, + ) + def custom_value_get(self, args): + args = self._args(args, 'custom_value_get') + value_id = require_id(args, 'customValueId', 'custom_value_get') + path = _PATH.format(location=self._location()) + return self._get(f'{path}/{value_id}', passthrough, key='customValue') + + @gohighlevel_tool( + group='custom_values', + writes=True, + input_schema=schema(required=['name', 'value'], **_CUSTOM_VALUE_WRITE_PROPS), + description=( + 'Create a custom value on the configured sub-account. Both name and value are required. The merge ' + 'tag comes back under fieldKey, ready to paste into a message or template. Read custom_value_list ' + 'first when a value for this purpose may already exist: custom_value_update changes an existing ' + 'one, and this tool does not.' + ), + output_schema=_CUSTOM_VALUE_RECORD_OUTPUT, + ) + def custom_value_create(self, args): + args = self._args(args, 'custom_value_create') + body = { + 'name': require_text(args, 'name', 'custom_value_create'), + 'value': require_text(args, 'value', 'custom_value_create'), + } + return self._write('POST', _PATH.format(location=self._location()), passthrough, key='customValue', body=body) + + @gohighlevel_tool( + group='custom_values', + writes=True, + input_schema=schema( + required=['customValueId', 'name', 'value'], + customValueId=STR('Id of the custom value to update.'), + **_CUSTOM_VALUE_WRITE_PROPS, + ), + description=( + 'Replace a custom value. This is a full replace rather than a patch: GoHighLevel requires both name ' + 'and value on every call, so read the record with custom_value_get first and send back the field you ' + 'do not mean to change. The merge tag under fieldKey is built from the name, so send the current ' + 'name unless a rename is what you want.' + ), + output_schema=_CUSTOM_VALUE_RECORD_OUTPUT, + ) + def custom_value_update(self, args): + args = self._args(args, 'custom_value_update') + value_id = require_id(args, 'customValueId', 'custom_value_update') + body = { + 'name': require_text(args, 'name', 'custom_value_update'), + 'value': require_text(args, 'value', 'custom_value_update'), + } + path = _PATH.format(location=self._location()) + return self._write('PUT', f'{path}/{value_id}', passthrough, key='customValue', body=body) + + @gohighlevel_tool( + group='custom_values', + writes=True, + input_schema=schema( + required=['customValueId'], + customValueId=STR('Id of the custom value to delete.'), + ), + description=( + 'Delete a custom value from the configured sub-account. Every message and template that references ' + 'its merge tag loses what the tag expanded to, so check what uses it before deleting.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def custom_value_delete(self, args): + args = self._args(args, 'custom_value_delete') + value_id = require_id(args, 'customValueId', 'custom_value_delete') + path = _PATH.format(location=self._location()) + return self._delete(f'{path}/{value_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/location_tags.py b/nodes/src/nodes/tool_gohighlevel/tools/location_tags.py new file mode 100644 index 000000000..d5495d61c --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/location_tags.py @@ -0,0 +1,166 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Location tag tools: the tag definitions a sub-account holds, not the tags on a contact.""" + +from __future__ import annotations + +from typing import Any + +from ..tool_groups import gohighlevel_tool +from ._base import ( + LIST_OUTPUT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + require_id, + schema, +) + +#: Fields kept from a tag definition. The whole record is three keys, so this drops nothing +#: GoHighLevel documents; it exists to keep the shape stable if the vendor adds more. +_TAG_READ_KEYS = ('id', 'name', 'locationId') + +#: Body fields accepted by location_tags_create and location_tags_update. +#: +#: The vendor DTO is one property wide and declares it required on both operations, so create +#: and update share the tuple and the props outright rather than drifting the way the contact +#: DTOs do. +_TAG_WRITE_KEYS = ('name',) + +_TAG_WRITE_PROPS = { + 'name': STR('Tag name as it appears in the GoHighLevel UI, for example "vip".'), +} + +#: Said on every tool here, because the distinction is the one an agent gets wrong: these five +#: tools manage the sub-account's tag vocabulary, and none of them touches a contact. +_DEFINITION_NOTE = ( + 'This is the sub-account tag vocabulary, not the tags on any contact. To tag or untag a ' + 'contact use contact_tags_add and contact_tags_remove, which also create a name that does ' + 'not exist yet.' +) + +_TAG_RECORD_OUTPUT = RECORD_OUTPUT('The tag definition: id, name and the sub-account it belongs to.') + + +def _clean_tag(tag: Any) -> dict: + """Trim a tag definition to the fields this node returns.""" + if not isinstance(tag, dict): + return {} + return {key: tag[key] for key in _TAG_READ_KEYS if key in tag} + + +class LocationTagsMixin(GoHighLevelToolsBase): + """Tools for the ``location_tags`` group.""" + + @gohighlevel_tool( + group='location_tags', + input_schema=schema(), + description=( + 'List every tag defined on the configured sub-account, with the id of each. Use it to resolve a tag ' + 'id before location_tags_update or location_tags_delete, or to check which names already exist ' + f'before tagging contacts. {_DEFINITION_NOTE} Takes no parameters: GoHighLevel offers this endpoint ' + 'no filter and no paging, and the whole vocabulary comes back in one response.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every tag in one response, so there is no next page.', + items='The tag definitions on the sub-account, each holding id, name and locationId.', + ), + ) + def location_tags_list(self, args): + args = self._args(args, 'location_tags_list') + return self._list(f'/locations/{self._location()}/tags', key='tags', cleaner=_clean_tag) + + @gohighlevel_tool( + group='location_tags', + writes=True, + input_schema=schema(required=['name'], **_TAG_WRITE_PROPS), + description=( + 'Define a new tag on the configured sub-account and return it with its id. Needed only to create a ' + 'tag nothing carries yet: contact_tags_add already creates any name it does not find. ' + f'{_DEFINITION_NOTE}' + ), + output_schema=_TAG_RECORD_OUTPUT, + ) + def location_tags_create(self, args): + args = self._args(args, 'location_tags_create') + body = body_from(args, _TAG_WRITE_KEYS) + return self._write('POST', f'/locations/{self._location()}/tags', _clean_tag, key='tag', body=body) + + @gohighlevel_tool( + group='location_tags', + input_schema=schema(required=['tagId'], tagId=STR('Id of the tag. Get tag ids from location_tags_list.')), + description=( + 'Get one tag definition by id. There is no lookup by name, so resolve the id with location_tags_list ' + f'first. {_DEFINITION_NOTE}' + ), + output_schema=_TAG_RECORD_OUTPUT, + ) + def location_tags_get(self, args): + args = self._args(args, 'location_tags_get') + tag_id = require_id(args, 'tagId', 'location_tags_get') + return self._get(f'/locations/{self._location()}/tags/{tag_id}', _clean_tag, key='tag') + + @gohighlevel_tool( + group='location_tags', + writes=True, + input_schema=schema( + required=['tagId', 'name'], + tagId=STR('Id of the tag to rename. Get tag ids from location_tags_list.'), + **_TAG_WRITE_PROPS, + ), + description=( + 'Rename a tag definition. The name is the only field a tag has, so this is a rename and nothing else, ' + f'and GoHighLevel requires it. {_DEFINITION_NOTE}' + ), + output_schema=_TAG_RECORD_OUTPUT, + ) + def location_tags_update(self, args): + args = self._args(args, 'location_tags_update') + tag_id = require_id(args, 'tagId', 'location_tags_update') + body = body_from(args, _TAG_WRITE_KEYS) + return self._write('PUT', f'/locations/{self._location()}/tags/{tag_id}', _clean_tag, key='tag', body=body) + + @gohighlevel_tool( + group='location_tags', + writes=True, + input_schema=schema( + required=['tagId'], + tagId=STR('Id of the tag to delete. Get tag ids from location_tags_list.'), + ), + description=( + 'Delete a tag definition from the sub-account. This removes the tag itself rather than removing it ' + 'from one contact, which is contact_tags_remove. GoHighLevel does not state what happens to contacts ' + 'already carrying the tag, so treat it as irreversible and check location_tags_list afterwards.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def location_tags_delete(self, args): + args = self._args(args, 'location_tags_delete') + tag_id = require_id(args, 'tagId', 'location_tags_delete') + return self._delete(f'/locations/{self._location()}/tags/{tag_id}') diff --git a/nodes/src/nodes/tool_gohighlevel/tools/locations.py b/nodes/src/nodes/tool_gohighlevel/tools/locations.py new file mode 100644 index 000000000..55187bec8 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/locations.py @@ -0,0 +1,164 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Sub-account tools: the location record itself, and the task search scoped to it.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + LIST_OUTPUT, + PAGING_SKIP_BODY, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + next_offset, + passthrough, + schema, + skip_body, +) + +#: Fields kept from a sub-account record, which is GetLocationByIdSchema. +#: +#: ``companyId`` is the load-bearing one and the reason this tool is worth publishing at all: +#: it is the agency id, ``GET /users/search`` requires it, and nothing else on the surface a +#: sub-account token can reach returns it. The record read here is a different schema from the +#: one ``GET /locations/search`` returns (that one omits companyId, domain, logoUrl, reseller, +#: business, firstName and lastName), but that endpoint is agency-scoped and this node does not +#: offer it, so there is only one shape to model. +_LOCATION_READ_KEYS = ( + 'id', + 'companyId', + 'name', + 'domain', + 'email', + 'phone', + 'address', + 'city', + 'state', + 'country', + 'postalCode', + 'website', + 'timezone', + 'firstName', + 'lastName', + 'logoUrl', + 'business', + 'social', + 'settings', + 'reseller', +) + +#: Body fields the sub-account task search filters on, all of them optional. +#: +#: ``skip`` and ``limit`` are absent deliberately: this endpoint takes both in the body as +#: numbers rather than in the query as strings, so :func:`skip_body` renders them and this +#: tuple carries only the filters. +_TASK_SEARCH_KEYS = ('contactId', 'assignedTo', 'businessId', 'completed', 'query') + +_TASK_SEARCH_PROPS = { + 'contactId': ARR( + 'Contact ids to return tasks for. The vendor names this parameter in the singular and it ' + 'takes an array, so pass a list even for one contact.' + ), + 'assignedTo': ARR('User ids the task is assigned to. Get user ids from user_list_by_location.'), + 'businessId': STR('Id of a business, to return only tasks belonging to it.'), + 'completed': BOOL('Whether to return completed tasks (true) or open ones (false). Omit for both.'), + 'query': STR('Free-text search over the task.'), +} + + +def _clean_location(location: Any) -> dict: + """Trim a sub-account record to the fields this node returns.""" + if not isinstance(location, dict): + return {} + return {key: location[key] for key in _LOCATION_READ_KEYS if key in location} + + +class LocationsMixin(GoHighLevelToolsBase): + """Tools for the ``locations`` group.""" + + # -- the sub-account record -------------------------------------------- + + @gohighlevel_tool( + group='locations', + input_schema=schema(), + description=( + 'Get the configured sub-account, which GoHighLevel also calls a location. Takes no parameters: the ' + 'sub-account id comes from node configuration and the credential can only read that one. Call this to ' + 'find the companyId (the agency id) that user_search requires, or to read the sub-account name, ' + 'address, timezone and business details before writing records that depend on them. Listing or ' + 'searching other sub-accounts is an agency-level operation this node does not offer.' + ), + output_schema=RECORD_OUTPUT( + 'The sub-account, trimmed to the fields this node returns. companyId is the agency the sub-account ' + 'belongs to, and timezone is an IANA name such as "America/Los_Angeles".' + ), + ) + def location_get(self, args): + args = self._args(args, 'location_get') + return self._get(f'/locations/{self._location()}', _clean_location, key='location') + + # -- tasks -------------------------------------------------------------- + + @gohighlevel_tool( + group='locations', + input_schema=schema(**_TASK_SEARCH_PROPS, **PAGING_SKIP_BODY('location_tasks_search')), + description=( + 'Search tasks across the whole sub-account, optionally narrowed by contact, assignee, business, ' + 'completion state or free text. This is the only way to see tasks that are not tied to a contact you ' + 'already know: contact_tasks_list needs a contactId. It is read-only, so create, update and complete ' + 'go through the contact task tools. Task records come back exactly as GoHighLevel sends them, since ' + 'the vendor publishes no schema for them, and this route names the task id _id while the ' + 'contact-scoped route names the same field id. Pages by offset: next is the skip value for the ' + 'following page.' + ), + output_schema=LIST_OUTPUT( + 'Offset for the next page: pass it back as the skip parameter. Null once a page comes back shorter ' + 'than the limit asked for, which means there is no next page.', + items=( + 'The tasks on this page, verbatim. GoHighLevel declares no schema for them; the observed record ' + 'carries _id, locationId, title, dueDate, completed, status, statusGroup, contactId, ' + 'contactDetails, assignedToUserDetails, businessId, dateAdded and dateUpdated.' + ), + ), + ) + def location_tasks_search(self, args): + args = self._args(args, 'location_tasks_search') + body, limit = skip_body(args, 'location_tasks_search') + body.update(body_from(args, _TASK_SEARCH_KEYS)) + path = f'/locations/{self._location()}/tasks/search' + _payload, records = self._fetch('POST', path, key='tasks', body=body) + return paginated( + [passthrough(record) for record in records], + next_cursor=next_offset(args.get('skip'), limit, len(records)), + requested_limit=limit, + ) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/messages.py b/nodes/src/nodes/tool_gohighlevel/tools/messages.py new file mode 100644 index 000000000..bcd78dce9 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/messages.py @@ -0,0 +1,458 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Message tools: reading a conversation's messages, sending one, and cancelling a scheduled send.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import normalize_success, paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + ENUM, + INT, + LIST_OUTPUT, + OBJ, + PAGING_CURSOR, + PAGING_LAST_MESSAGE_ID, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + cursor_params, + last_message_id_params, + next_cursor_of, + params_from, + passthrough, + require_id, + schema, +) + +#: Channels ``message_send`` can originate a message on, verbatim from SendMessageBodyDto. +#: +#: There is deliberately no ``Call`` and no voicemail value. Calls and voicemails cannot be +#: originated through this API at all: they can only be logged after the fact, through the +#: inbound and outbound message endpoints, which this node does not publish because both need +#: a conversationProviderId that only a marketplace application can mint. +_SEND_TYPES = ('SMS', 'RCS', 'Email', 'WhatsApp', 'IG', 'FB', 'Custom', 'Live_Chat', 'TIKTOK') + +#: Message-type values ``message_list`` filters on. Fewer than the 43 the conversation search +#: accepts, and the endpoint takes several of them at once as one comma-separated string, so +#: this is stated in prose rather than declared as an enum: an enum property would reject the +#: multi-value form the endpoint is built around. +_LIST_TYPES = ( + 'TYPE_CALL, TYPE_SMS, TYPE_RCS, TYPE_EMAIL, TYPE_FACEBOOK, TYPE_GMB, TYPE_INSTAGRAM, TYPE_WHATSAPP, ' + 'TYPE_ACTIVITY_APPOINTMENT, TYPE_ACTIVITY_CONTACT, TYPE_ACTIVITY_INVOICE, TYPE_ACTIVITY_PAYMENT, ' + 'TYPE_ACTIVITY_OPPORTUNITY, TYPE_LIVE_CHAT, TYPE_INTERNAL_COMMENTS, TYPE_ACTIVITY_EMPLOYEE_ACTION_LOG, ' + 'TYPE_TIKTOK, TYPE_ACTIVITY_WHATSAPP, TYPE_FORM_SUBMISSION' +) + +#: Query keys ``message_export`` forwards, minus the ones the paging builder owns. +_EXPORT_KEYS = ('sortBy', 'sortOrder', 'conversationId', 'contactId', 'channel', 'startDate', 'endDate') + +#: Body fields ``message_send`` accepts. +#: +#: There is no matching create-and-update pair here to share a tuple with: sending is the only +#: write on this surface, and a sent message cannot be edited. ``subType`` and ``status`` are +#: in the list only so a rejected send can be retried with them; see the tool description. +_SEND_KEYS = ( + 'type', + 'contactId', + 'message', + 'subject', + 'html', + 'attachments', + 'emailFrom', + 'emailTo', + 'emailCc', + 'emailBcc', + 'emailReplyMode', + 'replyMessageId', + 'threadId', + 'templateId', + 'appointmentId', + 'scheduledTimestamp', + 'usesNativeSchedulingAi', + 'optimizationPeriod', + 'fromNumber', + 'toNumber', + 'forward', + 'conversationProviderId', + 'customSubtypeId', + 'subType', + 'status', +) + +_SEND_PROPS = { + 'type': ENUM( + 'Channel to send on. The channel has to be provisioned on the sub-account first, so a sub-account with ' + 'no phone number cannot send SMS. There is no Call or voicemail value: this API cannot place calls.', + _SEND_TYPES, + ), + 'contactId': STR('Id of the contact to send to. Get contact ids from contact_search.'), + 'message': STR('Plain-text body of the message. Use this for SMS and for a plain-text email.'), + 'subject': STR('Subject line. Email only.'), + 'html': STR('HTML body. Email only, and it replaces message rather than accompanying it.'), + 'attachments': ARR('Publicly reachable URLs to attach. This API takes URLs, never file uploads.'), + 'emailFrom': STR('Address to send from. Email only, and it must be an address the sub-account can send as.'), + 'emailTo': STR('Address to send to, when it is not the contact primary email. Email only.'), + 'emailCc': ARR('Addresses to copy. Email only.'), + 'emailBcc': ARR('Addresses to blind copy. Email only.'), + 'emailReplyMode': ENUM( + 'Who a reply goes to. Email only, and only meaningful with replyMessageId.', ('reply', 'reply_all') + ), + 'replyMessageId': STR('Id of the message being replied to. Get message ids from message_list.'), + 'threadId': STR('Id of the thread to append to. For email this is the id of the message that opened it.'), + 'templateId': STR( + 'Id of a saved GoHighLevel template to send. This node has no tool that lists templates, so the id has ' + 'to come from the GoHighLevel UI.' + ), + 'appointmentId': STR('Id of an appointment to associate the message with.'), + 'scheduledTimestamp': INT( + 'When to send, as epoch SECONDS, not milliseconds. This is the only field in this node measured in ' + 'seconds, and a millisecond value schedules the message tens of thousands of years out. Cancel a ' + 'scheduled send with message_schedule_cancel.' + ), + 'usesNativeSchedulingAi': BOOL('Whether GoHighLevel picks the send time within optimizationPeriod.'), + 'optimizationPeriod': ENUM('Window the scheduling AI may pick a send time inside.', ('24h', '48h', '72h')), + 'fromNumber': STR('Number to send from, in E.164 form. It must belong to the sub-account.'), + 'toNumber': STR('Number to send to, in E.164 form, when it is not the contact primary phone.'), + 'forward': OBJ( + 'Forwarding settings for an email, holding isForwarded, forwardWholeThread, messageId, emailMessageId ' + 'and toEmail.' + ), + 'conversationProviderId': STR( + 'Id of the conversation provider to send through. Only for a custom provider, and only a marketplace ' + 'application can mint one, so leave it unset on a Private Integration Token.' + ), + 'customSubtypeId': STR('Custom subtype id governing email unsubscribe preferences. Email only.'), + 'subType': OBJ( + 'GoHighLevel marks this required and then types it as an object while giving a string as its example, ' + 'which reads as a defect in the published schema. This tool does not send it unless you do. Set it only ' + 'if a send is rejected for a missing subType.' + ), + 'status': ENUM( + 'Delivery outcome to stamp on the message. GoHighLevel marks this required, which reads as a defect: a ' + 'delivery outcome is something the platform reports, not something a sender chooses, and setting it may ' + 'have real effects on reporting. This tool does not send it unless you do.', + ('delivered', 'failed', 'pending', 'read'), + ), +} + +#: What a message read hands back, stated once because three tools return one. +_MESSAGE_SHAPE = ( + 'A message carries both a numeric type and a string messageType naming the same channel. Its attachments ' + 'array is empty for calls and voicemails whatever the message actually held, because GoHighLevel serves ' + 'recordings from a separate binary endpoint this node does not publish.' +) + + +def _clean_message(message: Any) -> dict: + """Normalise a message record without projecting it onto a key allowlist. + + Same call as ``_clean_conversation`` and for the same reason: a message is a short, flat + record with no near-duplicate keys and no embedded cursor, and the four routes that return + one send different subsets, so a key allowlist would be the union of four schemas and would + still drop whatever GoHighLevel adds next. This only guarantees the dict. + """ + return message if isinstance(message, dict) else {} + + +def _total_of(payload: Any) -> Any: + """Total matching records, for the one messages route that reports one. + + ``GET /conversations/messages/export`` declares ``total`` alongside its records. The other + two list routes report none, and this returns None rather than a synthesised number there. + """ + return payload.get('total') if isinstance(payload, dict) else None + + +def _next_page_flag(payload: Any) -> Any: + """``nextPage`` from a message list, or None when the response omits it. + + Worth reading rather than inferring: this is the one endpoint on the whole surface that + states outright whether another page exists, so it beats comparing the record count to the + requested limit. None is returned for an absent field so :func:`paginated` falls back to + that comparison instead of reading a missing flag as "no more pages". + """ + if isinstance(payload, dict) and 'nextPage' in payload: + return payload['nextPage'] + return None + + +def _last_message_id(payload: Any, records: Any) -> Any: + """Style H cursor: the id of the last message on the page. + + The response root carries it under ``lastMessageId``, which is preferred, but it is read + off the final record when the root omits it: the cursor is what the next call sends and a + missing one silently ends the traversal. + """ + if isinstance(payload, dict) and payload.get('lastMessageId'): + return payload['lastMessageId'] + for record in reversed(list(records or [])): + if isinstance(record, dict) and record.get('id'): + return record['id'] + return None + + +def _cancel_result(payload: Any) -> dict: + """Outcome of cancelling a scheduled send. + + These two endpoints do not answer with a success flag. They answer with an HTTP-style + ``status`` number and a ``message``, and the shape GoHighLevel documents for the success + case is an example reading ``404`` with "Failed cancel the scheduled message". A body that + reports a 4xx therefore has to be read as a failure even though the HTTP response was a + 2xx, otherwise a cancellation that did not happen is reported as one that did. + """ + if not isinstance(payload, dict): + return {'ok': True, 'status': None, 'message': ''} + ok = normalize_success(payload) + status = payload.get('status') + try: + if status is not None and int(status) >= 400: + ok = False + except (TypeError, ValueError): + pass + return {'ok': ok, 'status': status, 'message': payload.get('message') or ''} + + +class MessagesMixin(GoHighLevelToolsBase): + """Tools for the ``messages`` group.""" + + # -- reading ----------------------------------------------------------- + + @gohighlevel_tool( + group='messages', + input_schema=schema( + required=['conversationId'], + conversationId=STR('Id of the conversation. Get conversation ids from conversation_search.'), + type=STR(f'Message types to include, comma separated. One or more of: {_LIST_TYPES}.'), + **PAGING_LAST_MESSAGE_ID('message_list'), + ), + description=( + 'Read the messages in one conversation. This is the tool for "what did we say to this ' + 'contact": find the conversation with conversation_search, then read it here. The list includes ' + 'activity entries (appointments, opportunity changes) as well as real messages, so pass type to ' + 'narrow it. next holds the id of the last message on the page: pass it back as lastMessageId, and ' + f'ask for another page only while has_more is true. {_MESSAGE_SHAPE}' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: the id of the last message on this page. Pass it back as the ' + 'lastMessageId parameter. Null when GoHighLevel reports no further page.', + items='The messages on this page, as GoHighLevel returns them.', + ), + ) + def message_list(self, args): + args = self._args(args, 'message_list') + conversation_id = require_id(args, 'conversationId', 'message_list') + params, limit = last_message_id_params(args, 'message_list') + params.update(params_from(args, ('type',))) + payload, records = self._fetch( + 'GET', f'/conversations/{conversation_id}/messages', key='messages', params=params + ) + return paginated( + [_clean_message(record) for record in records], + next_cursor=_last_message_id(payload, records), + has_more=_next_page_flag(payload), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='messages', + input_schema=schema(required=['messageId'], messageId=STR('Id of the message.')), + description=( + 'Get one message by id, including its body, direction, delivery status and attachment URLs. Use ' + 'message_email_get instead for an email, which carries the subject, recipients and thread that this ' + f'route leaves out. {_MESSAGE_SHAPE}' + ), + output_schema=RECORD_OUTPUT(f'The message, as GoHighLevel returns it. {_MESSAGE_SHAPE}'), + ) + def message_get(self, args): + args = self._args(args, 'message_get') + message_id = require_id(args, 'messageId', 'message_get') + # The path is written out here rather than taken from the spec on purpose: GoHighLevel + # declares no id parameter on this operation, so its own generated SDK requests the + # literal string "/conversations/messages/{id}" and never substitutes anything. + return self._get(f'/conversations/messages/{message_id}', _clean_message) + + @gohighlevel_tool( + group='messages', + input_schema=schema(required=['emailMessageId'], emailMessageId=STR('Id of the email message.')), + description=( + 'Get one email message by id, with its subject, sender, to, cc and bcc lists, thread id and ' + 'attachment URLs. The id is the emailMessageId that message_send returns for an email, which is a ' + 'different id from the messageId it returns alongside it. Every other message type reads through ' + 'message_get.' + ), + output_schema=RECORD_OUTPUT( + 'The email message, as GoHighLevel returns it. from is a single string holding the sender name and ' + 'address together, while to, cc and bcc are arrays of addresses.' + ), + ) + def message_email_get(self, args): + args = self._args(args, 'message_email_get') + email_message_id = require_id(args, 'emailMessageId', 'message_email_get') + # Hand-written for the same reason as message_get: this operation declares no + # parameters at all, so nothing can substitute the id for us. + return self._get(f'/conversations/messages/email/{email_message_id}', _clean_message) + + @gohighlevel_tool( + group='messages', + input_schema=schema( + **PAGING_CURSOR('message_export'), + conversationId=STR('Only messages in this conversation.'), + contactId=STR('Only messages exchanged with this contact.'), + channel=ENUM( + 'Channel to include. Leaving it unset returns every non-email message, including activity ' + 'entries, and excludes email, so pass "Email" explicitly to export email.', + ('Call', 'SMS', 'Email', 'WhatsApp', 'Instagram', 'Facebook'), + ), + startDate=STR('Earliest message to include, as an ISO 8601 timestamp.'), + endDate=STR('Latest message to include, as an ISO 8601 timestamp.'), + sortBy=ENUM('Field to sort on. Defaults to createdAt.', ('createdAt', 'updatedAt')), + sortOrder=ENUM('Sort direction. Defaults to desc.', ('asc', 'desc')), + ), + description=( + 'Export messages across the whole configured sub-account, rather than one conversation at a time. ' + 'Use this for bulk reads and reporting; use message_list to read a single thread. Email is excluded ' + 'unless you pass channel="Email". Returns a real total and pages 10 to 500 at a time. next holds an ' + 'opaque cursor: pass it back unchanged, and ask for another page only while has_more is true.' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: an opaque token. Pass it back unchanged as the cursor parameter. Null ' + 'when there is no next page.', + items='The messages on this page, as GoHighLevel returns them.', + ), + ) + def message_export(self, args): + args = self._args(args, 'message_export') + params, limit = cursor_params(args, 'message_export') + params.update(params_from(args, _EXPORT_KEYS)) + params['locationId'] = self._location() + payload, records = self._fetch('GET', '/conversations/messages/export', key='messages', params=params) + return paginated( + [_clean_message(record) for record in records], + total=_total_of(payload), + next_cursor=next_cursor_of(payload), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='messages', + input_schema=schema(required=['messageId'], messageId=STR('Id of the call or voicemail message.')), + description=( + 'Get the speech-to-text transcription of a recorded call or voicemail. Only messages GoHighLevel has ' + 'transcribed have one, so this returns nothing useful for an SMS or an email. The audio itself is ' + 'served as a binary download and is not reachable from this node.' + ), + output_schema=RECORD_OUTPUT( + 'The transcription as GoHighLevel returns it, passed through untouched. It is declared as one object ' + 'holding transcript, mediaChannel, sentenceIndex, startTime and endTime in milliseconds, and ' + 'confidence, but the presence of sentenceIndex suggests a per-sentence array, so handle both.' + ), + ) + def message_transcription_get(self, args): + args = self._args(args, 'message_transcription_get') + message_id = require_id(args, 'messageId', 'message_transcription_get') + # The sub-account goes in the path here, and it comes BEFORE the message id. The + # recording endpoint nests the same two ids the other way round. + path = f'/conversations/locations/{self._location()}/messages/{message_id}/transcription' + return self._get(path, passthrough) + + # -- sending ----------------------------------------------------------- + + @gohighlevel_tool( + group='messages', + writes=True, + input_schema=schema(required=['type', 'contactId'], **_SEND_PROPS), + description=( + 'Send a message to a contact on any channel: SMS, email, WhatsApp, Instagram, Facebook, RCS, TikTok, ' + 'live chat or a custom provider. One tool covers every channel, selected by type; there is no ' + 'per-channel send endpoint, and no way to place a call or leave a voicemail. GoHighLevel opens the ' + 'conversation if the contact does not have one. The channel has to be provisioned on the sub-account ' + 'first, so a send fails on an account with no phone number or no email provider however well formed ' + 'the request is. Pass scheduledTimestamp, in epoch SECONDS, to schedule instead of sending now. ' + 'GoHighLevel also marks subType and status required, which reads as a defect in its published ' + 'schema, so this tool sends neither unless you set them.' + ), + output_schema=RECORD_OUTPUT( + 'conversationId and messageId identify what was sent. An email also carries emailMessageId, which is ' + 'a different id and the one message_email_get takes. A Google My Business send returns messageIds, ' + 'an array, instead of messageId. status is the delivery state at the moment of sending.' + ), + ) + def message_send(self, args): + args = self._args(args, 'message_send') + body = body_from(args, _SEND_KEYS, tool='message_send') + # No locationId anywhere: this operation takes the sub-account from the token alone, + # and sending one is a 422 rather than a no-op. + return self._write('POST', '/conversations/messages', _clean_message, body=body) + + @gohighlevel_tool( + group='messages', + writes=True, + input_schema=schema(required=['messageId'], messageId=STR('Id of the scheduled message.')), + description=( + 'Cancel a message that was scheduled but has not gone out yet. Only useful for a send that carried ' + 'scheduledTimestamp. Use message_email_schedule_cancel for a scheduled email, which is keyed by its ' + 'own emailMessageId. Check ok on the result: this endpoint reports a failed cancellation in the ' + 'response body rather than as an error.' + ), + output_schema=RECORD_OUTPUT( + 'ok is true when the message was actually cancelled, with the code GoHighLevel reported under status ' + 'and its own wording under message.' + ), + ) + def message_schedule_cancel(self, args): + args = self._args(args, 'message_schedule_cancel') + message_id = require_id(args, 'messageId', 'message_schedule_cancel') + return self._write('DELETE', f'/conversations/messages/{message_id}/schedule', _cancel_result) + + @gohighlevel_tool( + group='messages', + writes=True, + input_schema=schema( + required=['emailMessageId'], + emailMessageId=STR('Id of the scheduled email, which is the emailMessageId message_send returned.'), + ), + description=( + 'Cancel a scheduled email that has not gone out yet. Emails are keyed by emailMessageId rather than ' + 'by the messageId message_schedule_cancel takes, and the two ids are different values for the same ' + 'send. Check ok on the result: this endpoint reports a failed cancellation in the response body ' + 'rather than as an error.' + ), + output_schema=RECORD_OUTPUT( + 'ok is true when the email was actually cancelled, with the code GoHighLevel reported under status ' + 'and its own wording under message.' + ), + ) + def message_email_schedule_cancel(self, args): + args = self._args(args, 'message_email_schedule_cancel') + email_message_id = require_id(args, 'emailMessageId', 'message_email_schedule_cancel') + path = f'/conversations/messages/email/{email_message_id}/schedule' + return self._write('DELETE', path, _cancel_result) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/opportunities.py b/nodes/src/nodes/tool_gohighlevel/tools/opportunities.py new file mode 100644 index 000000000..1099ce0c3 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/opportunities.py @@ -0,0 +1,655 @@ +# ============================================================================= +# 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. +# ============================================================================= + +"""Opportunity tools: the opportunity record plus its status, its followers and the two searches.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import normalize_success, paginated, split_custom_fields +from ..tool_groups import gohighlevel_tool +from ._base import ( + ARR, + BOOL, + CUSTOM_FIELDS_DESC, + ENUM, + EXTRA, + LIST_OUTPUT, + MIXED_ARR, + NUM, + PAGING_SEARCH_AFTER_PAGE, + PAGING_START_AFTER, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + body_from, + params_from, + require_id, + require_text, + schema, + search_after_cursor, + search_after_page_body, + start_after_cursor, + start_after_params, +) + +#: Fields kept from an opportunity record. +#: +#: The tuple is the union of what the spec declares and what a live search actually returned, +#: because the two disagree: ``pipelineStageUId``, ``relations``, ``forecastProbability`` and +#: ``effectiveProbability`` came back on a real record and appear in no response schema, and +#: the vendor's own docs repo carries a standing pile of "API field missing" bugs, so the live +#: capture wins. +#: +#: ``notes``, ``tasks``, ``calendarEvents`` and ``followers`` are declared ``any[][]`` in both +#: the spec and the official SDK, which describes no field at all, so they are kept and passed +#: through rather than projected. ``indexVersion`` is left out: it is declared a string, its +#: own example is the integer 1, and it means nothing to an agent. +_OPPORTUNITY_READ_KEYS = ( + 'id', + 'name', + 'locationId', + 'contactId', + 'contact', + 'pipelineId', + 'pipelineStageId', + 'pipelineStageUId', + 'status', + 'lostReasonId', + 'monetaryValue', + 'assignedTo', + 'source', + 'followers', + 'relations', + 'notes', + 'tasks', + 'calendarEvents', + 'forecastProbability', + 'effectiveProbability', + 'lastStatusChangeAt', + 'lastStageChangeAt', + 'lastActionDate', + 'createdAt', + 'updatedAt', + 'externalObjectId', +) + +#: Body fields shared by opportunity_create and opportunity_update. +#: +#: ``locationId`` is absent: the node supplies it from config on create, and the update body +#: has no such property at all. ``contactId`` is absent too, because it is create-only (see +#: :data:`_CREATE_ONLY_KEYS`). +_OPPORTUNITY_WRITE_KEYS = ( + 'pipelineId', + 'pipelineStageId', + 'name', + 'status', + 'monetaryValue', + 'assignedTo', + 'customFields', +) + +#: Fields CreateDto declares and UpdateOpportunityDto does not. +#: +#: An opportunity is attached to its contact when it is created and the contact cannot be +#: changed afterwards: the update body declares no ``contactId``, and GoHighLevel answers an +#: undeclared property with a 422 rather than ignoring it. Offering it on opportunity_update +#: would show the agent a parameter whose only outcome is a hard vendor error. This is the +#: same create-versus-update policy the contacts module follows. +_CREATE_ONLY_KEYS = ('contactId',) + +#: The status values that mean something on a write. +#: +#: The spec's enum carries a fifth value, ``all``, on create, update, upsert and the status +#: write. ``all`` is a search filter rather than a state an opportunity can be in, so it is +#: offered on opportunity_search and withheld from every write. +_WRITE_STATUSES = ('open', 'won', 'lost', 'abandoned') +_SEARCH_STATUSES = ('open', 'won', 'lost', 'abandoned', 'all') + +_STATUS_DESC = ( + 'Stage of the deal: "open" while it is live, then "won", "lost" or "abandoned". Pass a lostReasonId ' + 'alongside "lost" when the sub-account has lost reasons configured; lost_reason_list returns them.' +) + +_CUSTOM_FIELDS_WRITE = MIXED_ARR( + f'Custom field values to set on the opportunity. {CUSTOM_FIELDS_DESC} Opportunity custom fields are ' + 'defined on the sub-account rather than on the pipeline, and they are the ones custom_field_list ' + 'reports against the opportunity model.' +) + +_OPPORTUNITY_WRITE_PROPS = { + 'pipelineId': STR('Id of the pipeline the opportunity sits in. Get pipeline ids from pipeline_list.'), + 'pipelineStageId': STR( + 'Id of the stage within that pipeline. Stage ids are carried inside the stages array on the records ' + 'pipeline_list returns; there is no endpoint that lists stages on their own.' + ), + 'name': STR('Name of the opportunity, for example "Website redesign".'), + 'status': ENUM(_STATUS_DESC, _WRITE_STATUSES), + 'monetaryValue': NUM('Value of the deal, as a number in the sub-account currency.'), + 'assignedTo': STR('Id of the user the opportunity is assigned to. Get user ids from user_list_by_location.'), + 'customFields': _CUSTOM_FIELDS_WRITE, + 'extra': EXTRA(), +} + +#: Body keys and schema properties for opportunity_create only. +_OPPORTUNITY_CREATE_KEYS = _OPPORTUNITY_WRITE_KEYS + _CREATE_ONLY_KEYS +_OPPORTUNITY_CREATE_PROPS = { + **_OPPORTUNITY_WRITE_PROPS, + 'contactId': STR( + 'Id of the contact the opportunity belongs to. Required, and it cannot be changed afterwards: ' + 'opportunity_update does not accept it. Get contact ids from contact_search.' + ), +} + +#: Body keys and schema properties for opportunity_upsert. +#: +#: A different set again, and not a subset of either write. UpsertOpportunityDto declares no +#: ``contactId`` and no ``customFields``, and it declares three follower fields the other +#: writes do not have. It also declares ``monetaryValue`` as an object while create and update +#: declare the same field a number; the number is what the other two take and what the spec's +#: own example shows, so it is typed as one here. +_OPPORTUNITY_UPSERT_KEYS = ( + 'id', + 'pipelineId', + 'pipelineStageId', + 'name', + 'status', + 'monetaryValue', + 'assignedTo', + 'lostReasonId', + 'followers', + 'isRemoveAllFollowers', + 'followersActionType', +) + +_OPPORTUNITY_UPSERT_PROPS = { + 'id': STR( + 'Id of an existing opportunity to update. Omit it to create one. This is the only field that ' + 'identifies an existing record: the upsert body declares no contactId.' + ), + 'pipelineId': _OPPORTUNITY_WRITE_PROPS['pipelineId'], + 'pipelineStageId': _OPPORTUNITY_WRITE_PROPS['pipelineStageId'], + 'name': _OPPORTUNITY_WRITE_PROPS['name'], + 'status': ENUM(_STATUS_DESC, _WRITE_STATUSES), + 'monetaryValue': _OPPORTUNITY_WRITE_PROPS['monetaryValue'], + 'assignedTo': _OPPORTUNITY_WRITE_PROPS['assignedTo'], + 'lostReasonId': STR('Id of the reason the deal was lost. Get lost reason ids from lost_reason_list.'), + 'followers': ARR( + 'Ids to add or remove as followers, according to followersActionType. Leave it out to touch no ' + 'followers: this tool then sends an empty list, which the endpoint requires. GoHighLevel labels this ' + 'field contactId here and user ids everywhere else, so prefer opportunity_followers_add, which is ' + 'unambiguous.' + ), + 'isRemoveAllFollowers': BOOL( + 'Whether to strip every follower off the opportunity. Defaults to false, which is what is sent when ' + 'this is omitted.' + ), + 'followersActionType': ENUM('What to do with the ids in followers. Defaults to "add".', ('add', 'remove')), + 'extra': EXTRA(), +} + +#: What an opportunity read returns for custom fields, stated once because several tools +#: return one. The direction mismatch is real and it is not the same one contacts have: +#: opportunities write ``field_value`` and read ``fieldValue``. +_OPPORTUNITY_CUSTOM_FIELDS_READ = ( + 'Custom field values come back as one object keyed by field id, or verbatim when GoHighLevel sends ' + 'them in a shape this node does not recognise. Neither is the array shape opportunity_create and ' + 'opportunity_update take, and the value is spelled fieldValue on the way out and field_value on the ' + 'way in, so a record from a read cannot be sent back unchanged.' +) + +_OPPORTUNITY_RECORD_OUTPUT = RECORD_OUTPUT( + f'The opportunity, trimmed to the fields this node returns. {_OPPORTUNITY_CUSTOM_FIELDS_READ}' +) + +_OPPORTUNITY_ITEMS = 'The opportunities on this page, each trimmed to the fields this node returns.' + +_FOLLOWERS_SHAPE = ( + 'GoHighLevel types the followers array on the opportunity record as an array of arrays, which ' + 'describes no field, so treat whatever comes back as opaque.' +) + + +def _clean_opportunity(opportunity: Any) -> dict: + """Trim an opportunity record and project its custom fields into a dict keyed by field id. + + The projection is a best effort rather than a filter, exactly as on contacts: a value in a + shape the projection does not recognise is handed back as it arrived, because losing data + silently is worse than returning a shape the agent has to look at. + """ + if not isinstance(opportunity, dict): + return {} + out = {key: opportunity[key] for key in _OPPORTUNITY_READ_KEYS if key in opportunity} + raw = opportunity.get('customFields') + if raw is not None: + custom = split_custom_fields(raw) + out['customFields'] = custom or raw + return out + + +def _opportunity_total(payload: Any) -> Any: + """Total matching records for a search, under whichever key that route reports it. + + The two searches sit on one path and answer differently: the GET puts the count in + ``meta.total`` and the POST puts it at the root under ``total`` with no ``meta`` at all. + """ + if not isinstance(payload, dict): + return None + meta = payload.get('meta') + if isinstance(meta, dict) and meta.get('total') is not None: + return meta['total'] + return payload.get('total') + + +def _search_cursor(payload: Any, records: Any) -> dict | None: + """Style A cursor for GET /opportunities/search, from ``meta`` first and the records second. + + This endpoint reports the pair in ``meta`` while the contacts list carries it on each + record, and both have been observed, so both are read. ``meta.nextPageUrl`` is never + followed: its documented example leaks a localhost address. + """ + meta = payload.get('meta') if isinstance(payload, dict) else None + if isinstance(meta, dict): + after = meta.get('startAfter') + after_id = meta.get('startAfterId') + if after is not None and after_id is not None: + return {'startAfter': after, 'startAfterId': after_id} + return start_after_cursor(records) + + +def _bool_params(args: dict, keys: tuple[str, ...]) -> dict: + """Render the supplied boolean arguments as the lowercase strings the query string needs. + + ``requests`` renders a Python ``True`` into a query string as ``True``, and GoHighLevel + validates query parameters strictly enough to answer 422 for a value it does not + recognise, so the capitalised form cannot be sent. Anything that is not a real boolean is + dropped rather than coerced: guessing at ``"yes"`` would send a filter the agent did not + ask for. + """ + out: dict = {} + for key in keys: + value = args.get(key) + if isinstance(value, bool): + out[key] = 'true' if value else 'false' + return out + + +def _follower_result(payload: Any) -> dict: + """Followers reported back by a follower write, plus the delta when the API states one.""" + if not isinstance(payload, dict): + return {'followers': []} + out: dict = {'followers': payload.get('followers') or []} + for key in ('followersAdded', 'followersRemoved'): + if key in payload: + out[key] = payload[key] or [] + return out + + +def _flag_result(payload: Any) -> dict: + """Outcome of a call whose whole answer is a success flag. + + On this surface the flag is spelled ``succeded``, with one "e", so it is read through + normalize_success rather than by name: reading ``succeeded`` returns None, which is falsy, + and a successful status change would report as a failure. + """ + return {'ok': normalize_success(payload)} + + +def _upsert_result(payload: Any) -> dict: + """Upsert response: the opportunity, plus whether it was created rather than updated.""" + if not isinstance(payload, dict): + return {'opportunity': _clean_opportunity(payload), 'new': None} + return {'opportunity': _clean_opportunity(payload.get('opportunity')), 'new': payload.get('new')} + + +class OpportunitiesMixin(GoHighLevelToolsBase): + """Tools for the ``opportunities`` group.""" + + # -- searching --------------------------------------------------------- + + @gohighlevel_tool( + group='opportunities', + input_schema=schema( + **PAGING_START_AFTER('opportunity_search'), + q=STR('Free-text search across the opportunity and its contact, for example an email address.'), + pipeline_id=STR('Return only opportunities in this pipeline. Get pipeline ids from pipeline_list.'), + pipeline_stage_id=STR('Return only opportunities sitting in this stage of that pipeline.'), + contact_id=STR('Return only opportunities belonging to this contact.'), + status=ENUM( + 'Return only opportunities in this state. "all" means every state, which is also what ' + 'omitting this does.', + _SEARCH_STATUSES, + ), + assigned_to=STR('Return only opportunities assigned to this user id.'), + campaignId=STR('Return only opportunities attributed to this campaign id.'), + id=STR('Return only the opportunity with this id. opportunity_get is the direct way to fetch one.'), + order=STR('Sort order, for example "added_asc". GoHighLevel publishes no list of the values it takes.'), + date=STR('Start of the date range to search, formatted mm-dd-yyyy, which is what this endpoint takes.'), + endDate=STR('End of the date range to search, formatted mm-dd-yyyy.'), + country=STR('2-letter country code to filter on, for example "US".'), + getTasks=BOOL('Whether to include the tasks on each opportunity.'), + getNotes=BOOL('Whether to include the notes on each opportunity.'), + getCalendarEvents=BOOL('Whether to include the calendar events on each opportunity.'), + ), + description=( + 'Find opportunities in the configured sub-account, filtered by pipeline, stage, contact, status, ' + 'assignee or free text. This is the way to list opportunities: GoHighLevel has no plain list ' + 'endpoint for them. Use it to answer "what is in this stage", "what is this contact working on" ' + 'and "which deals did we win". Returns opportunities under items, each carrying its pipelineId and ' + 'pipelineStageId, which pipeline_list turns into names. next holds {"startAfter", "startAfterId"}: ' + 'pass those two values back as the startAfter and startAfterId parameters, and ask for another page ' + 'only while has_more is true. Prefer this over opportunity_search_advanced unless you need the ' + 'unread-conversation counts only that one returns.' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: an object holding startAfter and startAfterId. Pass both back as the ' + 'startAfter and startAfterId parameters. Null when there is no next page.', + items=_OPPORTUNITY_ITEMS, + ), + ) + def opportunity_search(self, args): + args = self._args(args, 'opportunity_search') + params, limit = start_after_params(args, 'opportunity_search') + params.update( + params_from( + args, + ( + 'q', + 'pipeline_id', + 'pipeline_stage_id', + 'contact_id', + 'status', + 'assigned_to', + 'campaignId', + 'id', + 'order', + 'date', + 'endDate', + 'country', + ), + ) + ) + params.update(_bool_params(args, ('getTasks', 'getNotes', 'getCalendarEvents'))) + # snake_case, and the only endpoint on this surface that spells it that way. The + # camelCase form is live-confirmed to fail: 422 "property locationId should not exist". + params['location_id'] = self._location() + payload, records = self._fetch('GET', '/opportunities/search', key='opportunities', params=params) + return paginated( + [_clean_opportunity(record) for record in records], + total=_opportunity_total(payload), + next_cursor=_search_cursor(payload, records), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='opportunities', + input_schema=schema( + **PAGING_SEARCH_AFTER_PAGE('opportunity_search_advanced'), + query=STR('Free-text search across the opportunity and its contact. Sent as an empty string when omitted.'), + includeNotes=BOOL('Whether to include the notes on each opportunity.'), + includeTasks=BOOL('Whether to include the tasks on each opportunity.'), + includeCalendarEvents=BOOL('Whether to include the calendar events on each opportunity.'), + includeUnreadConversations=BOOL('Whether to include the unread conversation counts on each opportunity.'), + ), + description=( + 'Search opportunities in the configured sub-account by free text, and optionally pull their notes, ' + 'tasks, calendar events and unread conversation counts back in the same call. Use opportunity_search ' + 'instead for anything else: this one cannot filter by pipeline, stage, contact, status or assignee, ' + 'and the unread conversation counts are the only thing it returns that the other does not. It does ' + 'report total, which the other only sometimes does. Returns opportunities under items. next holds an ' + 'opaque searchAfter value to pass back unchanged, and another page should be asked for only while ' + 'has_more is true.' + ), + output_schema=LIST_OUTPUT( + 'Cursor for the next page: the opaque searchAfter value carried by the last record of this page. ' + 'Pass it back unchanged as the searchAfter parameter. Null when there is no next page.', + items=_OPPORTUNITY_ITEMS, + ), + ) + def opportunity_search_advanced(self, args): + args = self._args(args, 'opportunity_search_advanced') + body, limit = search_after_page_body(args, 'opportunity_search_advanced') + # This body declares all six of its fields required, so five of them are always sent + # with a harmless value. "page" is the exception: the vendor documents it 1-based on + # the GET form of this path and 0-based on the v3 spec for the same path, so a default + # would silently skip or repeat the first page under one of the two readings. It is + # sent only when the agent asks for it, and searchAfter is the cursor to prefer. + body['locationId'] = self._location() + body.setdefault('searchAfter', []) + body['query'] = args.get('query') or '' + body['additionalDetails'] = { + 'notes': bool(args.get('includeNotes')), + 'tasks': bool(args.get('includeTasks')), + 'calendarEvents': bool(args.get('includeCalendarEvents')), + 'unReadConversations': bool(args.get('includeUnreadConversations')), + } + payload, records = self._fetch('POST', '/opportunities/search', key='opportunities', body=body) + return paginated( + [_clean_opportunity(record) for record in records], + total=_opportunity_total(payload), + next_cursor=search_after_cursor(records), + requested_limit=limit, + ) + + # -- the opportunity record -------------------------------------------- + + @gohighlevel_tool( + group='opportunities', + input_schema=schema(required=['opportunityId'], opportunityId=STR('Id of the opportunity.')), + description=( + 'Get one opportunity by id. The record carries its followers inline, and that is the only way to ' + f'read them: GoHighLevel has no endpoint that lists an opportunity followers. {_FOLLOWERS_SHAPE} ' + f'{_OPPORTUNITY_CUSTOM_FIELDS_READ}' + ), + output_schema=_OPPORTUNITY_RECORD_OUTPUT, + ) + def opportunity_get(self, args): + args = self._args(args, 'opportunity_get') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_get') + return self._get(f'/opportunities/{opportunity_id}', _clean_opportunity, key='opportunity') + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema( + required=['pipelineId', 'name', 'status', 'contactId'], + **_OPPORTUNITY_CREATE_PROPS, + ), + description=( + 'Create an opportunity in the configured sub-account, against a pipeline and a contact. All four of ' + 'pipelineId, name, status and contactId are required: get the pipeline id from pipeline_list and the ' + 'contact id from contact_search, and open is the status a new deal normally starts in. The contact ' + 'cannot be changed afterwards. Pass pipelineStageId to place the deal in a specific stage, otherwise ' + 'GoHighLevel puts it in the first one.' + ), + output_schema=_OPPORTUNITY_RECORD_OUTPUT, + ) + def opportunity_create(self, args): + args = self._args(args, 'opportunity_create') + require_id(args, 'pipelineId', 'opportunity_create') + require_id(args, 'contactId', 'opportunity_create') + require_text(args, 'name', 'opportunity_create') + require_text(args, 'status', 'opportunity_create') + body = body_from(args, _OPPORTUNITY_CREATE_KEYS, tool='opportunity_create') + body['locationId'] = self._location() + # The trailing slash is load-bearing: the path is registered as /opportunities/. + return self._write('POST', '/opportunities/', _clean_opportunity, key='opportunity', body=body) + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema( + required=['opportunityId'], + opportunityId=STR('Id of the opportunity to update.'), + **_OPPORTUNITY_WRITE_PROPS, + ), + description=( + 'Update an opportunity. Only the fields you pass are changed. This is also how a deal is moved ' + 'between stages and between pipelines: pass pipelineStageId, and pipelineId too when the stage ' + 'belongs to a different pipeline. There is no separate stage endpoint. Sending customFields replaces ' + 'only the fields you name. The contact an opportunity belongs to cannot be changed, so this tool ' + 'takes no contactId even though opportunity_create requires one. Use opportunity_status_update when ' + 'the status is all you are changing, since that one takes a lost reason with it.' + ), + output_schema=_OPPORTUNITY_RECORD_OUTPUT, + ) + def opportunity_update(self, args): + args = self._args(args, 'opportunity_update') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_update') + body = body_from(args, _OPPORTUNITY_WRITE_KEYS, tool='opportunity_update') + return self._write('PUT', f'/opportunities/{opportunity_id}', _clean_opportunity, key='opportunity', body=body) + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema(required=['opportunityId'], opportunityId=STR('Id of the opportunity to delete.')), + description=( + 'Delete an opportunity by id. Prefer opportunity_status_update with "lost" or "abandoned" when the ' + 'deal simply did not close: that keeps the record and its history, and a delete does not.' + ), + output_schema=RECORD_OUTPUT( + 'Whether the delete succeeded, under deleted, with the raw GoHighLevel response under data.' + ), + ) + def opportunity_delete(self, args): + args = self._args(args, 'opportunity_delete') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_delete') + return self._delete(f'/opportunities/{opportunity_id}') + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema(required=['pipelineId'], **_OPPORTUNITY_UPSERT_PROPS), + description=( + 'Update an opportunity when you pass its id, or create one when you do not. Returns the opportunity ' + 'plus new, which is true when one was created. Reach for opportunity_create and opportunity_update ' + 'first: this endpoint takes no contactId, so an opportunity it creates is not attached to anyone, ' + 'and it takes no customFields either. It is worth using when you hold an id that may or may not ' + 'still exist, or when you want to set followers in the same call as the rest of the record.' + ), + output_schema=RECORD_OUTPUT( + 'The opportunity under opportunity, trimmed to the fields this node returns, plus new, which is ' + 'true when an opportunity was created rather than updated.' + ), + ) + def opportunity_upsert(self, args): + args = self._args(args, 'opportunity_upsert') + require_id(args, 'pipelineId', 'opportunity_upsert') + body = body_from(args, _OPPORTUNITY_UPSERT_KEYS, tool='opportunity_upsert') + # The upsert body declares its three follower fields required, so all three are always + # sent. The defaults are the no-op combination: an empty add that removes nothing. + body.setdefault('followers', []) + body.setdefault('isRemoveAllFollowers', False) + body.setdefault('followersActionType', 'add') + body['locationId'] = self._location() + return self._write('POST', '/opportunities/upsert', _upsert_result, body=body) + + # -- status ------------------------------------------------------------ + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema( + required=['opportunityId', 'status'], + opportunityId=STR('Id of the opportunity.'), + status=ENUM(_STATUS_DESC, _WRITE_STATUSES), + lostReasonId=STR( + 'Id of the reason the deal was lost, which goes with a status of "lost". Get lost reason ids ' + 'from lost_reason_list.' + ), + ), + description=( + 'Move an opportunity to won, lost, abandoned or back to open. This is the tool for closing a deal, ' + 'because it is the only one that takes a lost reason with the status. It answers with a flag rather ' + 'than the record, so call opportunity_get afterwards if you need the updated opportunity.' + ), + output_schema=RECORD_OUTPUT('ok is true when GoHighLevel reported the status change as successful.'), + ) + def opportunity_status_update(self, args): + args = self._args(args, 'opportunity_status_update') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_status_update') + require_text(args, 'status', 'opportunity_status_update') + body = body_from(args, ('status', 'lostReasonId')) + return self._write('PUT', f'/opportunities/{opportunity_id}/status', _flag_result, body=body) + + # -- followers --------------------------------------------------------- + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema( + required=['opportunityId', 'followers'], + opportunityId=STR('Id of the opportunity.'), + followers=ARR('User ids to add as followers. Get user ids from user_list_by_location.'), + ), + description=( + 'Add users as followers of an opportunity, so they are notified as it moves. There is no endpoint ' + 'that lists an opportunity followers, so read the followers array on the record opportunity_get ' + f'returns. {_FOLLOWERS_SHAPE}' + ), + output_schema=RECORD_OUTPUT( + 'The opportunity whole follower list after the change, under followers, plus followersAdded when ' + 'GoHighLevel reports which ids it added.' + ), + ) + def opportunity_followers_add(self, args): + args = self._args(args, 'opportunity_followers_add') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_followers_add') + body = body_from(args, ('followers',)) + return self._write('POST', f'/opportunities/{opportunity_id}/followers', _follower_result, body=body) + + @gohighlevel_tool( + group='opportunities', + writes=True, + input_schema=schema( + required=['opportunityId', 'followers'], + opportunityId=STR('Id of the opportunity.'), + followers=ARR('User ids to remove as followers.'), + isRemoveAllFollowers=BOOL( + 'Whether to strip every follower off the opportunity instead of only the ids listed in followers.' + ), + ), + description=( + 'Remove followers from an opportunity, by user id, or clear them all with isRemoveAllFollowers. ' + 'Returns the followers the opportunity still has.' + ), + output_schema=RECORD_OUTPUT( + 'The opportunity whole follower list after the change, under followers, plus followersRemoved when ' + 'GoHighLevel reports which ids it removed.' + ), + ) + def opportunity_followers_remove(self, args): + args = self._args(args, 'opportunity_followers_remove') + opportunity_id = require_id(args, 'opportunityId', 'opportunity_followers_remove') + body = body_from(args, ('followers',)) + params = _bool_params(args, ('isRemoveAllFollowers',)) + return self._write( + 'DELETE', + f'/opportunities/{opportunity_id}/followers', + _follower_result, + body=body, + params=params, + ) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/pipelines.py b/nodes/src/nodes/tool_gohighlevel/tools/pipelines.py new file mode 100644 index 000000000..e37a09c6b --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/pipelines.py @@ -0,0 +1,130 @@ +# ============================================================================= +# 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 tools: the pipelines opportunities move through, and the lost reasons a closed one can cite.""" + +from __future__ import annotations + +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + LIST_OUTPUT, + STR, + GoHighLevelToolsBase, + params_from, + passthrough, + schema, +) + +#: Both tools here return their records untouched, which is a deliberate exception to the +#: projection every other read in this node does. +#: +#: A pipeline carries seven declared fields and a lost reason five, so there is nothing to trim +#: for readability. More to the point, the pipeline ``stages`` array is declared +#: ``{"type": "array", "items": {"type": "array"}}`` in the spec and ``any[][]`` in the official +#: SDK, which names no field at all, and no live capture of one exists yet. A projection would +#: therefore have to guess at the stage shape, and the stage id is the single value +#: opportunity_create and opportunity_update need most. Passing the records through cannot drop +#: it. +_PIPELINE_ITEMS = ( + 'The pipelines on the sub-account, as GoHighLevel returns them: id, name, the stages array, ' + 'showInFunnel, showInPieChart, locationId and colorRenderMode.' +) + +_LOST_REASON_ITEMS = 'The lost reasons on the sub-account, as GoHighLevel returns them.' + +_NO_NEXT_PAGE = 'Always null: this endpoint answers in one response, so there is no next page.' + + +def _bool_params(args: dict, keys: tuple[str, ...]) -> dict: + """Render the supplied boolean arguments as the lowercase strings the query string needs. + + ``requests`` renders a Python ``True`` into a query string as ``True``, and GoHighLevel + validates query parameters strictly enough to answer 422 for a value it does not + recognise, so the capitalised form cannot be sent. Anything that is not a real boolean is + dropped rather than coerced. + """ + out: dict = {} + for key in keys: + value = args.get(key) + if isinstance(value, bool): + out[key] = 'true' if value else 'false' + return out + + +class PipelinesMixin(GoHighLevelToolsBase): + """Tools for the ``pipelines`` group.""" + + @gohighlevel_tool( + group='pipelines', + input_schema=schema(), + description=( + 'List the opportunity pipelines on the configured sub-account, each with its stages. Call this ' + 'before opportunity_create or opportunity_update: those need a pipelineId, and a pipelineStageId ' + 'that belongs to the same pipeline, and this is the only place either id can be found. It is also ' + 'what turns the pipelineId and pipelineStageId on an opportunity back into names. Every pipeline ' + 'comes back in one response, so there is no cursor. Pipelines are read-only over the API: there is ' + 'no way to create, rename or delete one, and no endpoint that returns a single pipeline or its ' + 'stages on their own. GoHighLevel publishes no schema for the objects inside the stages array, so ' + 'read the ids out of it as they arrive rather than expecting particular field names.' + ), + output_schema=LIST_OUTPUT(_NO_NEXT_PAGE, items=_PIPELINE_ITEMS), + ) + def pipeline_list(self, args): + args = self._args(args, 'pipeline_list') + # camelCase here, unlike GET /opportunities/search on the same resource, which takes + # location_id and rejects this spelling. + params = {'locationId': self._location()} + return self._list('/opportunities/pipelines', key='pipelines', cleaner=passthrough, params=params) + + @gohighlevel_tool( + group='pipelines', + input_schema=schema( + name=STR('Return only the lost reason with this exact name.'), + query=STR('Free-text search across the lost reason names.'), + deleted=BOOL('Whether to return deleted lost reasons instead of live ones. Defaults to false.'), + ), + description=( + 'List the reasons this sub-account records for a lost deal. Call it before marking an opportunity ' + 'lost: opportunity_status_update and opportunity_upsert take a lostReasonId, and this is the only ' + 'place those ids come from. A sub-account may have none configured, in which case a deal can still ' + 'be marked lost without one. The whole list comes back in one response, capped at the 100 ' + 'GoHighLevel returns by default, and total reports how many exist when GoHighLevel sends a count.' + ), + output_schema=LIST_OUTPUT(_NO_NEXT_PAGE, items=_LOST_REASON_ITEMS), + ) + def lost_reason_list(self, args): + args = self._args(args, 'lost_reason_list') + params = params_from(args, ('name', 'query')) + params.update(_bool_params(args, ('deleted',))) + params['locationId'] = self._location() + # The path is singular. /opportunities/lost-reasons does not exist. + return self._list( + '/opportunities/lost-reason', + key='lostReasons', + cleaner=passthrough, + params=params, + total_key='total', + ) diff --git a/nodes/src/nodes/tool_gohighlevel/tools/users.py b/nodes/src/nodes/tool_gohighlevel/tools/users.py new file mode 100644 index 000000000..03976d5c6 --- /dev/null +++ b/nodes/src/nodes/tool_gohighlevel/tools/users.py @@ -0,0 +1,258 @@ +# ============================================================================= +# 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: reading the staff users of the configured sub-account, to resolve user ids.""" + +from __future__ import annotations + +from typing import Any + +from ..gohighlevel_client import paginated +from ..tool_groups import gohighlevel_tool +from ._base import ( + BOOL, + LIST_OUTPUT, + PAGING_SKIP_TEXT, + RECORD_OUTPUT, + STR, + GoHighLevelToolsBase, + next_offset, + params_from, + record_of, + require_id, + schema, + skip_text_params, +) + +#: Fields kept from a user record on a listing. +#: +#: ``UserSchema`` declares thirteen fields and two of them are large: ``permissions`` is a flat +#: map of 38 booleans and ``scopes`` is a long OAuth scope string. A page of 25 users would +#: carry roughly a thousand keys of it, so a listing keeps identity plus role and the two big +#: fields are read one user at a time through user_get. That is the whole reason there are two +#: projections in this module rather than one. +_USER_LIST_KEYS = ( + 'id', + 'name', + 'firstName', + 'lastName', + 'email', + 'phone', + 'extension', + 'roles', + 'deleted', +) + +#: Fields kept from a single user record, which is every field ``UserSchema`` declares. +_USER_READ_KEYS = _USER_LIST_KEYS + ( + 'permissions', + 'scopes', + 'lcPhone', + 'platformLanguage', +) + +#: Query parameters GET /users/search accepts beyond paging and the two ids this node supplies. +#: ``enabled2waySync`` is absent here because it is a boolean and goes through +#: :func:`_bool_param` instead. +_USER_SEARCH_KEYS = ('query', 'type', 'role', 'ids', 'sort', 'sortDirection') + +_USER_RECORD_OUTPUT = RECORD_OUTPUT( + 'The user, trimmed to the fields this node returns, including the permissions map and the scopes string.' +) + +_USER_ITEMS = ( + 'The users on this page. Each carries identity and role only: call user_get for the permissions map and ' + 'the scopes string.' +) + +#: Said by both list tools, because the id these tools exist to produce is used in four places. +_USER_ID_USES = ( + 'The id is what assignedTo on a contact, followers on a contact, assignedTo on a task and assignedUserId ' + 'on an appointment all take.' +) + + +def _clean_user(user: Any) -> dict: + """Trim a user record to every field this node returns, for a single-record read.""" + if not isinstance(user, dict): + return {} + return {key: user[key] for key in _USER_READ_KEYS if key in user} + + +def _clean_user_summary(user: Any) -> dict: + """Trim a user record to identity and role, for a listing.""" + if not isinstance(user, dict): + return {} + return {key: user[key] for key in _USER_LIST_KEYS if key in user} + + +def _user_total(payload: Any) -> Any: + """Total matching users, which only GET /users/search reports and only as ``count``.""" + if isinstance(payload, dict): + return payload.get('count') + return None + + +def _bool_param(args: dict, key: str) -> dict: + """Render a boolean query parameter the way GoHighLevel reads it, or {} when it is absent. + + ``requests`` stringifies a Python bool as ``True``/``False``, capitalised, which is not a + value any documented GoHighLevel enum accepts and which a lenient parser would read as + truthy in both directions. The value is spelled out here instead, and ``False`` is sent + rather than dropped, because dropping it would silently mean "no filter" instead of + "only the users where this is false". + """ + value = args.get(key) + if value is None: + return {} + return {key: 'true' if value else 'false'} + + +class UsersMixin(GoHighLevelToolsBase): + """Tools for the ``users`` group. + + Read-only. GoHighLevel does publish user create, update and delete, but they provision + staff accounts and write ``password``, ``permissions`` and ``scopes``, and their spec + declares no ``userId`` path parameter, which has already broken the vendor's own SDK. + None of that belongs behind an agent tool, so this group offers three reads. + """ + + # -- companyId --------------------------------------------------------- + + def _company_id(self, args: dict, tool: str) -> str: + """Agency id GET /users/search requires, from the argument or from the sub-account record. + + This is the asymmetry that makes the users group awkward, and it is live-confirmed in + both directions: ``GET /users/search`` rejects a request with no ``companyId`` as + ``401 E01 - Unauthorized request``, wording that reads like a credential failure and is + really a missing parameter, while its sibling ``GET /users/`` rejects the same value + outright with ``property companyId should not exist``. + + The node has no companyId setting, and a Private Integration Token is opaque so nothing + can be derived from it, which leaves one source: the sub-account record carries the + agency it belongs to. Reading it costs a request against a measured 25-request/10s + budget, so the tool takes an override for an agent that already has the value. Nothing + is cached here: this mixin is composed into a node instance whose lifetime is not this + module's to assume. + """ + supplied = args.get('companyId') + if isinstance(supplied, str) and supplied.strip(): + return supplied.strip() + location = self._location() + record = record_of(self._call('GET', f'/locations/{location}'), 'location') + company = record.get('companyId') if isinstance(record, dict) else None + if isinstance(company, str) and company.strip(): + return company.strip() + raise ValueError( + f'{tool}: GoHighLevel requires a companyId here and the sub-account record for {location} did not ' + f'carry one. Pass companyId, or use user_list_by_location, which needs no companyId.' + ) + + # -- reads ------------------------------------------------------------- + + @gohighlevel_tool( + group='users', + input_schema=schema( + **PAGING_SKIP_TEXT('user_search'), + query=STR('Free-text search matched against the user full name, email or phone.'), + type=STR('Account type to filter on, for example "agency" or "account".'), + role=STR('Role to filter on, for example "admin" or "user".'), + ids=STR('Specific user ids to return, as one comma-separated string rather than an array.'), + sort=STR('Field to sort on, for example "dateAdded". Sorting is by first and last name by default.'), + sortDirection=STR('Sort direction, "asc" or "desc".'), + enabled2waySync=BOOL('Whether to return only users with two-way sync enabled, or only those without.'), + companyId=STR( + 'Agency id. Omit it: the node reads it from the sub-account record. Pass it only to save that ' + 'extra request when you already have the value.' + ), + ), + description=( + 'Find users of the configured sub-account by name, email, phone or role. This is the only user read ' + f'that can filter or page, so prefer it over user_list_by_location when you know anything about the ' + f'user you want. {_USER_ID_USES} Each result carries identity and role only, so call user_get for a ' + 'user permissions and scopes. Omitting companyId costs one extra request, because GoHighLevel ' + 'requires an agency id here that this node has to read off the sub-account first. Returns users ' + 'under items, and next is the skip value for the following page.' + ), + output_schema=LIST_OUTPUT( + 'Offset for the next page: pass it back as the skip parameter. Null once a page comes back shorter ' + 'than the limit asked for, which means there is no next page.', + items=_USER_ITEMS, + ), + ) + def user_search(self, args): + args = self._args(args, 'user_search') + params, limit = skip_text_params(args, 'user_search') + params.update(params_from(args, _USER_SEARCH_KEYS)) + params.update(_bool_param(args, 'enabled2waySync')) + params['companyId'] = self._company_id(args, 'user_search') + # Both ids are sent. companyId is required by the endpoint and locationId is the + # optional narrowing that keeps the result inside the sub-account this node is + # configured for, rather than returning every user in the agency. + params['locationId'] = self._location() + payload, records = self._fetch('GET', '/users/search', key='users', params=params) + return paginated( + [_clean_user_summary(record) for record in records], + total=_user_total(payload), + next_cursor=next_offset(args.get('skip'), limit, len(records)), + requested_limit=limit, + ) + + @gohighlevel_tool( + group='users', + input_schema=schema(), + description=( + 'List every user of the configured sub-account in one response. There is no paging and no filtering: ' + f'the whole list comes back at once, so next is always null. {_USER_ID_USES} Each user carries ' + 'identity and role only, so call user_get for a user permissions and scopes. Prefer user_search when ' + 'you want to narrow by name, email or role, and use this one to see everybody.' + ), + output_schema=LIST_OUTPUT( + 'Always null: this endpoint returns every user in one response, so there is no next page.', + items=_USER_ITEMS, + ), + ) + def user_list_by_location(self, args): + args = self._args(args, 'user_list_by_location') + # The trailing slash is load-bearing, and so is the absence of companyId: this endpoint + # rejects it with "property companyId should not exist", which is the exact opposite of + # what its sibling GET /users/search demands. Both were confirmed live. + return self._list('/users/', key='users', cleaner=_clean_user_summary, params={'locationId': self._location()}) + + @gohighlevel_tool( + group='users', + input_schema=schema(required=['userId'], userId=STR('Id of the user.')), + description=( + 'Get one user by id, including the permissions map and the scopes string that the two list tools ' + 'leave out. Get user ids from user_search or user_list_by_location. This tool is not scoped to the ' + 'configured sub-account: it answers for any user the token can see.' + ), + output_schema=_USER_RECORD_OUTPUT, + ) + def user_get(self, args): + args = self._args(args, 'user_get') + user_id = require_id(args, 'userId', 'user_get') + # The user arrives bare rather than under a "user" key, so no envelope key is named. + return self._get(f'/users/{user_id}', _clean_user) diff --git a/nodes/test/tool_gohighlevel/__init__.py b/nodes/test/tool_gohighlevel/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nodes/test/tool_gohighlevel/test_gohighlevel.py b/nodes/test/tool_gohighlevel/test_gohighlevel.py new file mode 100644 index 000000000..742fb87e9 --- /dev/null +++ b/nodes/test/tool_gohighlevel/test_gohighlevel.py @@ -0,0 +1,1449 @@ +""" +Unit tests for tool_gohighlevel. + +Covers the ``Version`` header (the header GoHighLevel requires on every request and +values per operation), auth and credential hygiene, rate-limit retries, error-body +parsing, the tool-group publication filter, read-only enforcement, pagination and the +published tool descriptors. No credentials and no network access are needed: this is +what runs in CI. See test_tools.py for the env-gated live suite. + +The framework is stubbed rather than imported, so the node loads without the engine. +Two stubs are deliberately stricter than the tool_pipedrive originals they were ported +from, because the loose versions hid real behaviour: + +* ``require_int`` there accepted 3.7 and truncated it to 3. The real helper rejects + floats outright, so a test written against the loose stub would pass on input the + engine refuses. +* ``normalize_tool_input`` there returned the args dict untouched. The real helper also + unwraps a ``{"input": {...}}`` envelope and strips ``security_context``, which is one + of the shapes the engine's invoke chain actually delivers, so the loose stub left that + delivery path untested. + +Both now match ``packages/ai/src/ai/common/utils/tool_args.py`` on the behaviour these +tests depend on. +""" + +from __future__ import annotations + +import json +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 Private Integration Token is "pit-" plus a UUID, but a literal +# one here trips secret scanners, so use a placeholder of the same shape class: the +# "pit-" prefix IGlobal.validateConfig looks for, and long enough that _redact fires +# (it ignores anything under 8 characters). +GHL_TEST_TOKEN = 'pit-gohighlevel-test-not-a-real-token' + +# The sub-account and agency ids from the live probe. They are public-looking opaque +# ids, not credentials. +LOCATION_ID = '9oPHGsovxk0Pvi1zmlOy' +COMPANY_ID = 'TM8GhFqVPF91bSMwbPrx' + +#: Stand-in record id. GoHighLevel ids are opaque strings of roughly 20 to 24 +#: characters, never integers. +RECORD_ID = 'ocQHyuzHvysMo5N5VsXc' + +#: The two ``Version`` values, written out rather than imported: a test that asserted +#: the constant against itself would pass even if both were changed to "v3". +CAL = '2021-04-15' +DEF = '2021-07-28' + +#: U+2014, written as an escape so this file does not contain the character it forbids. +EM_DASH = '\u2014' + + +# --------------------------------------------------------------------------- +# Framework stubs +# --------------------------------------------------------------------------- + + +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( + input_obj, + *, + extra_envelope_keys=(), + strip_keys=('security_context',), + parse_json_strings=True, + unwrap_pydantic=True, + tool_name='tool', + ): + """Coerce agent-supplied tool input to a plain args dict. + + Ported step for step from the real helper, including the envelope unwrap the + tool_pipedrive stub omitted: the engine's invoke chain can deliver + ``{"input": {...}, "security_context": ...}``, and a stub that only checked + ``isinstance(value, dict)`` let every tool pass that path untested. + """ + if input_obj is None: + return {} + + if unwrap_pydantic: + model_dump = getattr(input_obj, 'model_dump', None) + if callable(model_dump): + try: + input_obj = model_dump() + except Exception: + mod_rl.warning(f'{tool_name}: model_dump() raised during input normalisation') + else: + as_dict = getattr(input_obj, 'dict', None) + if callable(as_dict): + try: + input_obj = as_dict() + except Exception: + mod_rl.warning(f'{tool_name}: dict() raised during input normalisation') + + if parse_json_strings and isinstance(input_obj, str): + try: + parsed = json.loads(input_obj) + except (json.JSONDecodeError, TypeError): + parsed = None + if isinstance(parsed, dict): + input_obj = parsed + + if not isinstance(input_obj, dict): + mod_rl.warning(f'{tool_name}: unexpected input type {type(input_obj).__name__}') + return {} + + input_obj = dict(input_obj) + for key in ('input', *extra_envelope_keys): + wrapped = input_obj.get(key) + if isinstance(wrapped, dict): + extras = {k: v for k, v in input_obj.items() if k != key} + input_obj = {**wrapped, **extras} + + for key in strip_keys: + input_obj.pop(key, None) + return input_obj + + def require_str(args, key, *, tool_name=''): + val = args.get(key) + if not isinstance(val, str) or not val.strip(): + prefix = f'{tool_name}: ' if tool_name else '' + raise ValueError(f'{prefix}"{key}" is required and must be a non-empty string') + return val.strip() + + def _range_phrase(lo, hi): + if lo is not None and hi is not None: + return f' between {lo} and {hi}' + if lo is not None: + return f' >= {lo}' + if hi is not None: + return f' <= {hi}' + return '' + + def require_int(args, key, *, lo=None, hi=None, tool_name=''): + """Strict integer read. + + ``bool`` and ``float`` are rejected rather than coerced, which is where the + tool_pipedrive stub diverged: it accepted 3.7 and truncated it to 3, so a test + could pass on a value the engine refuses. + """ + prefix = f'{tool_name}: ' if tool_name else '' + val = args.get(key) + if val is None: + raise ValueError(f'{prefix}"{key}" is required') + if isinstance(val, (bool, float)) or not isinstance(val, (int, str)): + raise ValueError(f'{prefix}"{key}" must be an integer{_range_phrase(lo, hi)}') + try: + out = int(val) + except (TypeError, ValueError, OverflowError): + raise ValueError(f'{prefix}"{key}" must be an integer{_range_phrase(lo, hi)}') + if lo is not None and out < lo: + raise ValueError(f'{prefix}"{key}" must be an integer{_range_phrase(lo, hi)}') + if hi is not None and out > hi: + raise ValueError(f'{prefix}"{key}" must be an integer{_range_phrase(lo, hi)}') + return out + + def require_bool(args, key, *, tool_name=''): + prefix = f'{tool_name}: ' if tool_name else '' + val = args.get(key) + if val is None: + raise ValueError(f'{prefix}"{key}" is required') + if not isinstance(val, bool): + raise ValueError(f'{prefix}"{key}" must be a boolean') + return val + + def validate_tool_input_schema(input_schema, args, *, tool_name=''): + allowed = set((input_schema.get('properties') or {}).keys()) + unknown = sorted(k for k in args if k not in allowed) + if not unknown: + return + prefix = f'{tool_name}: ' if tool_name else '' + if allowed: + raise ValueError(f'{prefix}unknown parameter(s) {unknown}. Allowed parameters: {sorted(allowed)}.') + raise ValueError(f'{prefix}this tool takes no parameters; received unexpected: {unknown}.') + + mod_utils.normalize_tool_input = normalize_tool_input + mod_utils.require_str = require_str + mod_utils.require_int = require_int + mod_utils.require_bool = require_bool + mod_utils.validate_tool_input_schema = validate_tool_input_schema + 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 ai.common.utils import normalize_tool_input as stub_normalize_tool_input + from ai.common.utils import require_int as stub_require_int + from tool_gohighlevel.gohighlevel_client import ( + BASE_URL, + DEFAULT_VERSION, + PATH_VERSIONS, + VERSION_2021_04_15, + VERSION_2021_07_28, + GoHighLevelAPIError, + check_page_limits, + paginated, + version_for, + ) + from tool_gohighlevel.IInstance import IInstance + from tool_gohighlevel.tool_groups import ( + ALL_GROUPS, + DEFAULT_GROUPS, + RAW_REQUEST_TOOL, + gohighlevel_tool, + normalize_groups, + ) + +_CLIENT = 'tool_gohighlevel.gohighlevel_client.requests.request' + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resp(status=200, *, json_data=None, headers=None, text='', content=b'{}', reason='reason', 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(payload=None): + """A 2xx response. GoHighLevel has no envelope, so the payload is the body.""" + return _resp(200, json_data={} if payload is None else payload, content=b'{}') + + +def _instance(**overrides): + """Build an IInstance without running the engine lifecycle.""" + inst = IInstance.__new__(IInstance) + glob = Mock() + glob.token = GHL_TEST_TOKEN + glob.location_id = LOCATION_ID + 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 + + +def _dispatch(inst, tool, args=None): + """Call a tool the way the engine resolves one, through the published map. + + ``tool.invoke`` looks the name up in ``_collect_tool_methods()``, so a tool that + filter drops is not merely hidden from ``tool.query``: there is nothing left to + invoke. Raising here models that, and lets one assertion cover both halves. + """ + published = inst._collect_tool_methods() + if tool not in published: + raise LookupError(f'{tool}: not published by this node') + return published[tool](args or {}) + + +def _tool_attrs(): + """Every decorated tool on IInstance, as (name, function) pairs.""" + out = [] + for name in dir(IInstance): + attr = getattr(IInstance, name, None) + if attr is not None and hasattr(attr, '__tool_meta__'): + out.append((name, attr)) + return out + + +def _write_tool_names(): + return {name for name, attr in _tool_attrs() if getattr(attr, '__gohighlevel_writes__', False)} + + +def _paged_tool_names(): + """Tools that publish a ``limit``, which is what obliges a PAGE_LIMITS entry.""" + names = set() + for name, attr in _tool_attrs(): + properties = ((attr.__tool_meta__.get('input_schema') or {}).get('properties')) or {} + if 'limit' in properties: + names.add(name) + return names + + +def _descriptions(value): + """Every description string reachable inside a schema, in document order.""" + found = [] + if isinstance(value, dict): + for key, item in value.items(): + if key == 'description' and isinstance(item, str): + found.append(item) + else: + found.extend(_descriptions(item)) + elif isinstance(value, list): + for item in value: + found.extend(_descriptions(item)) + return found + + +def _sent(mock_request, index=-1): + """The (args, kwargs) of one call to requests.request.""" + call = mock_request.call_args_list[index] + return call[0], call[1] + + +#: Read calls covering every mixin, with the ``Version`` each path must carry. Args are +#: the minimum each tool's schema declares required, so the whole battery runs against a +#: single empty-object response. +_READ_CALLS = ( + ('contact_list', {}, DEF), + ('contact_search', {}, DEF), + ('contact_get', {'contactId': RECORD_ID}, DEF), + ('contact_duplicate_check', {'email': 'ada@example.com'}, DEF), + ('contact_appointments_list', {'contactId': RECORD_ID}, DEF), + ('contact_list_by_business', {'businessId': RECORD_ID}, DEF), + ('contact_notes_list', {'contactId': RECORD_ID}, DEF), + ('contact_notes_get', {'contactId': RECORD_ID, 'noteId': RECORD_ID}, DEF), + ('contact_tasks_list', {'contactId': RECORD_ID}, DEF), + ('contact_tasks_get', {'contactId': RECORD_ID, 'taskId': RECORD_ID}, DEF), + ('opportunity_search', {}, DEF), + ('opportunity_search_advanced', {}, DEF), + ('opportunity_get', {'opportunityId': RECORD_ID}, DEF), + ('pipeline_list', {}, DEF), + ('lost_reason_list', {}, DEF), + ('location_get', {}, DEF), + ('location_tasks_search', {}, DEF), + ('location_tags_list', {}, DEF), + ('location_tags_get', {'tagId': RECORD_ID}, DEF), + ('custom_field_list', {}, DEF), + ('custom_field_get', {'customFieldId': RECORD_ID}, DEF), + ('custom_value_list', {}, DEF), + ('custom_value_get', {'customValueId': RECORD_ID}, DEF), + ('business_list', {}, DEF), + ('business_get', {'businessId': RECORD_ID}, DEF), + ('user_search', {'companyId': COMPANY_ID}, DEF), + ('user_list_by_location', {}, DEF), + ('user_get', {'userId': RECORD_ID}, DEF), + ('calendar_list', {}, CAL), + ('calendar_get', {'calendarId': RECORD_ID}, CAL), + ('calendar_group_list', {}, CAL), + ('calendar_group_slug_check', {'slug': 'team-intro'}, CAL), + ('appointment_list', {'calendarId': RECORD_ID, 'startTime': 1700000000000, 'endTime': 1700086400000}, CAL), + ('appointment_get', {'eventId': RECORD_ID}, CAL), + ( + 'appointment_free_slots_list', + {'calendarId': RECORD_ID, 'startDate': 1700000000000, 'endDate': 1700086400000}, + CAL, + ), + ('appointment_notes_list', {'appointmentId': RECORD_ID}, CAL), + ('blocked_slot_list', {'calendarId': RECORD_ID, 'startTime': 1700000000000, 'endTime': 1700086400000}, CAL), + ('conversation_search', {}, CAL), + ('conversation_get', {'conversationId': RECORD_ID}, CAL), + ('message_list', {'conversationId': RECORD_ID}, CAL), + ('message_get', {'messageId': RECORD_ID}, CAL), + ('message_email_get', {'emailMessageId': RECORD_ID}, CAL), + ('message_export', {}, CAL), + ('message_transcription_get', {'messageId': RECORD_ID}, CAL), +) + +#: Write calls, same idea. Kept short: the point is that a write path derives the same +#: header as a read on the same resource, not to re-enumerate the surface. +_WRITE_CALLS = ( + ('contact_create', {'firstName': 'Ada'}, DEF), + ('contact_update', {'contactId': RECORD_ID, 'firstName': 'Ada'}, DEF), + ('contact_delete', {'contactId': RECORD_ID}, DEF), + ('contact_tags_add', {'contactId': RECORD_ID, 'tags': ['vip']}, DEF), + ('opportunity_delete', {'opportunityId': RECORD_ID}, DEF), + ('custom_value_delete', {'customValueId': RECORD_ID}, DEF), + ('calendar_delete', {'calendarId': RECORD_ID}, CAL), + ('calendar_group_delete', {'groupId': RECORD_ID}, CAL), + ('appointment_delete', {'eventId': RECORD_ID}, CAL), + ('appointment_notes_delete', {'appointmentId': RECORD_ID, 'noteId': RECORD_ID}, CAL), + ('conversation_delete', {'conversationId': RECORD_ID}, CAL), + ('message_schedule_cancel', {'messageId': RECORD_ID}, CAL), +) + +#: Rate-limit headers exactly as the deliberately triggered 429 returned them +#: (live-findings section 10). ``x-ratelimit-daily-reset`` is a duration in +#: milliseconds, not an epoch, and must never reach time.sleep. +BURST_429_HEADERS = { + 'content-type': 'application/json; charset=utf-8', + 'x-ratelimit-max': '25', + 'x-ratelimit-remaining': '0', + 'x-ratelimit-interval-milliseconds': '10000', + 'x-ratelimit-limit-daily': '10000', + 'x-ratelimit-daily-remaining': '9938', + 'x-ratelimit-daily-reset': '84523000', +} + +#: The captured 429 body: ``statusCode`` and ``message``, with no ``error`` key at all. +BODY_429 = {'statusCode': 429, 'message': 'Too Many Requests'} + +#: Values that are durations until the daily bucket resets, never sleep arguments. +#: 84523000 is from the captured 429, 86343000 from the ordinary responses. +DAILY_RESET_VALUES = (84523000, 86343000) + + +# --------------------------------------------------------------------------- +# The Version header +# +# GoHighLevel requires Version on every request and its value is per operation. +# Sending one value globally breaks the whole calendars and conversations family with a +# 4xx that reads like an authentication failure, which is why this is the first class in +# the file and the widest. +# --------------------------------------------------------------------------- + + +class TestVersionHeader: + @pytest.mark.parametrize( + 'path', + [ + '/calendars/', + '/calendars/groups', + '/calendars/events', + '/calendars/events/appointments', + '/calendars/blocked-slots', + f'/calendars/{RECORD_ID}/free-slots', + f'/calendars/appointments/{RECORD_ID}/notes', + 'calendars/', + '/Calendars/', + '/calendars/?limit=10', + ], + ) + def test_calendars_paths_resolve_to_2021_04_15(self, path): + assert version_for(path) == CAL + + @pytest.mark.parametrize( + 'path', + [ + '/conversations/search', + '/conversations/', + f'/conversations/{RECORD_ID}/messages', + '/conversations/messages/export', + f'/conversations/locations/{LOCATION_ID}/messages/{RECORD_ID}/transcription', + ], + ) + def test_conversations_paths_resolve_to_2021_04_15(self, path): + assert version_for(path) == CAL + + @pytest.mark.parametrize( + 'path', + [ + '/contacts/', + '/contacts/search', + '/opportunities/search', + '/opportunities/pipelines', + '/businesses/', + f'/locations/{LOCATION_ID}', + f'/locations/{LOCATION_ID}/customFields', + '/users/search', + '/', + '', + ], + ) + def test_every_other_path_resolves_to_2021_07_28(self, path): + assert version_for(path) == DEF + + @patch(_CLIENT) + @pytest.mark.parametrize('tool,args,expected', _READ_CALLS, ids=[case[0] for case in _READ_CALLS]) + def test_read_tools_send_the_right_version(self, mock_request, tool, args, expected): + mock_request.return_value = _ok() + _dispatch(_instance(tool_groups=ALL_GROUPS), tool, args) + for call in mock_request.call_args_list: + headers = call[1]['headers'] + assert headers['Version'] == expected + + @patch(_CLIENT) + @pytest.mark.parametrize('tool,args,expected', _WRITE_CALLS, ids=[case[0] for case in _WRITE_CALLS]) + def test_write_tools_send_the_right_version(self, mock_request, tool, args, expected): + mock_request.return_value = _ok() + _dispatch(_instance(tool_groups=ALL_GROUPS), tool, args) + assert mock_request.call_args[1]['headers']['Version'] == expected + + @patch(_CLIENT) + def test_version_is_present_on_every_request(self, mock_request): + """Not one request across the whole surface may go out without the header.""" + mock_request.return_value = _ok() + inst = _instance(tool_groups=ALL_GROUPS) + for tool, args, _expected in _READ_CALLS + _WRITE_CALLS: + _dispatch(inst, tool, args) + + assert mock_request.call_count >= len(_READ_CALLS) + len(_WRITE_CALLS) + for call in mock_request.call_args_list: + assert call[1]['headers'].get('Version') + + @patch(_CLIENT) + def test_no_code_path_sends_v3(self, mock_request): + """v3 is a header value on this API, and this node never sends it.""" + mock_request.return_value = _ok() + inst = _instance(tool_groups=ALL_GROUPS) + for tool, args, _expected in _READ_CALLS + _WRITE_CALLS: + _dispatch(inst, tool, args) + + sent = {call[1]['headers']['Version'] for call in mock_request.call_args_list} + assert sent == {CAL, DEF} + assert 'v3' not in sent + + def test_client_constants_are_the_two_documented_values(self): + assert VERSION_2021_04_15 == CAL + assert VERSION_2021_07_28 == DEF + assert DEFAULT_VERSION == DEF + assert PATH_VERSIONS == {'calendars': CAL, 'conversations': CAL} + assert 'v3' not in set(PATH_VERSIONS.values()) | {DEFAULT_VERSION} + + @patch(_CLIENT) + def test_raw_request_derives_the_version_from_the_path(self, mock_request): + mock_request.return_value = _ok() + _instance().request({'method': 'GET', 'path': '/calendars/'}) + assert mock_request.call_args[1]['headers']['Version'] == CAL + + _instance().request({'method': 'GET', 'path': '/contacts/'}) + assert mock_request.call_args[1]['headers']['Version'] == DEF + + @patch(_CLIENT) + def test_raw_request_version_override_is_honoured(self, mock_request): + """The one place a Version other than the derived value can be sent, and it is explicit.""" + mock_request.return_value = _ok() + _instance().request({'method': 'GET', 'path': '/contacts/', 'version': CAL}) + assert mock_request.call_args[1]['headers']['Version'] == CAL + + +# --------------------------------------------------------------------------- +# Auth and credential hygiene +# --------------------------------------------------------------------------- + + +class TestAuth: + @patch(_CLIENT) + def test_get_sends_exactly_three_headers(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_list({}) + + headers = mock_request.call_args[1]['headers'] + assert set(headers) == {'Accept', 'Authorization', 'Version'} + assert headers['Authorization'] == f'Bearer {GHL_TEST_TOKEN}' + assert headers['Accept'] == 'application/json' + + @patch(_CLIENT) + def test_content_type_is_added_only_for_a_bodied_request(self, mock_request): + mock_request.return_value = _ok({'contact': {'id': RECORD_ID}}) + _instance().contact_create({'firstName': 'Ada'}) + + _args, kwargs = _sent(mock_request) + assert set(kwargs['headers']) == {'Accept', 'Authorization', 'Version', 'Content-Type'} + assert kwargs['headers']['Content-Type'] == 'application/json' + assert kwargs['json'] == {'firstName': 'Ada', 'locationId': LOCATION_ID} + + @patch(_CLIENT) + def test_bodyless_delete_sends_no_content_type(self, mock_request): + mock_request.return_value = _ok({'succeded': True}) + _instance().contact_delete({'contactId': RECORD_ID}) + + kwargs = mock_request.call_args[1] + assert 'Content-Type' not in kwargs['headers'] + assert kwargs['json'] is None + + @patch(_CLIENT) + def test_the_token_never_reaches_the_url_or_the_query_string(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_list({'query': 'ada'}) + + args, kwargs = _sent(mock_request) + assert args == ('GET', f'{BASE_URL}/contacts/') + assert GHL_TEST_TOKEN not in args[1] + assert GHL_TEST_TOKEN not in str(kwargs['params']) + + @patch(_CLIENT) + def test_an_echoed_token_is_redacted_out_of_the_error_message(self, mock_request): + mock_request.return_value = _resp( + 401, + json_data={'message': f'Invalid token {GHL_TEST_TOKEN}', 'statusCode': 401}, + text=f'Invalid token {GHL_TEST_TOKEN}', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert GHL_TEST_TOKEN not in str(exc.value) + assert GHL_TEST_TOKEN not in exc.value.message + assert '[redacted]' in exc.value.message + + @patch(_CLIENT) + def test_a_transport_failure_message_carries_no_token(self, mock_request): + mock_request.side_effect = requests.ConnectionError(f'dns failure while sending {GHL_TEST_TOKEN}') + with pytest.raises(ValueError, match='GoHighLevel request failed') as exc: + _instance().contact_list({}) + + assert GHL_TEST_TOKEN not in str(exc.value) + + @patch(_CLIENT) + def test_a_non_json_body_message_carries_no_token(self, mock_request): + mock_request.return_value = _resp(200, json_data=None, text=f'{GHL_TEST_TOKEN}', content=b'') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'response was not JSON' in exc.value.message + assert GHL_TEST_TOKEN not in exc.value.message + + @patch(_CLIENT) + def test_the_raw_request_tool_redacts_the_token_out_of_a_success_payload(self, mock_request): + """The only tool that returns an unprojected body, so the only one that can echo it back.""" + mock_request.return_value = _ok({'echo': {'authorization': f'Bearer {GHL_TEST_TOKEN}'}}) + result = _instance().request({'method': 'GET', 'path': '/contacts/'}) + assert GHL_TEST_TOKEN not in json.dumps(result) + + +# --------------------------------------------------------------------------- +# Rate limiting +# --------------------------------------------------------------------------- + + +class TestRateLimit: + @patch('time.sleep') + @patch(_CLIENT) + def test_429_is_retried_and_sleeps_one_burst_window(self, mock_request, mock_sleep): + limited = _resp(429, json_data=BODY_429, headers=BURST_429_HEADERS, text='Too Many Requests') + mock_request.side_effect = [limited, limited, _ok({'contacts': []})] + + _instance().contact_list({}) + + assert mock_request.call_count == 3 + assert mock_sleep.call_count == 2 + # 10000 milliseconds, straight from x-ratelimit-interval-milliseconds. + assert [call[0][0] for call in mock_sleep.call_args_list] == [10.0, 10.0] + + @patch('time.sleep') + @patch(_CLIENT) + def test_three_attempts_is_the_ceiling(self, mock_request, mock_sleep): + limited = _resp(429, json_data=BODY_429, headers=BURST_429_HEADERS, text='Too Many Requests') + mock_request.return_value = limited + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 429 + assert mock_request.call_count == 3 + assert mock_sleep.call_count == 2 + + @patch('time.sleep') + @patch(_CLIENT) + def test_the_daily_reset_duration_is_never_a_sleep_argument(self, mock_request, mock_sleep): + """x-ratelimit-daily-reset is a duration of about 24h. Sleeping on it hangs the run.""" + limited = _resp(429, json_data=BODY_429, headers=BURST_429_HEADERS, text='Too Many Requests') + mock_request.side_effect = [limited, _ok({'contacts': []})] + + _instance().contact_list({}) + + slept = [call[0][0] for call in mock_sleep.call_args_list] + assert slept == [10.0] + for value in DAILY_RESET_VALUES: + assert value not in slept + assert value / 1000.0 not in slept + + @patch('time.sleep') + @patch(_CLIENT) + def test_403_is_never_retried(self, mock_request, mock_sleep): + """GoHighLevel answers 403 for a missing locationId and for agency scope. Both are permanent.""" + refused = _resp( + 403, + json_data={'message': 'Forbidden resource', 'error': 'Forbidden', 'statusCode': 403}, + headers=BURST_429_HEADERS, + text='Forbidden resource', + ) + mock_request.return_value = refused + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 403 + assert mock_request.call_count == 1 + assert mock_sleep.call_count == 0 + + @patch('time.sleep') + @patch(_CLIENT) + def test_an_exhausted_daily_bucket_fails_fast(self, mock_request, mock_sleep): + headers = dict(BURST_429_HEADERS, **{'x-ratelimit-daily-remaining': '0'}) + mock_request.return_value = _resp(429, json_data=BODY_429, headers=headers, text='Too Many Requests') + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 429 + assert mock_request.call_count == 1 + assert mock_sleep.call_count == 0 + assert 'daily request budget' in exc.value.message + + @patch('time.sleep') + @patch(_CLIENT) + def test_an_exhausted_daily_bucket_is_reported_on_any_status(self, mock_request, mock_sleep): + headers = dict(BURST_429_HEADERS, **{'x-ratelimit-daily-remaining': '0'}) + mock_request.return_value = _resp( + 404, + json_data={'error': 'Contact with id X not found', 'status': 400}, + headers=headers, + text='not found', + ) + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_get({'contactId': RECORD_ID}) + + assert 'daily request budget' in exc.value.message + assert mock_sleep.call_count == 0 + + @patch('time.sleep') + @patch(_CLIENT) + def test_the_burst_hint_stays_off_errors_that_are_not_rate_limits(self, mock_request, mock_sleep): + """The burst header rides on every response, including every 404 and every 422.""" + mock_request.return_value = _resp( + 404, + json_data={'error': 'Contact with id X not found', 'status': 400}, + headers=BURST_429_HEADERS, + text='not found', + ) + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_get({'contactId': RECORD_ID}) + + assert 'burst window' not in exc.value.message + assert mock_sleep.call_count == 0 + + @patch('time.sleep') + @patch(_CLIENT) + @pytest.mark.parametrize('bad_value', ['malformed', 'NaN', 'Inf', '']) + def test_a_malformed_burst_window_falls_back_to_exponential_backoff(self, mock_request, mock_sleep, bad_value): + headers = dict(BURST_429_HEADERS, **{'x-ratelimit-interval-milliseconds': bad_value}) + limited = _resp(429, json_data=BODY_429, headers=headers, text='Too Many Requests') + mock_request.side_effect = [limited, _ok({'contacts': []})] + + _instance().contact_list({}) + + assert mock_sleep.call_count == 1 + assert mock_sleep.call_args[0][0] >= 2.0 + + @patch('time.sleep') + @patch(_CLIENT) + def test_a_window_longer_than_the_timeout_fails_fast(self, mock_request, mock_sleep): + headers = dict(BURST_429_HEADERS, **{'x-ratelimit-interval-milliseconds': '3600000'}) + mock_request.return_value = _resp(429, json_data=BODY_429, headers=headers, text='Too Many Requests') + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 429 + assert mock_request.call_count == 1 + assert mock_sleep.call_count == 0 + + +# --------------------------------------------------------------------------- +# Errors +# +# Five body shapes, all observed live on one trial account. The fifth is the captured +# 429, which carries no "error" key at all. +# --------------------------------------------------------------------------- + + +class TestErrors: + @patch(_CLIENT) + def test_shape_1_message_error_status_code(self, mock_request): + mock_request.return_value = _resp( + 403, + json_data={'message': 'Forbidden resource', 'error': 'Forbidden', 'statusCode': 403}, + text='Forbidden resource', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 403 + assert 'Forbidden resource' in exc.value.message + assert 'agency-scoped endpoint' in exc.value.message + + @patch(_CLIENT) + def test_shape_2_message_as_a_list_of_strings(self, mock_request): + mock_request.return_value = _resp( + 422, + json_data={ + 'message': ['companyId must be a string', 'property locationId should not exist'], + 'error': 'Unprocessable Entity', + 'statusCode': 422, + }, + text='unprocessable', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 422 + # Joined, not str()-ed: a Python list repr is unreadable to an agent. + assert exc.value.message == 'companyId must be a string, property locationId should not exist' + + @patch(_CLIENT) + def test_shape_3_error_with_a_status_key(self, mock_request): + mock_request.return_value = _resp( + 400, + json_data={'error': f'Contact with id {RECORD_ID} not found', 'status': 400}, + text='not found', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_get({'contactId': RECORD_ID}) + + assert exc.value.status_code == 400 + assert exc.value.message == f'Contact with id {RECORD_ID} not found' + + @patch(_CLIENT) + def test_shape_4_error_alone(self, mock_request): + mock_request.return_value = _resp(400, json_data={'error': 'Task with id search not found'}, text='not found') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 400 + assert exc.value.message == 'Task with id search not found' + + @patch('time.sleep') + @patch(_CLIENT) + def test_shape_5_the_captured_429_which_has_no_error_key(self, mock_request, mock_sleep): + """live-findings section 10: {"statusCode": 429, "message": "Too Many Requests"}.""" + mock_request.return_value = _resp(429, json_data=BODY_429, headers=BURST_429_HEADERS, text='Too Many Requests') + + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.status_code == 429 + assert exc.value.message.startswith('Too Many Requests') + assert 'the burst window allows 25 requests per 10s' in exc.value.message + + @patch(_CLIENT) + def test_message_as_a_string_wins_over_error(self, mock_request): + mock_request.return_value = _resp( + 400, + json_data={'message': 'the readable one', 'error': 'the terse one'}, + text='boom', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.message == 'the readable one' + + @patch(_CLIENT) + def test_the_http_status_wins_over_a_disagreeing_body_status_code(self, mock_request): + """POST /contacts/ with only a locationId answers HTTP 400 with a body claiming 422.""" + mock_request.return_value = _resp( + 400, + json_data={'message': 'Bad Request', 'error': 'Bad Request', 'statusCode': 422}, + text='Bad Request', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_create({'firstName': 'Ada'}) + + assert exc.value.status_code == 400 + + @patch(_CLIENT) + def test_a_404_with_an_empty_body_still_produces_a_message(self, mock_request): + mock_request.return_value = _resp(404, json_data=None, text='', content=b'', reason='Not Found') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_get({'contactId': RECORD_ID}) + + assert exc.value.status_code == 404 + assert exc.value.message == 'Not Found' + + @patch(_CLIENT) + def test_a_trace_id_is_carried_onto_the_exception(self, mock_request): + mock_request.return_value = _resp( + 422, + json_data={'message': 'bad', 'statusCode': 422, 'traceId': 'trace-abc-123'}, + text='bad', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.trace_id == 'trace-abc-123' + assert '[traceId: trace-abc-123]' in exc.value.message + + @patch(_CLIENT) + def test_a_missing_location_403_names_the_real_cause(self, mock_request): + mock_request.return_value = _resp( + 403, + json_data={'statusCode': 403, 'message': 'The token does not have access to this location.'}, + text='forbidden', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'locationId is missing or mismatched' in exc.value.message + + @patch(_CLIENT) + def test_a_scope_401_names_the_real_cause(self, mock_request): + mock_request.return_value = _resp( + 401, + json_data={'message': 'The token is not authorized for this scope.'}, + text='unauthorized', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'missing a scope this operation requires' in exc.value.message + + # -- the half of an error body that is not prose ----------------------- + + @patch(_CLIENT) + def test_a_conflict_keeps_the_id_it_names(self, mock_request): + """Live: POST /conversations/ for a contact that already has one answers 400 with its id. + + Creating a contact through the API creates its conversation too, so this is the + common answer rather than an edge case, and the id is the only way forward. + """ + mock_request.return_value = _resp( + 400, + json_data={ + 'message': 'Conversation already exists', + 'canonicalCode': 'CONVERSATIONS_CONVERSATION_ALREADY_EXISTS', + 'conversationId': 'hzB0N9v0FmRoW3B2ZM4d', + }, + text='Conversation already exists', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().conversation_create({'contactId': RECORD_ID}) + + assert exc.value.status_code == 400 + assert 'Conversation already exists' in exc.value.message + assert 'conversationId: hzB0N9v0FmRoW3B2ZM4d' in exc.value.message + assert 'canonicalCode: CONVERSATIONS_CONVERSATION_ALREADY_EXISTS' in exc.value.message + assert exc.value.details == {'conversationId': 'hzB0N9v0FmRoW3B2ZM4d'} + assert exc.value.canonical_code == 'CONVERSATIONS_CONVERSATION_ALREADY_EXISTS' + + @patch(_CLIENT) + def test_any_id_bearing_field_is_surfaced_not_just_the_conversation_one(self, mock_request): + """The rule is any key that is id or ends in Id, not a per-endpoint special case.""" + mock_request.return_value = _resp( + 409, + json_data={'message': 'already exists', 'contactId': 'c-1', 'id': 'r-9', 'unrelated': 'x'}, + text='conflict', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.details == {'contactId': 'c-1', 'id': 'r-9'} + assert 'unrelated' not in exc.value.message + + @patch(_CLIENT) + def test_the_trace_id_is_not_repeated_among_the_details(self, mock_request): + """The traceId ends in Id but has its own slot, so it must not be reported twice.""" + mock_request.return_value = _resp( + 400, + json_data={'message': 'boom', 'traceId': 'trace-abc-123', 'conversationId': 'conv-1'}, + text='boom', + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.details == {'conversationId': 'conv-1'} + assert exc.value.message.count('trace-abc-123') == 1 + + @patch(_CLIENT) + def test_an_error_body_with_neither_extra_reads_exactly_as_before(self, mock_request): + mock_request.return_value = _resp(400, json_data={'error': 'Task with id search not found'}, text='nope') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert exc.value.message == 'Task with id search not found' + assert exc.value.details == {} + assert exc.value.canonical_code == '' + + # -- a 401 is not always a credential problem -------------------------- + + @patch(_CLIENT) + def test_a_gateway_timeout_wearing_a_401_is_not_called_a_credential_problem(self, mock_request): + """Live capture: HTTP 401 with the body text "Command timed out".""" + mock_request.return_value = _resp(401, json_data={'message': 'Command timed out'}, text='Command timed out') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'upstream timeout' in exc.value.message + assert 'retry the call' in exc.value.message + assert 'revoked' not in exc.value.message + assert 'rotated Private Integration Token' not in exc.value.message + + @patch(_CLIENT) + def test_a_401_naming_something_else_entirely_does_not_blame_the_token(self, mock_request): + mock_request.return_value = _resp(401, json_data={'message': 'Bad Gateway'}, text='Bad Gateway') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'this 401 names no credential' in exc.value.message + assert 'revoked' not in exc.value.message + + @patch(_CLIENT) + def test_a_real_credential_401_still_gets_the_rotation_warning(self, mock_request): + """The tightening must not cost the guidance on the 401 that really is the token.""" + mock_request.return_value = _resp(401, json_data={'message': 'Invalid JWT'}, text='Invalid JWT') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'may be invalid, expired or revoked' in exc.value.message + assert 'exactly 7 days' in exc.value.message + + @patch(_CLIENT) + def test_a_401_with_an_empty_body_is_still_treated_as_the_credential(self, mock_request): + mock_request.return_value = _resp(401, json_data=None, text='', content=b'', reason='Unauthorized') + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'may be invalid, expired or revoked' in exc.value.message + + @patch(_CLIENT) + def test_the_users_search_401_names_the_parameter_and_the_422_it_becomes(self, mock_request): + mock_request.return_value = _resp( + 401, json_data={'message': 'E01 - Unauthorized request'}, text='E01 - Unauthorized request' + ) + with pytest.raises(GoHighLevelAPIError) as exc: + _instance().contact_list({}) + + assert 'missing required parameter' in exc.value.message + assert 'answers 422' in exc.value.message + + +# --------------------------------------------------------------------------- +# Read-only mode +# --------------------------------------------------------------------------- + + +class TestReadOnly: + def test_write_tools_are_hidden_rather_than_merely_refused(self): + """An agent should never see a tool it can only ever fail on.""" + writable = _instance(tool_groups=ALL_GROUPS)._collect_tool_methods() + read_only = _instance(tool_groups=ALL_GROUPS, read_only=True)._collect_tool_methods() + + assert set(writable) - set(read_only) == _write_tool_names() + assert _write_tool_names(), 'the write stamp is what makes hiding possible' + + def test_reads_stay_published_in_read_only_mode(self): + published = _instance(tool_groups=ALL_GROUPS, read_only=True)._collect_tool_methods() + for name in ('contact_list', 'contact_get', 'calendar_list', 'conversation_search', 'opportunity_search'): + assert name in published + + @patch(_CLIENT) + def test_a_read_still_runs_in_read_only_mode(self, mock_request): + mock_request.return_value = _ok({'contact': {'id': RECORD_ID, 'firstName': 'Ada'}}) + assert _dispatch(_instance(read_only=True), 'contact_get', {'contactId': RECORD_ID})['id'] == RECORD_ID + + @pytest.mark.parametrize( + 'tool,args', + [ + ('contact_create', {'firstName': 'Ada'}), + ('contact_update', {'contactId': RECORD_ID, 'firstName': 'Ada'}), + ('contact_delete', {'contactId': RECORD_ID}), + ('contact_tags_add', {'contactId': RECORD_ID, 'tags': ['vip']}), + ('contact_notes_create', {'contactId': RECORD_ID, 'body': 'called'}), + ('contact_tasks_delete', {'contactId': RECORD_ID, 'taskId': RECORD_ID}), + ('opportunity_delete', {'opportunityId': RECORD_ID}), + ('conversation_delete', {'conversationId': RECORD_ID}), + ('message_schedule_cancel', {'messageId': RECORD_ID}), + ('calendar_delete', {'calendarId': RECORD_ID}), + ('calendar_group_delete', {'groupId': RECORD_ID}), + ('appointment_delete', {'eventId': RECORD_ID}), + ('appointment_notes_delete', {'appointmentId': RECORD_ID, 'noteId': RECORD_ID}), + ('business_delete', {'businessId': RECORD_ID}), + ('custom_field_delete', {'customFieldId': RECORD_ID}), + ('custom_value_delete', {'customValueId': RECORD_ID}), + ('location_tags_delete', {'tagId': RECORD_ID}), + ], + ) + @patch(_CLIENT) + def test_require_write_still_guards_a_direct_call(self, mock_request, tool, args): + """Defence in depth: hiding is the agent-facing behaviour, this is the gate behind it.""" + inst = _instance(tool_groups=ALL_GROUPS, read_only=True) + with pytest.raises(ValueError, match='read-only mode'): + getattr(inst, tool)(args) + mock_request.assert_not_called() + + @patch(_CLIENT) + def test_the_raw_request_tool_refuses_a_write_method(self, mock_request): + """It carries no write stamp, so it stays published and is gated at invoke instead.""" + inst = _instance(read_only=True) + assert RAW_REQUEST_TOOL in inst._collect_tool_methods() + with pytest.raises(ValueError, match='read-only mode'): + inst.request({'method': 'POST', 'path': '/contacts/', 'body': {}}) + mock_request.assert_not_called() + + @patch(_CLIENT) + def test_the_raw_request_tool_still_allows_get(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + assert _instance(read_only=True).request({'method': 'GET', 'path': '/contacts/'}) == {'contacts': []} + + +# --------------------------------------------------------------------------- +# Tool-group gating +# --------------------------------------------------------------------------- + + +class TestGroupGating: + def test_a_gated_tool_is_invisible_to_the_descriptor_list(self): + published = _instance()._collect_tool_methods() + # businesses is not in DEFAULT_GROUPS. + assert 'business_list' not in published + assert 'business_create' not in published + assert 'contact_list' in published + + def test_a_gated_tool_is_refused_on_invoke(self): + with pytest.raises(LookupError, match='business_list'): + _dispatch(_instance(), 'business_list', {}) + + def test_enabling_the_group_publishes_it(self): + published = _instance(tool_groups=normalize_groups(['businesses']))._collect_tool_methods() + assert 'business_list' in published + assert 'contact_list' not in published + + @pytest.mark.parametrize('sentinel', ['all', '*', ['all'], ['*'], 'ALL']) + def test_the_all_sentinels_publish_everything(self, sentinel): + assert normalize_groups(sentinel) == ALL_GROUPS + + def test_all_publishes_the_whole_surface(self): + """Guards against a mixin silently dropping out of the composition.""" + published = _instance(tool_groups=normalize_groups('all'))._collect_tool_methods() + # 101 typed tools plus the raw request escape hatch. + assert len(published) == 102 + + def test_an_unknown_group_name_is_dropped(self): + assert normalize_groups(['contacts', 'nope']) == frozenset({'contacts'}) + assert normalize_groups('contacts, nope') == frozenset({'contacts'}) + + @pytest.mark.parametrize('empty', [None, [], '', ' ', 42, ['nope'], [''], {}]) + def test_empty_or_wholly_unknown_falls_back_to_the_defaults(self, empty): + assert normalize_groups(empty) == DEFAULT_GROUPS + + def test_the_raw_request_tool_follows_its_own_switch(self): + assert RAW_REQUEST_TOOL in _instance()._collect_tool_methods() + assert RAW_REQUEST_TOOL not in _instance(allow_raw_request=False)._collect_tool_methods() + + def test_every_tool_carries_a_known_group(self): + """Closes the hole where an untagged tool would publish unconditionally.""" + untagged = [ + name + for name, attr in _tool_attrs() + if name != RAW_REQUEST_TOOL and getattr(attr, '__gohighlevel_group__', None) not in ALL_GROUPS + ] + assert untagged == [] + + def test_the_decorator_rejects_an_unknown_group_at_import_time(self): + with pytest.raises(ValueError, match='unknown group'): + gohighlevel_tool(group='not_a_group') + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + + +def _contact_page(count, *, cursor_key='startAfter'): + """A page of contacts, each carrying the cursor its route puts on the record.""" + records = [] + for index in range(count): + record = {'id': f'{RECORD_ID}{index}', 'firstName': f'Contact {index}'} + record[cursor_key] = [1700000000000 + index, f'{RECORD_ID}{index}'] + records.append(record) + return records + + +class TestPagination: + @patch(_CLIENT) + def test_a_full_page_reports_has_more_and_a_cursor(self, mock_request): + mock_request.return_value = _ok({'contacts': _contact_page(2)}) + result = _instance().contact_list({'limit': 2}) + + assert result['count'] == 2 + assert result['has_more'] is True + assert result['next'] == {'startAfter': 1700000000001, 'startAfterId': f'{RECORD_ID}1'} + + @patch(_CLIENT) + def test_a_short_page_reports_no_more_and_a_null_next(self, mock_request): + """The cursor rides on the record, so the last page carries one too. It must not be reported.""" + mock_request.return_value = _ok({'contacts': _contact_page(1)}) + result = _instance().contact_list({'limit': 2}) + + assert result['count'] == 1 + assert result['has_more'] is False + assert result['next'] is None + + @patch(_CLIENT) + def test_an_empty_page_reports_no_more(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + result = _instance().contact_list({'limit': 2}) + + assert result == {'items': [], 'count': 0, 'total': None, 'next': None, 'has_more': False} + + def test_next_is_null_whenever_has_more_is_false(self): + assert paginated([{'id': 1}], next_cursor='abc', requested_limit=10)['next'] is None + assert paginated([{'id': 1}], next_cursor='abc', requested_limit=10)['has_more'] is False + assert paginated([{'id': 1}], next_cursor='abc', has_more=False)['next'] is None + assert paginated([], next_cursor='abc', requested_limit=1)['next'] is None + + @patch(_CLIENT) + def test_contact_list_pages_on_start_after_and_start_after_id(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_list({'startAfter': 1700000000000, 'startAfterId': RECORD_ID, 'limit': 50}) + + params = mock_request.call_args[1]['params'] + assert params['startAfter'] == 1700000000000 + assert params['startAfterId'] == RECORD_ID + assert params['limit'] == 50 + assert params['locationId'] == LOCATION_ID + assert 'searchAfter' not in params + assert 'pageLimit' not in params + + @patch(_CLIENT) + def test_contact_search_pages_on_search_after_and_calls_the_size_page_limit(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_search({'searchAfter': [1700000000000, RECORD_ID], 'limit': 25}) + + body = mock_request.call_args[1]['json'] + assert body['searchAfter'] == [1700000000000, RECORD_ID] + assert body['pageLimit'] == 25 + assert body['locationId'] == LOCATION_ID + # This endpoint does not accept "limit" and ignores "startAfter". + assert 'limit' not in body + assert 'startAfter' not in body + + @patch(_CLIENT) + def test_contact_search_reads_the_cursor_off_the_last_record(self, mock_request): + mock_request.return_value = _ok({'contacts': _contact_page(2, cursor_key='searchAfter')}) + result = _instance().contact_search({'limit': 2}) + + assert result['has_more'] is True + assert result['next'] == [1700000000001, f'{RECORD_ID}1'] + + @patch(_CLIENT) + def test_opportunity_search_sends_the_sub_account_as_snake_case_location_id(self, mock_request): + """The only endpoint on this surface that spells it that way. camelCase is a live 422.""" + mock_request.return_value = _ok({'opportunities': []}) + _instance().opportunity_search({'limit': 10}) + + params = mock_request.call_args[1]['params'] + assert params['location_id'] == LOCATION_ID + assert 'locationId' not in params + + @patch(_CLIENT) + def test_every_other_endpoint_spells_it_camel_case(self, mock_request): + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_list({}) + assert 'location_id' not in mock_request.call_args[1]['params'] + assert mock_request.call_args[1]['params']['locationId'] == LOCATION_ID + + @patch(_CLIENT) + def test_the_page_size_is_clamped_to_what_the_endpoint_documents(self, mock_request): + mock_request.return_value = _ok({'notes': []}) + # Appointment notes cap at 20, and a larger value is a 400 rather than a clamp. + _instance(tool_groups=ALL_GROUPS).appointment_notes_list({'appointmentId': RECORD_ID, 'limit': 500}) + assert mock_request.call_args[1]['params']['limit'] == 20 + + mock_request.return_value = _ok({'contacts': []}) + _instance().contact_list({'limit': 5000}) + assert mock_request.call_args[1]['params']['limit'] == 100 + + @patch(_CLIENT) + def test_an_offset_style_reports_the_next_offset_and_stops_on_a_short_page(self, mock_request): + mock_request.return_value = _ok({'contacts': [{'id': RECORD_ID}]}) + result = _instance().contact_list_by_business({'businessId': RECORD_ID, 'limit': 1, 'skip': 4}) + + # Both values are stringified: this endpoint declares them as strings. + params = mock_request.call_args[1]['params'] + assert params['limit'] == '1' + assert params['skip'] == '4' + assert result['has_more'] is True + assert result['next'] == 5 + + mock_request.return_value = _ok({'contacts': []}) + result = _instance().contact_list_by_business({'businessId': RECORD_ID, 'limit': 2}) + assert result['has_more'] is False + assert result['next'] is None + + +# --------------------------------------------------------------------------- +# Published descriptors +# --------------------------------------------------------------------------- + + +class TestSchemas: + def test_every_published_tool_has_a_description(self): + missing = [name for name, attr in _tool_attrs() if not attr.__tool_meta__.get('description')] + assert missing == [] + + def test_every_published_tool_has_an_output_schema(self): + # The raw request tool is the one exemption, and it is a real one: it returns an + # undeclared response body straight from whatever endpoint the agent named, so + # there is no shape to declare. + missing = [ + name + for name, attr in _tool_attrs() + if name != RAW_REQUEST_TOOL and not attr.__tool_meta__.get('output_schema') + ] + assert missing == [] + + def test_no_description_contains_an_em_dash(self): + offenders = [] + for name, attr in _tool_attrs(): + meta = attr.__tool_meta__ + texts = [meta.get('description') or ''] + texts.extend(_descriptions(meta.get('input_schema') or {})) + texts.extend(_descriptions(meta.get('output_schema') or {})) + if any(EM_DASH in text for text in texts): + offenders.append(name) + assert offenders == [] + + def test_every_input_schema_is_a_json_object_schema(self): + for name, attr in _tool_attrs(): + schema = attr.__tool_meta__.get('input_schema') + assert isinstance(schema, dict), name + assert schema.get('type') == 'object', name + assert isinstance(schema.get('properties'), dict), name + for required in schema.get('required', []): + assert required in schema['properties'], f'{name}: {required} is required but not declared' + + def test_every_paged_tool_is_registered_in_the_page_size_tables(self): + """page_limit() raises for an unregistered tool, so this is a first-invoke failure.""" + assert check_page_limits({name for name, _attr in _tool_attrs()}, _paged_tool_names()) == [] + + def test_every_list_output_schema_declares_the_pagination_envelope(self): + for name in _paged_tool_names(): + output = getattr(IInstance, name).__tool_meta__['output_schema'] + assert set(output.get('required', [])) == {'items', 'count', 'has_more'}, name + assert set(output['properties']) >= {'items', 'count', 'total', 'next', 'has_more'}, name + + def test_both_tag_tools_declare_the_sub_account_side_effect(self): + """Tagging with an unknown name defines that tag on the whole sub-account. + + Live-confirmed, undocumented by GoHighLevel, and invisible from the contact: only + location_tags_delete removes the definition that contact_tags_add creates. An agent + looping over contacts with generated names grows the account tag list permanently, so + both halves have to be in the descriptions the agent actually reads. + """ + for name in ('contact_tags_add', 'contact_tags_remove'): + description = getattr(IInstance, name).__tool_meta__['description'] + assert 'location_tags_delete' in description, name + assert 'sub-account' in description, name + + +# --------------------------------------------------------------------------- +# Argument normalisation +# +# These cover the two stubs tightened above, so the tightening is load-bearing rather +# than decorative. +# --------------------------------------------------------------------------- + + +class TestArgNormalisation: + @patch(_CLIENT) + def test_the_input_envelope_delivery_path_reaches_the_tool(self, mock_request): + mock_request.return_value = _ok({'contact': {'id': RECORD_ID, 'firstName': 'Ada'}}) + result = _instance().contact_get({'input': {'contactId': RECORD_ID}, 'security_context': {'user': 'x'}}) + + assert result['id'] == RECORD_ID + assert mock_request.call_args[0][1] == f'{BASE_URL}/contacts/{RECORD_ID}' + + def test_a_top_level_key_beside_the_envelope_wins(self): + assert stub_normalize_tool_input({'input': {'a': 1}, 'a': 2}) == {'a': 2} + + def test_security_context_is_stripped_before_the_schema_check(self): + assert stub_normalize_tool_input({'contactId': RECORD_ID, 'security_context': {}}) == {'contactId': RECORD_ID} + + def test_a_json_string_payload_is_parsed(self): + assert stub_normalize_tool_input(json.dumps({'contactId': RECORD_ID})) == {'contactId': RECORD_ID} + + @pytest.mark.parametrize('value', [None, 7, [1, 2], 'not json']) + def test_an_uncoercible_payload_becomes_an_empty_dict(self, value): + assert stub_normalize_tool_input(value) == {} + + @pytest.mark.parametrize('value', [3.7, True, [1], {'a': 1}, None]) + def test_require_int_rejects_rather_than_coerces(self, value): + with pytest.raises(ValueError): + stub_require_int({'limit': value}, 'limit') + + def test_require_int_accepts_an_int_and_a_numeric_string(self): + assert stub_require_int({'limit': 3}, 'limit') == 3 + assert stub_require_int({'limit': '3'}, 'limit') == 3 + + def test_an_undeclared_parameter_is_rejected_locally(self): + """GoHighLevel answers 422 "property X should not exist", which reads as a server fault.""" + with pytest.raises(ValueError, match='unknown parameter'): + _instance().contact_get({'contactId': RECORD_ID, 'include_notes': True}) + + def test_a_tool_name_typo_in_args_is_a_hard_error(self): + with pytest.raises(ValueError, match='no such tool method'): + _instance()._args({}, 'contact_lst') diff --git a/nodes/test/tool_gohighlevel/test_tools.py b/nodes/test/tool_gohighlevel/test_tools.py new file mode 100644 index 000000000..a6a333aae --- /dev/null +++ b/nodes/test/tool_gohighlevel/test_tools.py @@ -0,0 +1,1140 @@ +# ============================================================================= +# 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. +# ============================================================================= + +""" +Live integration tests for tool_gohighlevel. + +Calls the real GoHighLevel v2 API at https://services.leadconnectorhq.com through the +node's own tool methods, so the request shapes, the response cleaners, the pagination +envelope and the error mapping are all exercised against what GoHighLevel actually +sends rather than against a stub. That is the point of this file: the stubbed suite can +only prove the node is self-consistent, and every disagreement between the two lives +here. + + export GHL_PIT= + export GHL_LOCATION_ID= + export GHL_ALLOW_WRITES=1 # optional, enables the create/delete lifecycles + pytest nodes/test/tool_gohighlevel/test_tools.py -v + +Everything skips without GHL_PIT and GHL_LOCATION_ID. The write tests skip separately +without GHL_ALLOW_WRITES=1, and every record they create is removed in a finally block. + +Rate limits are measured rather than assumed: a sub-account Private Integration Token +gets 25 requests per 10 seconds (the 429 lands on request 26) and 10,000 per day, not +the 100 per 10s published for marketplace apps. Every request this file makes goes +through one pacer that spaces them out, so the suite cannot trip the limiter it is +here to observe. Do not run it with pytest-xdist: parallel workers each get their own +pacer and share one budget. +""" + +from __future__ import annotations + +import os +import sys +import time +import types +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest +import requests + +# --------------------------------------------------------------------------- +# Import scaffolding +# +# The node imports ``rocketlib`` (engine-only, needs the native engLib) and +# ``ai.common.utils`` (which imports rocketlib in turn), so neither is available when +# this file runs standalone. rocketlib is stubbed the way the offline node suites stub +# it, and ``ai.common.utils`` is loaded from its real source file on top of that stub, +# because the argument validation in it is part of what this suite is checking. Both +# stubs, and the ``tool_gohighlevel`` package alias, are removed again once the node is +# imported: this module shares a pytest session with every other node's tests and a +# half-built ``ai.common`` left in sys.modules would shadow the real package for +# whoever imports it next. +# --------------------------------------------------------------------------- + +_NODE_DIR = Path(__file__).resolve().parents[2] / 'src' / 'nodes' / 'tool_gohighlevel' +_TOOL_ARGS = ( + Path(__file__).resolve().parents[3] / 'packages' / 'ai' / 'src' / 'ai' / 'common' / 'utils' / 'tool_args.py' +) +_MISSING = object() +_SCAFFOLD = ('rocketlib', 'ai', 'ai.common', 'ai.common.utils', 'ai.common.config', 'tool_gohighlevel') + + +def _tool_function(**meta): + """Stand-in for rocketlib's decorator that keeps the metadata the node reads back.""" + + def wrap(fn): + fn.__tool_meta__ = meta + return fn + + return wrap + + +def _install_scaffolding() -> None: + """Put the engine-only modules the node imports into sys.modules.""" + rocketlib = types.ModuleType('rocketlib') + rocketlib.IInstanceBase = type('IInstanceBase', (), {}) + rocketlib.IGlobalBase = type('IGlobalBase', (), {}) + rocketlib.tool_function = _tool_function + rocketlib.OPEN_MODE = type('OPEN_MODE', (), {'CONFIG': 'config'}) + for name in ('debug', 'error', 'warning'): + setattr(rocketlib, name, lambda *a, **k: None) + sys.modules['rocketlib'] = rocketlib + + for name in ('ai', 'ai.common', 'ai.common.config'): + sys.modules[name] = types.ModuleType(name) + sys.modules['ai.common.config'].Config = type('Config', (), {'getNodeConfig': staticmethod(lambda *a, **k: {})}) + + # The real validators, not a passthrough: _args() rejecting an undeclared parameter + # is node behaviour, and a stub would quietly stop testing it. + import importlib.util + + spec = importlib.util.spec_from_file_location('ai.common.utils', _TOOL_ARGS) + utils = importlib.util.module_from_spec(spec) + sys.modules['ai.common.utils'] = utils + spec.loader.exec_module(utils) + + package = types.ModuleType('tool_gohighlevel') + package.__path__ = [str(_NODE_DIR)] + sys.modules['tool_gohighlevel'] = package + + +_ORIGINAL = {name: sys.modules.get(name, _MISSING) for name in _SCAFFOLD} +try: + _install_scaffolding() + from tool_gohighlevel import gohighlevel_client as ghl # noqa: E402 + from tool_gohighlevel.IGlobal import IGlobal # noqa: E402 + from tool_gohighlevel.IInstance import IInstance # noqa: E402 + from tool_gohighlevel.tool_groups import ALL_GROUPS # noqa: E402 +finally: + for _name, _module in _ORIGINAL.items(): + if _module is _MISSING: + sys.modules.pop(_name, None) + else: + sys.modules[_name] = _module + + +# --------------------------------------------------------------------------- +# Credentials and gates +# --------------------------------------------------------------------------- + +TOKEN = os.getenv('GHL_PIT', '') +LOCATION = os.getenv('GHL_LOCATION_ID', '') +ALLOW_WRITES = os.getenv('GHL_ALLOW_WRITES') == '1' + +pytestmark = pytest.mark.skipif( + not TOKEN or not LOCATION, + reason='GHL_PIT and GHL_LOCATION_ID must both be set', +) + +writes = pytest.mark.skipif( + not ALLOW_WRITES, + reason='GHL_ALLOW_WRITES=1 must be set: these tests create, change and delete real records', +) + +#: Obviously fake, so secret scanners have nothing to find. Used for the 401 probe. +BAD_TOKEN = 'pit-gohighlevel-live-suite-not-a-real-token' + +#: Suffix on every record this suite creates, so a run that dies between create and +#: cleanup leaves something searchable behind rather than something anonymous. +UID = uuid.uuid4().hex[:8] + + +# --------------------------------------------------------------------------- +# Rate-limit pacing +# +# Measured on this credential: x-ratelimit-max 25 with +# x-ratelimit-interval-milliseconds 10000, and a deliberately fired burst put the 429 +# on request 26. That is 2.5 requests per second, so every request the suite makes is +# spaced 0.5s apart, serially. The pacer wraps the ``requests`` module reference inside +# the client rather than the client functions themselves, so retries, redirects and the +# raw request tool are all paced too and nothing in the node changes behaviour. +# --------------------------------------------------------------------------- + +MIN_INTERVAL = 0.5 + +#: Response headers of the most recent call, for the rate-limit assertions. +LAST_HEADERS: dict = {} + + +class _PacedRequests: + """The ``requests`` module, with a floor on the interval between requests.""" + + def __init__(self, real, min_interval: float): + self._real = real + self._min_interval = min_interval + self._last = 0.0 + self.count = 0 + + def request(self, method, url, **kwargs): + wait = self._min_interval - (time.monotonic() - self._last) + if wait > 0: + time.sleep(wait) + response = self._real.request(method, url, **kwargs) + self._last = time.monotonic() + self.count += 1 + LAST_HEADERS.clear() + LAST_HEADERS.update(response.headers) + return response + + def __getattr__(self, name): + return getattr(self._real, name) + + +PACER = _PacedRequests(requests, MIN_INTERVAL) +ghl.requests = PACER + + +# --------------------------------------------------------------------------- +# Mismatch log +# +# A live response that disagrees with what the node expects is the whole reason this +# file exists, and not every disagreement is worth failing a test over: the node is +# deliberately tolerant in places (unknown custom-field shapes, absent totals). Those +# are recorded here and printed at the end of the session, so a tolerated difference is +# still visible instead of being silently absorbed. +# --------------------------------------------------------------------------- + +MISMATCHES: list[str] = [] + +#: Tools GoHighLevel sent no record count for, so ``total`` is null. Expected on most of +#: this surface and kept apart from the mismatches so it cannot bury them. +NO_TOTAL: list[str] = [] + + +def note(text: str) -> None: + """Record a live response that differs from what the node expects.""" + if text not in MISMATCHES: + MISMATCHES.append(text) + + +@pytest.fixture(scope='session', autouse=True) +def _report_mismatches(): + yield + print(f'\n\n=== live/code mismatches ({len(MISMATCHES)}) ===') + for line in MISMATCHES or ['none']: + print(f' - {line}') + print(f'=== tools that reported no total ({len(NO_TOTAL)}) ===') + print(f' {", ".join(sorted(NO_TOTAL)) or "none"}') + print(f'=== HTTP requests made: {PACER.count} ===') + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def cleanup(action, label: str) -> None: + """Run a teardown delete, retrying a transport failure before giving up on it. + + A cleanup that dies on a read timeout leaks a record into the sub-account and turns + a run red for a reason that has nothing to do with the node, and this API times a + request out often enough to matter. Retried twice, then recorded rather than raised, + so the leak is named in the report and the run still reports on what it tested. + """ + for attempt in range(3): + try: + action() + return + except Exception as exc: # noqa: BLE001 - a teardown must not mask the test result + if attempt == 2: + note(f'cleanup failed for {label}: {exc}') + return + time.sleep(2) + + +def _node(token: str = '', location: str = '', read_only: bool = False) -> IInstance: + """A node instance wired to the live sub-account, with every tool group published.""" + instance = IInstance() + state = IGlobal() + state.token = token or TOKEN + state.location_id = location or LOCATION + state.read_only = read_only + state.tool_groups = ALL_GROUPS + state.allow_raw_request = True + instance.IGlobal = state + return instance + + +@pytest.fixture(scope='module') +def node() -> IInstance: + return _node() + + +@pytest.fixture(scope='module') +def company_id(node) -> str: + """Agency id, read off the sub-account: GET /users/search requires it and 401s without.""" + location = node.location_get({}) + value = location.get('companyId') + if not value: + pytest.skip('the sub-account record carries no companyId') + return value + + +@pytest.fixture(scope='module') +def seed_contact_id(node) -> str: + """Id of a contact that already exists, preferring one with an email on it. + + The email-based reads (contact_search by filter, contact_duplicate_check) need an + address to look for, and the seed data does not put one on every contact, so the + contact is chosen rather than taken off the top of the list. + """ + page = node.contact_list({'limit': 20}) + if not page['items']: + pytest.skip('the sub-account has no contacts to read') + with_email = [record for record in page['items'] if record.get('email')] + if not with_email: + note('no contact on the first page of contact_list carries an email, so the email reads were skipped') + return page['items'][0]['id'] + return with_email[0]['id'] + + +@pytest.fixture(scope='module') +def seed_conversation(node) -> dict: + page = node.conversation_search({'limit': 5}) + if not page['items']: + pytest.skip('the sub-account has no conversations to read') + return page['items'][0] + + +@pytest.fixture(scope='module') +def pipeline(node) -> dict: + page = node.pipeline_list({}) + if not page['items']: + pytest.skip('the sub-account has no opportunity pipeline') + return page['items'][0] + + +@pytest.fixture(scope='module') +def temp_contact(node) -> dict: + """A contact created for this run and deleted afterwards.""" + if not ALLOW_WRITES: + pytest.skip('GHL_ALLOW_WRITES=1 must be set') + contact = node.contact_create( + { + 'firstName': 'RocketRide', + 'lastName': f'Live {UID}', + 'email': f'rocketride-live-{UID}@example.com', + 'phone': '+15005550001', + 'source': 'rocketride live suite', + } + ) + try: + yield contact + finally: + cleanup(lambda: node.contact_delete({'contactId': contact['id']}), f'contact {contact["id"]}') + + +@pytest.fixture(scope='module') +def temp_calendar(node) -> dict: + """A calendar created for this run and deleted afterwards. + + A fresh sub-account has none, and the appointment tools cannot be exercised without + one, so the calendar group of tests builds its own subject rather than skipping. + """ + if not ALLOW_WRITES: + pytest.skip('GHL_ALLOW_WRITES=1 must be set') + calendar = node.calendar_create({'name': f'RocketRide live {UID}', 'slotDuration': 30}) + try: + yield calendar + finally: + cleanup(lambda: node.calendar_delete({'calendarId': calendar['id']}), f'calendar {calendar["id"]}') + + +@pytest.fixture(scope='module') +def temp_appointment(node, temp_calendar, temp_contact) -> dict: + """An appointment booked on the temporary calendar for the temporary contact.""" + start = datetime.now(timezone.utc).astimezone() + timedelta(days=2) + start = start.replace(minute=0, second=0, microsecond=0) + appointment = node.appointment_create( + { + 'calendarId': temp_calendar['id'], + 'contactId': temp_contact['id'], + 'startTime': start.isoformat(timespec='seconds'), + 'title': f'RocketRide live {UID}', + 'ignoreFreeSlotValidation': True, + 'toNotify': False, + } + ) + try: + yield appointment + finally: + cleanup(lambda: node.appointment_delete({'eventId': appointment['id']}), f'appointment {appointment["id"]}') + + +# --------------------------------------------------------------------------- +# Shared assertions +# --------------------------------------------------------------------------- + + +def check_page(result: dict, tool: str, requested_limit: int | None = None) -> list: + """Assert the paginated envelope this node returns, and hand back the records. + + The envelope is the node's own invention rather than anything GoHighLevel sends, so + an agent has nothing else to go on: count has to equal the number of records, and + next has to be null whenever has_more is false, or an agent that trusts either one + spends a request discovering an empty page. + """ + assert set(result) == {'items', 'count', 'total', 'next', 'has_more'}, tool + assert isinstance(result['items'], list), tool + assert result['count'] == len(result['items']), tool + assert isinstance(result['has_more'], bool), tool + if not result['has_more']: + assert result['next'] is None, f'{tool}: next is set while has_more is false' + if requested_limit is not None: + assert result['count'] <= requested_limit, f'{tool}: returned more records than the limit asked for' + if result['total'] is None and tool not in NO_TOTAL: + NO_TOTAL.append(tool) + return result['items'] + + +# --------------------------------------------------------------------------- +# Listing sweep +# --------------------------------------------------------------------------- + +#: Every listing tool that needs no id, with the arguments to call it with. Grouped by +#: tool group so a failure names the area rather than only the tool. +LISTINGS = [ + ('contacts', 'contact_list', {'limit': 5}), + ('contacts', 'contact_search', {'limit': 5}), + ('opportunities', 'opportunity_search', {'limit': 5}), + ('opportunities', 'opportunity_search_advanced', {'limit': 5}), + ('pipelines', 'pipeline_list', {}), + ('pipelines', 'lost_reason_list', {}), + ('conversations', 'conversation_search', {'limit': 5}), + ('messages', 'message_export', {'limit': 10}), + ('businesses', 'business_list', {'limit': 5}), + ('locations', 'location_tasks_search', {'limit': 5}), + ('location_tags', 'location_tags_list', {}), + ('custom_fields', 'custom_field_list', {}), + ('custom_values', 'custom_value_list', {}), + ('users', 'user_list_by_location', {}), + ('calendars', 'calendar_list', {}), + ('calendar_groups', 'calendar_group_list', {}), +] + + +class TestListings: + @pytest.mark.parametrize('group,tool,args', LISTINGS, ids=[row[1] for row in LISTINGS]) + def test_listing(self, node, group, tool, args): + result = getattr(node, tool)(args) + check_page(result, tool, args.get('limit')) + + def test_user_search_needs_the_company_id(self, node, company_id): + result = node.user_search({'limit': 5, 'companyId': company_id}) + users = check_page(result, 'user_search', 5) + assert users, 'a sub-account always has at least one user' + assert users[0].get('id') + + +# --------------------------------------------------------------------------- +# Reads that need an id +# --------------------------------------------------------------------------- + + +class TestContactReads: + def test_contact_get(self, node, seed_contact_id): + contact = node.contact_get({'contactId': seed_contact_id}) + assert contact['id'] == seed_contact_id + assert contact.get('locationId') == LOCATION + raw = contact.get('customFields') + if isinstance(raw, list) and raw: + note('contact_get: customFields came back as a list the node could not project, passed through raw') + + def test_contact_search_by_email_finds_the_same_contact(self, node, seed_contact_id): + contact = node.contact_get({'contactId': seed_contact_id}) + email = contact.get('email') + if not email: + pytest.skip('the seeded contact has no email to search on') + result = node.contact_search({'limit': 5, 'filters': [{'field': 'email', 'operator': 'eq', 'value': email}]}) + found = check_page(result, 'contact_search', 5) + assert seed_contact_id in [record['id'] for record in found] + + def test_contact_list_and_search_disagree_about_the_name_casing(self, node, seed_contact_id): + """Both casing variants are kept because the two routes put the cased name in different keys.""" + listed = node.contact_list({'limit': 100}) + record = next((row for row in listed['items'] if row['id'] == seed_contact_id), None) + if record is None: + pytest.skip('the seeded contact is not on the first page of contact_list') + searched = node.contact_search({'limit': 100}) + other = next((row for row in searched['items'] if row['id'] == seed_contact_id), None) + if other is None: + pytest.skip('the seeded contact is not on the first page of contact_search') + if record.get('firstName') != other.get('firstName'): + note( + 'contact_list and contact_search return different casing under firstName for the same contact, ' + 'which is why _CONTACT_READ_KEYS keeps firstNameRaw and firstNameLowerCase alongside it' + ) + + def test_contact_duplicate_check(self, node, seed_contact_id): + contact = node.contact_get({'contactId': seed_contact_id}) + email = contact.get('email') + if not email: + pytest.skip('the seeded contact has no email to check') + result = node.contact_duplicate_check({'email': email}) + assert result['found'] is True + assert result['contact'].get('id') == seed_contact_id + + def test_contact_duplicate_check_on_an_unused_address(self, node): + result = node.contact_duplicate_check({'email': f'nobody-{UID}@example.com'}) + assert result['found'] is False + + def test_contact_appointments_list(self, node, seed_contact_id): + result = node.contact_appointments_list({'contactId': seed_contact_id}) + check_page(result, 'contact_appointments_list') + assert result['next'] is None + + def test_contact_notes_list(self, node, seed_contact_id): + check_page(node.contact_notes_list({'contactId': seed_contact_id}), 'contact_notes_list') + + def test_contact_tasks_list(self, node, seed_contact_id): + check_page(node.contact_tasks_list({'contactId': seed_contact_id}), 'contact_tasks_list') + + def test_contact_list_by_business(self, node): + businesses = node.business_list({'limit': 5})['items'] + if not businesses: + pytest.skip('the sub-account has no business to list contacts for') + result = node.contact_list_by_business({'businessId': businesses[0]['id'], 'limit': 5}) + check_page(result, 'contact_list_by_business', 5) + + +class TestOtherReads: + def test_location_get(self, node): + location = node.location_get({}) + assert location['id'] == LOCATION + assert location.get('companyId') + + def test_location_tasks_search_ids_are_underscore_id(self, node): + tasks = node.location_tasks_search({'limit': 5})['items'] + if not tasks: + pytest.skip('the sub-account has no tasks') + assert '_id' in tasks[0], 'this route is documented to key tasks _id rather than id' + if 'id' in tasks[0]: + note('location_tasks_search returned both _id and id, where only _id was documented') + + def test_business_get(self, node): + businesses = node.business_list({'limit': 5})['items'] + if not businesses: + pytest.skip('the sub-account has no businesses') + business = node.business_get({'businessId': businesses[0]['id']}) + assert business['id'] == businesses[0]['id'] + + def test_pipeline_stages_carry_ids(self, node, pipeline): + stages = pipeline.get('stages') + assert isinstance(stages, list) and stages, 'a pipeline always has stages' + assert isinstance(stages[0], dict), 'the spec types stages as any[][]; live sends objects' + assert stages[0].get('id'), 'opportunity_create needs a pipelineStageId out of this' + + def test_opportunity_get(self, node): + found = node.opportunity_search({'limit': 5})['items'] + if not found: + pytest.skip('the sub-account has no opportunities') + opportunity = node.opportunity_get({'opportunityId': found[0]['id']}) + assert opportunity['id'] == found[0]['id'] + + def test_conversation_get(self, node, seed_conversation): + conversation = node.conversation_get({'conversationId': seed_conversation['id']}) + assert conversation['id'] == seed_conversation['id'] + for key in ('contactName', 'email', 'phone'): + if key in conversation and key not in seed_conversation: + note(f'conversation_get returned {key}, which the node documents as search-only') + + def test_conversation_dates_are_epoch_milliseconds(self, node, seed_conversation): + added = seed_conversation.get('dateAdded') + if added is None: + pytest.skip('the conversation carries no dateAdded') + if not isinstance(added, (int, float)): + note(f'conversation dateAdded is {type(added).__name__}, not the epoch milliseconds documented') + + def test_message_list_and_get(self, node): + """Walk the conversations until one holds a message: a seeded thread can be empty.""" + conversations = node.conversation_search({'limit': 5})['items'] + if not conversations: + pytest.skip('the sub-account has no conversations') + messages: list = [] + for conversation in conversations: + result = node.message_list({'conversationId': conversation['id'], 'limit': 5}) + messages = check_page(result, 'message_list', 5) + if messages: + break + if not messages: + pytest.skip('none of the first five conversations holds a message') + message_id = messages[0].get('id') + assert message_id, 'a message always carries an id' + message = node.message_get({'messageId': message_id}) + assert message.get('id') == message_id + + def test_user_get(self, node): + users = node.user_list_by_location({})['items'] + if not users: + pytest.skip('the sub-account has no users') + user = node.user_get({'userId': users[0]['id']}) + assert user['id'] == users[0]['id'] + + def test_calendar_group_slug_check(self, node): + result = node.calendar_group_slug_check({'slug': f'rocketride-live-{UID}'}) + assert isinstance(result, dict) + + +# --------------------------------------------------------------------------- +# Write lifecycles +# --------------------------------------------------------------------------- + + +@writes +class TestContactLifecycle: + def test_create_update_get(self, node, temp_contact): + assert temp_contact['id'] + assert temp_contact.get('email') == f'rocketride-live-{UID}@example.com' + updated = node.contact_update({'contactId': temp_contact['id'], 'city': 'Cupertino'}) + assert updated['id'] == temp_contact['id'] + fetched = node.contact_get({'contactId': temp_contact['id']}) + assert fetched.get('city') == 'Cupertino' + + def test_update_rejects_the_create_only_fields(self, node, temp_contact): + """The create DTO declares companyName and the update DTO does not, so the node refuses it locally.""" + with pytest.raises(ValueError, match='unknown parameter'): + node.contact_update({'contactId': temp_contact['id'], 'companyName': 'Nope'}) + + def test_tags_add_and_remove(self, node, temp_contact): + """Adding an unknown tag name also defines it on the sub-account, and removing it does not undo that.""" + name = f'rocketride-live-{UID}' + added = node.contact_tags_add({'contactId': temp_contact['id'], 'tags': [name]}) + try: + assert name in added['tags'], 'a tag write answers with the whole tag list' + removed = node.contact_tags_remove({'contactId': temp_contact['id'], 'tags': [name]}) + assert name not in removed['tags'] + leftover = [tag for tag in node.location_tags_list({})['items'] if tag.get('name') == name] + assert leftover, 'the tag definition outlives the contact it was added to' + note( + 'contact_tags_add defines an unknown tag name on the sub-account and contact_tags_remove leaves ' + 'that definition behind, so a run that tags with a fresh name grows the sub-account tag list. ' + 'Only location_tags_delete removes it. Undocumented by GoHighLevel; both tool descriptions and ' + 'the node README say so.' + ) + finally: + for tag in node.location_tags_list({})['items']: + if tag.get('name') == name: + cleanup(lambda tag_id=tag['id']: node.location_tags_delete({'tagId': tag_id}), f'tag {tag["id"]}') + + def test_tags_cannot_be_smuggled_through_extra(self, node, temp_contact): + with pytest.raises(ValueError, match='cannot be sent through'): + node.contact_update({'contactId': temp_contact['id'], 'extra': {'tags': ['nope']}}) + + def test_followers_add_and_remove(self, node, temp_contact): + users = node.user_list_by_location({})['items'] + if not users: + pytest.skip('the sub-account has no users to follow with') + user_id = users[0]['id'] + added = node.contact_followers_add({'contactId': temp_contact['id'], 'followers': [user_id]}) + assert user_id in added['followers'] + removed = node.contact_followers_remove({'contactId': temp_contact['id'], 'followers': [user_id]}) + assert user_id not in removed['followers'] + + def test_upsert_matches_the_existing_contact(self, node, temp_contact): + result = node.contact_upsert({'email': f'rocketride-live-{UID}@example.com', 'city': 'Sunnyvale'}) + assert result['contact']['id'] == temp_contact['id'], 'upsert on a known email must update, not duplicate' + assert result['new'] is False + + def test_notes_lifecycle(self, node, temp_contact): + note_record = node.contact_notes_create({'contactId': temp_contact['id'], 'body': f'live {UID}'}) + note_id = note_record['id'] + try: + fetched = node.contact_notes_get({'contactId': temp_contact['id'], 'noteId': note_id}) + assert fetched['id'] == note_id + updated = node.contact_notes_update( + {'contactId': temp_contact['id'], 'noteId': note_id, 'body': f'live {UID} edited'} + ) + assert updated['body'].endswith('edited') + finally: + deleted = node.contact_notes_delete({'contactId': temp_contact['id'], 'noteId': note_id}) + assert deleted['deleted'] is True + + def test_tasks_lifecycle(self, node, temp_contact): + due = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat().replace('+00:00', 'Z') + task = node.contact_tasks_create({'contactId': temp_contact['id'], 'title': f'live {UID}', 'dueDate': due}) + task_id = task['id'] + try: + fetched = node.contact_tasks_get({'contactId': temp_contact['id'], 'taskId': task_id}) + assert fetched['id'] == task_id + node.contact_tasks_update({'contactId': temp_contact['id'], 'taskId': task_id, 'title': f'live {UID} v2'}) + completed = node.contact_tasks_set_completed( + {'contactId': temp_contact['id'], 'taskId': task_id, 'completed': True} + ) + assert isinstance(completed, dict) + finally: + deleted = node.contact_tasks_delete({'contactId': temp_contact['id'], 'taskId': task_id}) + assert deleted['deleted'] is True + + +@writes +class TestOpportunityLifecycle: + def test_lifecycle(self, node, pipeline, temp_contact): + stages = pipeline.get('stages') or [] + opportunity = node.opportunity_create( + { + 'pipelineId': pipeline['id'], + 'pipelineStageId': stages[0]['id'] if stages else None, + 'name': f'RocketRide live {UID}', + 'status': 'open', + 'contactId': temp_contact['id'], + 'monetaryValue': 1234, + } + ) + opportunity_id = opportunity['id'] + try: + assert opportunity['name'] == f'RocketRide live {UID}' + updated = node.opportunity_update({'opportunityId': opportunity_id, 'monetaryValue': 4321}) + assert updated['id'] == opportunity_id + status = node.opportunity_status_update({'opportunityId': opportunity_id, 'status': 'won'}) + assert status['ok'] is True + after = node.opportunity_get({'opportunityId': opportunity_id}) + assert after['status'] == 'won' + finally: + deleted = node.opportunity_delete({'opportunityId': opportunity_id}) + assert deleted['deleted'] is True + + def test_followers(self, node, pipeline, temp_contact): + users = node.user_list_by_location({})['items'] + if not users: + pytest.skip('the sub-account has no users to follow with') + opportunity = node.opportunity_create( + { + 'pipelineId': pipeline['id'], + 'name': f'RocketRide followers {UID}', + 'status': 'open', + 'contactId': temp_contact['id'], + } + ) + try: + added = node.opportunity_followers_add({'opportunityId': opportunity['id'], 'followers': [users[0]['id']]}) + assert isinstance(added, dict) + node.opportunity_followers_remove({'opportunityId': opportunity['id'], 'followers': [users[0]['id']]}) + finally: + node.opportunity_delete({'opportunityId': opportunity['id']}) + + +@writes +class TestConversationLifecycle: + def test_create_on_a_contact_that_already_has_one_is_rejected(self, node, temp_contact): + """Creating a contact creates its conversation too, so conversation_create 400s on a new contact. + + Live-confirmed on a contact created seconds earlier through this node: the very first + POST /conversations/ for it comes back 400 ``Conversation already exists``. There is no + idempotent form of this call, so the create path is reachable only for a contact whose + conversation has been deleted. + + The error body carries the id of the conversation that already exists, which is the + only way forward from here, so the node has to surface it rather than reading + ``message`` alone. + """ + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.conversation_create({'contactId': temp_contact['id']}) + error = excinfo.value + assert error.status_code == 400 + assert 'already exists' in str(error) + if not error.details.get('conversationId'): + note( + 'conversation_create: the 400 "Conversation already exists" body carried no conversationId on ' + 'this run, so there was nothing for the node to surface' + ) + return + assert error.canonical_code == 'CONVERSATIONS_CONVERSATION_ALREADY_EXISTS' + assert f'conversationId: {error.details["conversationId"]}' in str(error) + # The id has to lead somewhere: the conversation it names must be real. + existing = node.conversation_get({'conversationId': error.details['conversationId']}) + assert existing['id'] == error.details['conversationId'] + assert existing.get('contactId') == temp_contact['id'] + + def test_update_and_delete(self, node, temp_contact): + found = node.conversation_search({'contactId': temp_contact['id'], 'limit': 5}) + conversations = check_page(found, 'conversation_search', 5) + if not conversations: + pytest.skip('the contact has no conversation to update') + conversation_id = conversations[0]['id'] + updated = node.conversation_update({'conversationId': conversation_id, 'unreadCount': 0}) + assert updated['id'] == conversation_id + deleted = node.conversation_delete({'conversationId': conversation_id}) + assert deleted['deleted'] is True + + +@writes +class TestLocationSettingsLifecycles: + def test_tag_lifecycle(self, node): + tag = node.location_tags_create({'name': f'rocketride-live-tag-{UID}'}) + tag_id = tag['id'] + try: + fetched = node.location_tags_get({'tagId': tag_id}) + assert fetched['id'] == tag_id + renamed = node.location_tags_update({'tagId': tag_id, 'name': f'rocketride-live-tag-{UID}-v2'}) + assert renamed['name'].endswith('-v2') + finally: + deleted = node.location_tags_delete({'tagId': tag_id}) + assert deleted['deleted'] is True + + def test_custom_field_lifecycle(self, node): + field = node.custom_field_create({'name': f'RocketRide live {UID}', 'dataType': 'TEXT'}) + field_id = field['id'] + try: + fetched = node.custom_field_get({'customFieldId': field_id}) + assert fetched['id'] == field_id + assert fetched.get('fieldKey'), 'the field key is what a custom-field write addresses' + renamed = node.custom_field_update({'customFieldId': field_id, 'name': f'RocketRide live {UID} v2'}) + assert renamed['name'].endswith('v2') + finally: + deleted = node.custom_field_delete({'customFieldId': field_id}) + assert deleted['deleted'] is True + + def test_custom_field_round_trips_onto_a_contact(self, node, temp_contact): + """Read tools return a map keyed by field id; write tools take an array. Prove both directions.""" + field = node.custom_field_create({'name': f'RocketRide rt {UID}', 'dataType': 'TEXT'}) + try: + node.contact_update( + { + 'contactId': temp_contact['id'], + 'customFields': [{'id': field['id'], 'field_value': f'value {UID}'}], + } + ) + fetched = node.contact_get({'contactId': temp_contact['id']}) + values = fetched.get('customFields') + assert isinstance(values, dict), 'contact reads project custom fields into a map keyed by field id' + assert values.get(field['id']) == f'value {UID}' + finally: + node.custom_field_delete({'customFieldId': field['id']}) + + def test_custom_value_lifecycle(self, node): + value = node.custom_value_create({'name': f'RocketRide live {UID}', 'value': 'one'}) + value_id = value['id'] + try: + fetched = node.custom_value_get({'customValueId': value_id}) + assert fetched['id'] == value_id + updated = node.custom_value_update( + {'customValueId': value_id, 'name': f'RocketRide live {UID}', 'value': 'two'} + ) + assert updated['value'] == 'two' + finally: + deleted = node.custom_value_delete({'customValueId': value_id}) + assert deleted['deleted'] is True + + +@writes +class TestBusinessLifecycle: + def test_lifecycle(self, node): + business = node.business_create({'name': f'RocketRide live {UID}'}) + business_id = business['id'] + try: + assert business['name'] == f'RocketRide live {UID}' + renamed = node.business_update({'businessId': business_id, 'name': f'RocketRide live {UID} v2'}) + assert renamed['name'].endswith('v2') + finally: + deleted = node.business_delete({'businessId': business_id}) + assert deleted['deleted'] is True + + +@writes +class TestCalendarLifecycle: + def test_calendar_created_and_readable(self, node, temp_calendar): + assert temp_calendar['id'] + fetched = node.calendar_get({'calendarId': temp_calendar['id']}) + assert fetched['id'] == temp_calendar['id'] + listed = node.calendar_list({})['items'] + assert temp_calendar['id'] in [row['id'] for row in listed] + + def test_calendar_update(self, node, temp_calendar): + updated = node.calendar_update({'calendarId': temp_calendar['id'], 'description': f'live {UID}'}) + assert updated['id'] == temp_calendar['id'] + + def test_group_lifecycle(self, node): + group = node.calendar_group_create( + {'name': f'RocketRide live {UID}', 'description': 'live suite', 'slug': f'rocketride-live-{UID}'} + ) + group_id = group['id'] + try: + node.calendar_group_update( + { + 'groupId': group_id, + 'name': f'RocketRide live {UID} v2', + 'description': 'live suite', + 'slug': f'rocketride-live-{UID}', + } + ) + status = node.calendar_group_status_set({'groupId': group_id, 'isActive': False}) + assert isinstance(status, dict) + finally: + deleted = node.calendar_group_delete({'groupId': group_id}) + assert deleted['deleted'] is True + + def test_free_slots(self, node, temp_calendar): + start = int(time.time() * 1000) + result = node.appointment_free_slots_list( + {'calendarId': temp_calendar['id'], 'startDate': start, 'endDate': start + 7 * 86400000} + ) + assert isinstance(result, dict), 'free slots answer a map keyed by date rather than a list' + assert all(len(key) == 10 for key in result), 'non-date keys such as traceId are dropped' + + def test_free_slots_range_is_capped_locally(self, node, temp_calendar): + start = int(time.time() * 1000) + with pytest.raises(ValueError, match='at most 31 days'): + node.appointment_free_slots_list( + {'calendarId': temp_calendar['id'], 'startDate': start, 'endDate': start + 60 * 86400000} + ) + + def test_appointment_get_reads_the_right_envelope_key(self, node, temp_appointment): + """GET /calendars/events/appointments/{eventId} answers under "appointment", not "event".""" + note( + 'Regression guard. GET /calendars/events/appointments/{eventId} answers under "appointment", while ' + 'both published specs declare "event". The tool passed the spec spelling and returned {} for every ' + 'appointment, because record_of only unwraps a key the payload carries and _clean_event then matched ' + 'none of its read keys. It now accepts ("appointment", "event"), wire spelling first. Only the get ' + 'was affected: create and update answer the record bare.' + ) + fetched = node.appointment_get({'eventId': temp_appointment['id']}) + assert fetched.get('id') == temp_appointment['id'], 'appointment_get dropped the whole record' + + def test_appointment_lifecycle(self, node, temp_calendar, temp_appointment): + assert temp_appointment['id'] + updated = node.appointment_update({'eventId': temp_appointment['id'], 'title': f'RocketRide live {UID} v2'}) + assert updated['id'] == temp_appointment['id'], 'the update answers the record bare, with no envelope key' + window_start = int((time.time() - 86400) * 1000) + listed = node.appointment_list( + {'calendarId': temp_calendar['id'], 'startTime': window_start, 'endTime': window_start + 30 * 86400000} + ) + events = check_page(listed, 'appointment_list') + assert temp_appointment['id'] in [row['id'] for row in events] + + def test_appointment_read_through_the_contact_has_no_timezone(self, node, temp_contact, temp_appointment): + listed = node.contact_appointments_list({'contactId': temp_contact['id']}) + events = check_page(listed, 'contact_appointments_list') + match = next((row for row in events if row.get('id') == temp_appointment['id']), None) + if match is None: + pytest.skip('the appointment is not on the contact appointment list') + start = str(match.get('startTime') or '') + if '+' in start or start.endswith('Z'): + note('contact_appointments_list returned an offset-bearing startTime, not the naive local string') + + def test_appointment_notes_lifecycle(self, node, temp_appointment): + created = node.appointment_notes_create({'appointmentId': temp_appointment['id'], 'body': f'live {UID}'}) + note_id = created['id'] + try: + listed = node.appointment_notes_list({'appointmentId': temp_appointment['id'], 'limit': 20}) + notes = check_page(listed, 'appointment_notes_list', 20) + assert note_id in [row['id'] for row in notes] + node.appointment_notes_update( + {'appointmentId': temp_appointment['id'], 'noteId': note_id, 'body': f'live {UID} v2'} + ) + finally: + deleted = node.appointment_notes_delete({'appointmentId': temp_appointment['id'], 'noteId': note_id}) + assert deleted['deleted'] is True + + def test_blocked_slot_lifecycle(self, node, temp_calendar): + start = datetime.now(timezone.utc).astimezone() + timedelta(days=5) + start = start.replace(minute=0, second=0, microsecond=0) + end = start + timedelta(hours=1) + blocked = node.blocked_slot_create( + { + 'calendarId': temp_calendar['id'], + 'title': f'RocketRide live {UID}', + 'startTime': start.isoformat(timespec='seconds'), + 'endTime': end.isoformat(timespec='seconds'), + } + ) + try: + node.blocked_slot_update( + {'eventId': blocked['id'], 'calendarId': temp_calendar['id'], 'title': f'RocketRide live {UID} v2'} + ) + window = int((time.time() - 86400) * 1000) + listed = node.blocked_slot_list( + {'calendarId': temp_calendar['id'], 'startTime': window, 'endTime': window + 30 * 86400000} + ) + check_page(listed, 'blocked_slot_list') + finally: + deleted = node.appointment_delete({'eventId': blocked['id']}) + assert deleted['deleted'] is True + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_bad_token_is_401(self): + bad = _node(token=BAD_TOKEN) + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + bad.contact_list({'limit': 1}) + assert excinfo.value.status_code == 401 + assert BAD_TOKEN not in str(excinfo.value), 'the credential must never reach an error message' + + def test_missing_location_is_a_403_that_blames_the_token(self): + """The vendor message says the token has no access; the guidance says the parameter is missing.""" + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + ghl.call(TOKEN, 'GET', '/contacts/') + assert excinfo.value.status_code == 403 + assert 'locationId is missing or mismatched' in str(excinfo.value) + + def test_unknown_path_is_a_404_with_an_empty_body(self): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + ghl.call(TOKEN, 'GET', '/no-such-resource-here') + assert excinfo.value.status_code == 404 + + def test_contact_create_with_nothing_is_refused_before_the_request(self, node): + before = PACER.count + with pytest.raises(ValueError, match='at least one of'): + node.contact_create({}) + assert PACER.count == before, 'the local check must fire before any network call' + + def test_contact_create_with_only_a_location_is_400_with_a_422_body(self, node): + """HTTP status and body statusCode disagree here, and only the HTTP one may be trusted.""" + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'POST', 'path': '/contacts/', 'body': {'locationId': LOCATION}}) + assert excinfo.value.status_code == 400, 'the body claims 422; the HTTP status is 400' + + def test_opportunity_search_rejects_the_camelcase_location(self, node): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': '/opportunities/search', 'params': {'locationId': LOCATION}}) + assert excinfo.value.status_code == 422 + assert 'should not exist' in str(excinfo.value) + + def test_users_search_with_no_parameters_is_a_401_that_is_really_validation(self, node): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': '/users/search'}) + assert excinfo.value.status_code == 401 + assert 'missing required parameter' in str(excinfo.value) + + def test_users_search_with_a_location_but_no_company_is_a_422(self, node): + """The same missing companyId is a 401 bare and a 422 once locationId is supplied.""" + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': '/users/search', 'params': {'locationId': LOCATION}}) + assert excinfo.value.status_code == 422 + assert 'companyId must be a string' in str(excinfo.value) + + def test_users_list_rejects_the_company_id_its_sibling_requires(self, node, company_id): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request( + {'method': 'GET', 'path': '/users/', 'params': {'locationId': LOCATION, 'companyId': company_id}} + ) + # This route answered 401 "Command timed out" on one run, which is the gateway in + # front of the API failing rather than anything the node did. Recorded and skipped: + # asserting on the validation message through a gateway that is not answering turns + # a vendor outage into a red suite. The 401 mapping itself is covered offline. + if excinfo.value.status_code == 401 and 'timed out' in str(excinfo.value).lower(): + note('GET /users/ answered 401 "Command timed out": an upstream timeout, not a credential problem') + pytest.skip('the gateway answered 401 "Command timed out" rather than the endpoint validating') + assert 'companyId should not exist' in str(excinfo.value) + + def test_contact_tags_cannot_be_read(self, node, seed_contact_id): + """Tags are a write-only sub-resource: read them off the contact record instead.""" + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': f'/contacts/{seed_contact_id}/tags'}) + assert excinfo.value.status_code == 404 + + def test_unknown_parameter_is_named_locally(self, node): + before = PACER.count + with pytest.raises(ValueError, match='unknown parameter'): + node.contact_list({'locationId': LOCATION}) + assert PACER.count == before + + def test_agency_location_search_is_forbidden(self, node): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': '/locations/search'}) + assert excinfo.value.status_code == 403 + assert 'agency-scoped endpoint' in str(excinfo.value) + + def test_oauth_installed_locations_is_out_of_scope(self, node): + with pytest.raises(ghl.GoHighLevelAPIError) as excinfo: + node.request({'method': 'GET', 'path': '/oauth/installedLocations'}) + assert excinfo.value.status_code == 401 + + def test_read_only_mode_refuses_a_write(self, seed_contact_id): + locked = _node(read_only=True) + before = PACER.count + with pytest.raises(ValueError, match='read-only mode'): + locked.contact_update({'contactId': seed_contact_id, 'city': 'Nowhere'}) + assert PACER.count == before, 'the read-only gate must fire before any network call' + + +# --------------------------------------------------------------------------- +# Rate limits +# --------------------------------------------------------------------------- + + +class TestRateLimits: + def test_headers_are_present_and_numeric(self, node): + node.location_get({}) + for header in ( + 'x-ratelimit-max', + 'x-ratelimit-remaining', + 'x-ratelimit-interval-milliseconds', + 'x-ratelimit-limit-daily', + 'x-ratelimit-daily-remaining', + ): + raw = LAST_HEADERS.get(header) + assert raw is not None, f'{header} is missing' + assert int(raw) >= 0, f'{header} is not an integer' + + def test_daily_reset_is_a_duration_not_a_timestamp(self, node): + node.location_get({}) + raw = LAST_HEADERS.get('x-ratelimit-daily-reset') + if raw is None: + pytest.skip('x-ratelimit-daily-reset is absent on this response') + assert int(raw) < 90000000, 'this is a duration in milliseconds, so it can never be an epoch timestamp' + + def test_no_retry_after_header(self, node): + node.location_get({}) + if LAST_HEADERS.get('Retry-After') is not None: + note('a Retry-After header appeared, which the client is written not to expect') + + def test_daily_budget_is_not_exhausted(self): + assert not ghl.daily_budget_exhausted(LAST_HEADERS), 'the daily bucket is empty, so nothing else can pass' + + +# --------------------------------------------------------------------------- +# What this account cannot exercise +# +# Recorded as skips with a reason rather than left out, so the coverage gap is visible +# in the run rather than only in a document. +# --------------------------------------------------------------------------- + + +class TestNotExercisable: + def test_message_send(self): + pytest.skip('message_send needs a phone number or email provider provisioned on the sub-account') + + def test_message_schedule_cancel(self): + pytest.skip('nothing can be scheduled without a messaging provider, so there is no scheduled message to cancel') + + def test_message_transcription_get(self): + pytest.skip('transcriptions exist only for recorded calls, which need a phone provider') + + def test_message_email_get(self): + pytest.skip('needs an email message id, which needs an email provider on the sub-account') + + def test_contact_workflow_add_and_remove(self): + pytest.skip('GoHighLevel publishes no endpoint that lists workflows, so a workflowId cannot be resolved') + + def test_agency_endpoints(self): + pytest.skip('locations search, location create/update/delete and companies are agency-scoped and refused')