improvement(zoho-desk): pick the data center from a dropdown and trim service-account help text (#6271)

* improvement(zoho-desk): pick the data center from a dropdown and trim service-account help text

The Zoho Desk Self Client modal rendered a paragraph of setup steps as the hint
under Client secret, duplicating both the setup guide and two of its own field
hints. Cut it to the one caveat that isn't derivable from the form, and moved it
to the org-identifier field the caveats actually qualify. Data center is now a
dropdown sourced from ZOHO_DESK_DATA_CENTERS.

Same editorial pass across the other service accounts: Zoom, Salesforce,
Shopify, Webflow, Trello and Cal.com dropped setup steps in favor of caveats.

Also adds the documented Zoho Desk params that were missing (list_tickets
assignee/channel/receivedInDays, list_comments and list_threads sortBy,
get_contact and get_thread include), each gated per operation so a stale
subBlock value can't leak into an endpoint that reads the same param name.

* fix(zoho-desk): let an unsupported receivedInDays reach the tool's validation

The block mapper filtered on shape before forwarding, so a fractional or
non-numeric value was dropped and List Tickets then ran with no window at all —
returning the whole queue as though the requested filter had applied. The tool
owns that validation, so the mapper now passes the value straight through.

Adds a block-to-tool seam test: neither side's own tests could catch a value
lost between them.

* fix(zoho-desk): overwrite operation-scoped params instead of omitting them

The block mapper scoped params by destructuring them out of the spread, on the
assumption that a key left out of the return value never reaches the tool. It
does: both call sites merge the mapper's output on top of the original inputs
(`{ ...inputs, ...transformedParams }`), so an omitted key is restored.

The serializer is what actually held this together, and it has a gap — an
advanced subBlock with a retained value is emitted for every operation while the
block's advanced toggle is off, because that branch returns on isNonEmptyValue
without evaluating the subBlock's condition. So a Sort By set on List Tickets
reached List Comments, and a ticket Include reached Get Contact, each rejected
by Zoho. Out-of-range from/limit reached the wire for the same reason.

Every scoped param is now assigned unconditionally, undefined included, so the
merge cannot resurrect a stale value.

Also fixes a crash this branch introduced: clearing the Departments multi-select
stores [], which reached the comma-list normalizer and threw on .split. The
helper now takes arrays, which is what that subBlock actually stores.

The block-to-tool tests now model the real merge rather than the mapper's return
value alone — the previous version passed while production threw on the same
input. Corrects two comments that misstated where Zoho documents customFields
and errorMessage, and splits the shared include subBlock, since Get Ticket
accepts contract and skills and List Tickets does not.

* fix(zoho-desk): do not scope params on the agent-tool path

The previous commit made the mapper assign every operation-scoped param
unconditionally, so the merge could not resurrect a stale value. That is right
on the canvas path and wrong on the agent-tool path, where `operation` is a
sibling of the tool call rather than a member of params: the mapper saw
`operation === undefined`, every gate resolved to undefined, and the merge then
overwrote the model's own arguments with it. A Zoho Desk tool called by an agent
lost every parameter the model supplied.

That path needs no scoping — the tool is already chosen, and the model addresses
tool params by their real names — so it now returns early. Custom fields are
still coerced there, since parsing JSON is a type fix rather than an operation
gate, and that parsing is now shared by both paths.

* fix(zoho-desk): keep the legacy include working on Get Ticket

Splitting the shared `include` subBlock into `include` and `ticketInclude` left
workflows saved before the split reading an empty field, so their Get Ticket
calls silently stopped embedding what they asked for.

Get Ticket now reads `ticketInclude ?? include`. The fallback only goes that
direction: Get Ticket accepts every value List Tickets does plus `contract` and
`skills`, so a legacy value is always valid there, while List Tickets still
reads only `include` and can never receive the two extra tokens it does not
document.
This commit is contained in:
Waleed
2026-08-04 18:28:50 -07:00
committed by GitHub
parent fbd02bc176
commit 0ab44c5b44
20 changed files with 869 additions and 135 deletions
@@ -13,10 +13,10 @@ This is the recommended way to use Zoho Desk in production workflows: the creden
## Prerequisites
You need a Zoho account with access to the [Zoho API Console](https://api-console.zoho.com) for the same organization your Zoho Desk portal belongs to, and the Zoho Desk **organization ID** for that portal.
You need a Zoho account with access to the Zoho API Console **for your data center**, for the same organization your Zoho Desk portal belongs to, and the Zoho Desk **organization ID** for that portal. See [Know Your Data Center](#3-know-your-data-center) for the console that matches your region.
<Callout type="info">
A Self Client can authenticate against the **US, EU, IN, or AU** accounts server — pick your region with the **Data center** field when you add the credential, or leave it blank for US. Organizations in the JP, CA, SA, CN, or UK data centers are not supported yet. API calls are then routed to the Desk host for that same region, so data residency is honored end to end.
A Self Client can authenticate against the **US, EU, IN, or AU** accounts server — pick your region from the **Data center** dropdown when you add the credential, or leave it unset for US. Organizations in the JP, CA, SA, CN, or UK data centers are not supported yet. API calls are then routed to the Desk host for that same region, so data residency is honored end to end.
The interactive **OAuth** connection is a separate path and remains **US-only** (`accounts.zoho.com`), so a non-US organization can connect only through a Self Client.
</Callout>
@@ -31,7 +31,7 @@ Zoho Desk **webhooks are a Professional-edition and above feature**. The Zoho De
<Steps>
<Step>
Sign in at [api-console.zoho.com](https://api-console.zoho.com) with the Zoho account that owns the Desk portal
Sign in to the Zoho API Console **for your data center** with the Zoho account that owns the Desk portal — see [Know Your Data Center](#3-know-your-data-center) for the right one. A Self Client is registered in one data center and cannot authenticate against another region's accounts server
</Step>
<Step>
Click **Add Client**, choose **Self Client**, and click **Create** — then **OK** on the confirmation
@@ -68,20 +68,24 @@ This must be the organization ID of the Desk portal you want the workflows to ac
### 3. Know Your Data Center
Zoho hosts each organization in one data center, and the accounts server that issues tokens is per region. Look at the URL you use to sign in to Zoho Desk and pick the matching code:
Zoho hosts each organization in one data center, and the accounts server that issues tokens is per region. Look at the URL you use to sign in to Zoho Desk and pick the matching region in the **Data center** dropdown:
| Data center | Sign-in domain | Code to enter |
| Sign-in domain | Data center to pick | API Console to create the Self Client in |
| --- | --- | --- |
| United States | `zoho.com` | `us` (or leave blank) |
| Europe | `zoho.eu` | `eu` |
| India | `zoho.in` | `in` |
| Australia | `zoho.com.au` | `au` |
| `zoho.com` | United States (the default when left unset) | [api-console.zoho.com](https://api-console.zoho.com) |
| `zoho.eu` | Europe | [api-console.zoho.eu](https://api-console.zoho.eu) |
| `zoho.in` | India | [api-console.zoho.in](https://api-console.zoho.in) |
| `zoho.com.au` | Australia | [api-console.zoho.com.au](https://api-console.zoho.com.au) |
Organizations in the JP, CA, SA, CN, and UK data centers cannot be connected yet.
<Callout type="warn">
Create the Self Client in the console for **your** data center. Zoho ties a client to the region it was registered in — *"the accounts-server-url is specific to the location (i.e., datacenter) where the client is registered"* — and the multi-data-center setting that would extend a client to other regions is [not available for Self Clients](https://docs.catalyst.zoho.com/en/api/oauth2/register-new-client/). A Self Client created in the wrong console cannot be repointed later; create a new one in the right region.
</Callout>
### 4. Scopes
Sim requests exactly the scopes its Zoho Desk tools and trigger exercise:
Sim requests the Zoho Desk scopes its tools and its webhook trigger exercise — the same list on both connection types:
```
Desk.tickets.READ
@@ -91,10 +95,11 @@ Desk.agents.READ
Desk.basic.READ
Desk.webhooks.CREATE
Desk.webhooks.DELETE
aaaserver.profile.READ
```
Sim sends this list on every token request, so there is nothing to pre-configure on the Self Client itself. If Zoho rejects the request with an invalid-scope error, the Self Client's owner does not have access to one of the Desk modules above in that organization.
Sim sends this list on every token request, so there is nothing to pre-configure on the Self Client itself. The `aaaserver.profile.READ` scope Sim requests on the interactive OAuth flow is deliberately left off this grant — it is an Accounts *profile* scope, and this grant never calls the Accounts profile endpoint; identity is synthesized from the organization ID. If Zoho rejects the request with an invalid-scope error, the Self Client's owner does not have access to one of the Desk modules above in that organization.
`Desk.webhooks.CREATE` and `Desk.webhooks.DELETE` belong to the trigger, which runs on an OAuth connection only (see [Triggers](#triggers-still-need-oauth) below). They are harmless on a Self Client grant, but a Free or Standard Desk plan may reject them, since webhooks are a Professional-edition feature.
A scope that is granted but insufficient surfaces at run time as a `4xx` from the Zoho Desk API naming the scope problem.
@@ -118,7 +123,7 @@ Regenerating or revoking the Self Client in the Zoho API Console invalidates the
{/* TODO(screenshot): Zoho Desk integration page with the Add Self Client connect option */}
</Step>
<Step>
In the **Add Zoho Desk Self Client** dialog, paste the **Client ID**, the **Client secret**, and the numeric **Organization ID**. Set **Data center** to your region (`us`, `eu`, `in`, or `au`) — leave it blank for US. Optionally set a display name and description
In the **Add Zoho Desk Self Client** dialog, paste the **Client ID**, the **Client secret**, and the numeric **Organization ID**. Pick your region from the **Data center** dropdown — leaving it unset uses the United States. Optionally set a display name and description
{/* TODO(screenshot): Add Zoho Desk Self Client dialog with all fields filled in */}
</Step>
@@ -153,10 +158,10 @@ Access tokens minted from a Self Client live for one hour and there is **no refr
<FAQ items={[
{ question: "Why a Self Client instead of OAuth?", answer: "A Self Client authenticates as your Zoho organization, not as a person — nothing expires when someone leaves or their login lapses. Sim mints short-lived tokens from the stored client ID and secret whenever a workflow runs." },
{ question: "Where do I find the Organization ID?", answer: "In Zoho Desk, go to Setup (gear icon) → Developer Space → API. The numeric Organization ID shown there is the value to paste. This is expected to be the same ID that Zoho Desk API calls send in the orgId header; if Zoho rejects it with missing_org_info, paste the full ZohoDesk.<your-org-id> value instead." },
{ question: "Zoho rejects my credentials with invalid_client — why?", answer: "Either the client ID or secret was mistyped, or the client you created is not a Self Client. Only Self Clients support the client-credentials grant — in the Zoho API Console, Add Client → Self Client. Copy both values from the client's Client Secret tab." },
{ question: "Zoho rejects my credentials with invalid_client — why?", answer: "Either the client ID or secret was mistyped, the client you created is not a Self Client, or the Self Client was created in a different data center's API Console than the Data center you selected. Only Self Clients support the client-credentials grant — in the Zoho API Console, Add Client → Self Client. Copy both values from the client's Client Secret tab, and create the client in the console for your region." },
{ question: "Zoho returns missing_org_info or rejects the organization — why?", answer: "Zoho could not resolve a Desk organization from the ID you pasted. Re-copy the numeric Organization ID from Setup → Developer Space → API in the Desk portal you want to use. If your Zoho account has multiple Desk portals, make sure it is the ID of the right one." },
{ question: "Can I use a Self Client with a non-US Zoho account?", answer: "Yes, for the US, EU, IN, and AU data centers. Set the Data center field to us, eu, in, or au when you add the credential, and Sim mints tokens against that region's accounts server and calls the Desk host in the same region. Leaving it blank means US. The JP, CA, SA, CN, and UK data centers are not supported yet, and the interactive OAuth connection remains US-only." },
{ question: "I picked the wrong data center — what happens?", answer: "The region's accounts server does not know your organization, so Zoho rejects the token request and Sim reports that it could not authenticate. Edit the credential and set the Data center to the region whose domain you sign in to Zoho Desk with." },
{ question: "Can I use a Self Client with a non-US Zoho account?", answer: "Yes, for the US, EU, IN, and AU data centers. Pick your region from the Data center dropdown when you add the credential, and Sim mints tokens against that region's accounts server and calls the Desk host in the same region. Leaving it unset means US. Create the Self Client in that region's API Console too. The JP, CA, SA, CN, and UK data centers are not supported yet, and the interactive OAuth connection remains US-only." },
{ question: "I picked the wrong data center — what happens?", answer: "The region's accounts server does not know your Self Client, so Zoho rejects the token request and Sim reports that it could not authenticate. Edit the credential and pick the region whose domain you sign in to Zoho Desk with — and check that the Self Client itself was created in that same region's API Console, since a client cannot authenticate against another region." },
{ question: "Why doesn't my Zoho Desk trigger work with the Self Client?", answer: "The trigger provisions a webhook subscription in your Desk organization, and that path runs against a personal OAuth connection only. Connect Zoho Desk through OAuth for triggers. Separately, Zoho Desk webhooks require a Professional-edition plan or above — they are unavailable on Free and Standard." },
{ question: "How do I rotate the credentials?", answer: "Regenerate the client secret on the Self Client's Client Secret tab in the Zoho API Console, then update the credential in Sim with the new secret. The old secret stops working as soon as it's regenerated, so update Sim promptly." },
]} />
@@ -53,11 +53,14 @@ List tickets from a Zoho Desk organization with optional filters. Returns a list
| --------- | ---- | -------- | ----------- |
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
| `orgId` | string | Yes | Zoho Desk organization ID |
| `from` | number | No | Pagination start index \(0-based, max 4999\) |
| `limit` | number | No | Number of tickets to return \(1-100, default 10\) |
| `from` | number | No | Pagination start index \(0-based\) |
| `limit` | number | No | Number of tickets to return \(1-100\) |
| `departmentIds` | string | No | Filter by department ID \(comma-separated for multiple\) |
| `status` | string | No | Filter by status, including custom statuses. Comma-separate to match multiple \(e.g. "Open,On Hold"\) |
| `priority` | string | No | Filter by priority. Comma-separate to match multiple \(e.g. "High,Urgent"\) |
| `assignee` | string | No | Filter by assignee: an agent ID, or "Unassigned". Comma-separate to match multiple. |
| `channel` | string | No | Filter by origin channel, spelled as your portal spells it. Comma-separate to match multiple. |
| `receivedInDays` | number | No | Only tickets whose last customer response was within the last 15, 30, or 90 days \(Zoho filters on customerResponseTime, despite the name\) |
| `sortBy` | string | No | Sort field: createdTime, customerResponseTime, or responseDueDate. Prefix with - for descending. |
| `include` | string | No | Comma-separated related data to embed. Allowed: contacts, products, departments, team, isRead, assignee |
@@ -88,6 +91,7 @@ List tickets from a Zoho Desk organization with optional filters. Returns a list
| ↳ `responseDueDate` | string | Response due date |
| ↳ `createdTime` | string | Created timestamp |
| ↳ `modifiedTime` | string | Last modified timestamp |
| ↳ `customerResponseTime` | string | Time the last customer response was received |
| ↳ `closedTime` | string | Closed timestamp |
| ↳ `resolution` | string | Resolution text |
| ↳ `threadCount` | string | Number of threads |
@@ -139,6 +143,7 @@ Retrieve a single Zoho Desk ticket by ID.
| ↳ `responseDueDate` | string | Response due date |
| ↳ `createdTime` | string | Created timestamp |
| ↳ `modifiedTime` | string | Last modified timestamp |
| ↳ `customerResponseTime` | string | Time the last customer response was received |
| ↳ `closedTime` | string | Closed timestamp |
| ↳ `resolution` | string | Resolution text |
| ↳ `threadCount` | string | Number of threads |
@@ -170,7 +175,7 @@ Update fields on an existing Zoho Desk ticket.
| `dueDate` | string | No | Due date \(ISO 8601\) |
| `description` | string | No | Ticket description |
| `resolution` | string | No | Resolution notes recorded on the ticket |
| `classification` | string | No | Ticket classification: Problem, Request, Question, or Others |
| `classification` | string | No | Ticket classification. Zoho\'s system-defined values are Problem, Request, and Question; portals can define custom values. Pass "" to clear it. |
| `customFields` | json | No | Custom field values as a JSON object, keyed by custom field API name |
#### Output
@@ -200,6 +205,7 @@ Update fields on an existing Zoho Desk ticket.
| ↳ `responseDueDate` | string | Response due date |
| ↳ `createdTime` | string | Created timestamp |
| ↳ `modifiedTime` | string | Last modified timestamp |
| ↳ `customerResponseTime` | string | Time the last customer response was received |
| ↳ `closedTime` | string | Closed timestamp |
| ↳ `resolution` | string | Resolution text |
| ↳ `threadCount` | string | Number of threads |
@@ -223,6 +229,7 @@ List comments on a Zoho Desk ticket.
| `ticketId` | string | Yes | Ticket ID |
| `from` | number | No | Pagination start index \(0-based\) |
| `limit` | number | No | Number of comments to return \(1-100, default 50\) |
| `sortBy` | string | No | Sort by commentedTime. Ascending by default; prefix with - for descending \(-commentedTime\). |
#### Output
@@ -307,6 +314,7 @@ List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sen
| `ticketId` | string | Yes | Ticket ID |
| `from` | number | No | Pagination start index \(0-based\) |
| `limit` | number | No | Number of threads to return \(1-200, default 100\) |
| `sortBy` | string | No | Sort by sendDateTime. Zoho sorts descending \(newest first\) when unset; pass sendDateTime for oldest first. |
#### Output
@@ -333,7 +341,7 @@ List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sen
| ↳ `isContentTruncated` | boolean | Whether Zoho truncated the thread content; fetch fullContentURL for the rest |
| ↳ `fullContentURL` | string | URL returning the untruncated thread content |
| ↳ `plainText` | string | Zoho's own plain-text rendering of the thread, when it supplies one |
| ↳ `status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
| ↳ `status` | string | Delivery status of the thread \(e.g. SUCCESS, PENDING, FAILED, DRAFT\) |
| ↳ `isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
| ↳ `visibility` | string | Thread visibility \(e.g. public\) |
| ↳ `canReply` | boolean | Whether the thread can be replied to |
@@ -363,6 +371,7 @@ Retrieve the full content of a single Zoho Desk ticket thread.
| `orgId` | string | Yes | Zoho Desk organization ID |
| `ticketId` | string | Yes | Ticket ID |
| `threadId` | string | Yes | Thread ID |
| `include` | string | No | Related data to embed. Allowed: plainText — Zoho's own plain-text rendering of the thread |
#### Output
@@ -389,7 +398,7 @@ Retrieve the full content of a single Zoho Desk ticket thread.
| ↳ `isContentTruncated` | boolean | Whether Zoho truncated the thread content; fetch fullContentURL for the rest |
| ↳ `fullContentURL` | string | URL returning the untruncated thread content |
| ↳ `plainText` | string | Zoho's own plain-text rendering of the thread, when it supplies one |
| ↳ `status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
| ↳ `status` | string | Delivery status of the thread \(e.g. SUCCESS, PENDING, FAILED, DRAFT\) |
| ↳ `isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
| ↳ `visibility` | string | Thread visibility \(e.g. public\) |
| ↳ `canReply` | boolean | Whether the thread can be replied to |
@@ -417,6 +426,7 @@ Retrieve a Zoho Desk contact by ID.
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
| `orgId` | string | Yes | Zoho Desk organization ID |
| `contactId` | string | Yes | Contact ID to retrieve |
| `include` | string | No | Comma-separated related data to embed. Allowed: accounts, owner |
#### Output
@@ -42,8 +42,13 @@ function messageForClientCredentialError(
switch (err.code) {
case 'invalid_credentials':
return `We couldn't authenticate with those credentials. Check that the ${fieldLabels} all belong to the same ${descriptor.serviceLabel} app and that the app is authorized.`
case 'site_not_found':
return `We couldn't find a ${descriptor.serviceLabel} account at that host. Check the spelling of the host field and try again.`
case 'site_not_found': {
// "host field" named a label no provider renders — Salesforce calls it
// My Domain host, and Zoom/Box/Zoho Desk have no host field at all.
const hostFieldLabel =
descriptor.fields.find((field) => field.id === 'orgId')?.label ?? 'host'
return `We couldn't find a ${descriptor.serviceLabel} account at that host. Check the ${hostFieldLabel} field and try again.`
}
case 'provider_unavailable':
return `We couldn't reach ${descriptor.serviceLabel} to verify these credentials. Try again in a moment.`
case 'duplicate_display_name':
@@ -191,17 +196,12 @@ export function ClientCredentialAccountModal({
placeholder={clientIdField.placeholder}
autoComplete='off'
required
hint={hintFor(clientIdField, trimmedClientId)}
hint={hintFor(clientIdField, trimmedClientId) ?? clientIdField.hint}
/>
)}
{clientSecretField && (
<ChipModalField
type='custom'
title={clientSecretField.label}
required
hint={descriptor.helpText}
>
<ChipModalField type='custom' title={clientSecretField.label} required>
<SecretInput
value={clientSecret}
onChange={(value) => {
@@ -231,22 +231,28 @@ export function ClientCredentialAccountModal({
placeholder={orgIdField.placeholder}
autoComplete='off'
required
hint={hintFor(orgIdField, trimmedOrgId)}
// helpText lands here, not on the client secret: every provider's
// caveat qualifies the org identifier or what the credential can
// reach (Box's Admin Console authorization, Zoom's Account ID,
// Salesforce's Run As user), never the secret being pasted. A live
// format hint still wins, matching the token modal's precedence.
hint={hintFor(orgIdField, trimmedOrgId) ?? orgIdField.hint ?? descriptor.helpText}
/>
)}
{dataCenterField && (
{dataCenterField?.options && (
<ChipModalField
type='input'
type='dropdown'
title={dataCenterField.label}
value={dataCenter}
value={dataCenter || undefined}
onChange={(value) => {
setDataCenter(value)
if (error) setError(null)
}}
options={dataCenterField.options}
placeholder={dataCenterField.placeholder}
autoComplete='off'
hint={hintFor(dataCenterField, trimmedDataCenter) ?? dataCenterField.hintMessage}
align='start'
hint={dataCenterField.hint}
/>
)}
+243 -49
View File
@@ -18,6 +18,48 @@ const OPERATIONS_NEEDING_ORG = [
'get_attachment',
]
/**
* Collapse the three "not supplied" shapes to `undefined`. The workflow
* serializer initializes untouched subBlocks to `null`, and a cleared field
* arrives as `''`; both mean the same thing as absent.
*/
function orUndefined(value: unknown): unknown {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'string') {
const trimmed = value.trim()
return trimmed || undefined
}
return value
}
/**
* Coerce a pagination input to an integer at or above `min`, or `undefined`.
* Anything out of range is discarded rather than forwarded: Zoho answers a
* negative or fractional index with an opaque provider error.
*/
function toPaginationValue(value: unknown, min: number): number | undefined {
const resolved = orUndefined(value)
if (resolved === undefined) return undefined
const parsed = Number(resolved)
return Number.isInteger(parsed) && parsed >= min ? parsed : undefined
}
/**
* Accept the custom-field map as either an object (an agent supplying it
* directly) or the JSON text the subBlock stores. Anything unparseable fails
* loudly rather than reaching Zoho as a string it would silently ignore.
*/
function parseCustomFields(value: unknown): Record<string, unknown> | undefined {
if (value === undefined || value === null) return undefined
if (typeof value !== 'string') return value as Record<string, unknown>
if (!value.trim()) return undefined
try {
return JSON.parse(value)
} catch {
throw new Error('Invalid JSON provided for custom fields')
}
}
export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
type: 'zoho_desk',
name: 'Zoho Desk',
@@ -208,6 +250,39 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
placeholder: 'Filter, e.g. High,Urgent',
condition: { field: 'operation', value: 'list_tickets' },
},
{
id: 'assigneeFilter',
title: 'Assignee',
type: 'short-input',
placeholder: 'Filter, e.g. Unassigned',
condition: { field: 'operation', value: 'list_tickets' },
mode: 'advanced',
},
{
id: 'channelFilter',
title: 'Channel',
type: 'short-input',
placeholder: 'Filter, e.g. Email,Web',
condition: { field: 'operation', value: 'list_tickets' },
mode: 'advanced',
},
{
id: 'receivedInDays',
title: 'Customer Responded Within',
type: 'dropdown',
// "Any time" is required, not cosmetic: a dropdown with no empty option
// seeds its first option into the store on mount, so merely opening the
// advanced fields would pin every List Tickets run to a 15-day
// customer-response window with no way to clear it.
options: [
{ label: 'Any time', id: '' },
{ label: 'Last 15 days', id: '15' },
{ label: 'Last 30 days', id: '30' },
{ label: 'Last 90 days', id: '90' },
],
condition: { field: 'operation', value: 'list_tickets' },
mode: 'advanced',
},
{
id: 'assigneeId',
title: 'Assignee',
@@ -245,16 +320,15 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
condition: { field: 'operation', value: 'update_ticket' },
mode: 'advanced',
},
// Zoho marks classification `x-dynamic-enum` and documents "Custom values
// are also supported", so the picklist is portal-editable — a closed
// dropdown would lock out any portal that renamed or replaced the
// system-defined values. Free text, with those values as the placeholder.
{
id: 'classification',
title: 'Classification',
type: 'dropdown',
options: [
{ label: 'Problem', id: 'Problem' },
{ label: 'Request', id: 'Request' },
{ label: 'Question', id: 'Question' },
{ label: 'Others', id: 'Others' },
],
type: 'short-input',
placeholder: 'e.g. Problem, Request, Question',
condition: { field: 'operation', value: 'update_ticket' },
mode: 'advanced',
},
@@ -365,9 +439,43 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
title: 'Include',
type: 'short-input',
placeholder: 'e.g. contacts,assignee',
condition: { field: 'operation', value: ['list_tickets', 'get_ticket'] },
condition: { field: 'operation', value: 'list_tickets' },
mode: 'advanced',
},
// Split from the list_tickets `include` above rather than shared: Get Ticket
// additionally accepts `contract` and `skills`, which List Tickets does not
// document. A shared subBlock keeps its value across an operation change, so
// `skills` set here would follow the user to List Tickets and put an
// undocumented token on the wire.
{
id: 'ticketInclude',
title: 'Include',
type: 'short-input',
placeholder: 'e.g. contacts,contract,skills',
condition: { field: 'operation', value: 'get_ticket' },
mode: 'advanced',
},
{
id: 'contactInclude',
title: 'Include',
type: 'short-input',
placeholder: 'accounts,owner',
condition: { field: 'operation', value: 'get_contact' },
mode: 'advanced',
},
{
id: 'threadInclude',
title: 'Include',
type: 'short-input',
placeholder: 'plainText',
condition: { field: 'operation', value: 'get_thread' },
mode: 'advanced',
},
// Sort is split per operation because Zoho allows a different field set on
// each list endpoint (tickets sort on createdTime/customerResponseTime/
// responseDueDate, comments on commentedTime, threads on sendDateTime). One
// shared subBlock keeps its value across an operation change, so it would
// carry a field name the next endpoint rejects.
{
id: 'sortBy',
title: 'Sort By',
@@ -376,6 +484,22 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
condition: { field: 'operation', value: 'list_tickets' },
mode: 'advanced',
},
{
id: 'commentSortBy',
title: 'Sort By',
type: 'short-input',
placeholder: 'commentedTime or -commentedTime',
condition: { field: 'operation', value: 'list_comments' },
mode: 'advanced',
},
{
id: 'threadSortBy',
title: 'Sort By',
type: 'short-input',
placeholder: 'sendDateTime or -sendDateTime',
condition: { field: 'operation', value: 'list_threads' },
mode: 'advanced',
},
{
id: 'from',
title: 'From',
@@ -416,9 +540,29 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
config: {
tool: (params) => `zoho_desk_${params.operation}`,
params: (params) => {
// Pull raw pagination out of the spread so invalid values never reach the
// tool; only re-add them when Number() yields a finite value (a non-numeric
// typo would otherwise become NaN and produce an invalid Zoho query param).
// The agent-tool path does not carry `operation` inside params - it is a
// sibling of the tool call, used only to pick the tool - and there the
// model addresses tool params by their real names. The tool is already
// selected, so there is no cross-operation leak to guard against, while
// running the scoping below WOULD overwrite the model's own values with
// `undefined`. Leave those params alone; only coerce the JSON field,
// which is a type fix rather than an operation gate.
if (typeof params.operation !== 'string') {
return { ...params, customFields: parseCustomFields(params.customFields) }
}
// IMPORTANT: destructuring a key out of `rest` does NOT keep it from the
// tool. Both call sites merge this function's return value on top of the
// original inputs (`{ ...inputs, ...transformedParams }` in
// executor/handlers/generic/generic-handler.ts, and the same shape in
// providers/utils.ts), so a key left out of `result` is simply restored
// from `inputs`. The only way to scope a param to an operation is to
// OVERWRITE it with `undefined`, which every tool then treats as unset.
//
// This matters because a `mode: 'advanced'` subBlock with a retained
// value is serialized for every operation when the block's advanced
// toggle is off - serializer/index.ts returns on `isNonEmptyValue`
// without evaluating the subBlock's `condition` - so stale advanced
// values genuinely do arrive here under an unrelated operation.
const {
oauthCredential,
from: rawFrom,
@@ -431,6 +575,16 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
priorityFilter: rawPriorityFilter,
customFields: rawCustomFields,
departmentIds: rawDepartmentIds,
sortBy: rawSortBy,
commentSortBy: rawCommentSortBy,
threadSortBy: rawThreadSortBy,
include: rawInclude,
ticketInclude: rawTicketInclude,
contactInclude: rawContactInclude,
threadInclude: rawThreadInclude,
assigneeFilter: rawAssigneeFilter,
channelFilter: rawChannelFilter,
receivedInDays: rawReceivedInDays,
...rest
} = params
const result: Record<string, unknown> = { ...rest, oauthCredential }
@@ -447,15 +601,19 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
: typeof rawDepartmentIds === 'string'
? rawDepartmentIds.trim()
: ''
if (departmentIds) result.departmentIds = departmentIds
// Always assigned, never conditionally: an emptied multi-select stores
// `[]`, and leaving the key unset would let that array through to the
// tool, where the comma-list normalizer would throw on `.split`.
result.departmentIds = departmentIds || undefined
// contentType is the comment's content type; its default would otherwise
// serialize for every operation (e.g. get_attachment, which has no such
// param). Only forward it for add_comment so the UI can't imply an option
// that has no effect elsewhere.
if (params.operation === 'add_comment' && typeof contentType === 'string' && contentType) {
result.contentType = contentType
}
result.contentType =
params.operation === 'add_comment' && typeof contentType === 'string' && contentType
? contentType
: undefined
// Zoho documents from >= 0 and limit >= 1 as integers; a negative or
// fractional value reaches the API as an opaque provider error, so drop
@@ -463,21 +621,16 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
// `null` is checked explicitly: the serializer initializes untouched
// subBlocks to null, and Number(null) is 0 — which would otherwise inject
// from=0 on every operation instead of leaving the param unset.
if (rawFrom !== undefined && rawFrom !== null && rawFrom !== '') {
const from = Number(rawFrom)
if (Number.isInteger(from) && from >= 0) result.from = from
}
if (rawLimit !== undefined && rawLimit !== null && rawLimit !== '') {
const limit = Number(rawLimit)
if (Number.isInteger(limit) && limit >= 1) result.limit = limit
}
result.from = toPaginationValue(rawFrom, 0)
result.limit = toPaginationValue(rawLimit, 1)
// Gated for the same reason as contentType above: isPublic carries a
// defaultValue, so forwarding it unconditionally would serialize a
// comment-only field onto every other operation's params. Destructured
// out of `rest` so the default never reaches non-comment operations.
if (params.operation === 'add_comment' && isPublic !== undefined) {
result.isPublic = isPublic === true || isPublic === 'true'
}
result.isPublic =
params.operation === 'add_comment' && isPublic !== undefined
? isPublic === true || isPublic === 'true'
: undefined
// Gated to update_ticket for the same reason as contentType and isPublic
// above: the subBlock keeps its value when the operation changes, so
// stale (or half-typed) JSON left behind after switching away from
@@ -498,27 +651,57 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
: params.operation === 'update_ticket'
? rawPriority
: undefined
if (activeStatus !== undefined && activeStatus !== null && activeStatus !== '') {
result.status = activeStatus
}
if (activePriority !== undefined && activePriority !== null && activePriority !== '') {
result.priority = activePriority
}
result.status = orUndefined(activeStatus)
result.priority = orUndefined(activePriority)
if (params.operation === 'update_ticket' && rawCustomFields !== undefined) {
if (typeof rawCustomFields === 'string') {
if (rawCustomFields.trim()) {
try {
result.customFields = JSON.parse(rawCustomFields)
} catch {
throw new Error('Invalid JSON provided for custom fields')
}
}
} else if (rawCustomFields !== null) {
// Already an object when an agent supplies it directly.
result.customFields = rawCustomFields
}
}
// sortBy and include are per-operation for the same reason: three list
// endpoints accept three different sort fields, and get_contact accepts a
// different `include` vocabulary than the ticket endpoints. A subBlock
// keeps its value across an operation change, so an ungated spread would
// send list_tickets' `createdTime` to list_comments, which Zoho rejects.
const activeSortBy =
params.operation === 'list_tickets'
? rawSortBy
: params.operation === 'list_comments'
? rawCommentSortBy
: params.operation === 'list_threads'
? rawThreadSortBy
: undefined
result.sortBy = orUndefined(activeSortBy)
// Get Ticket falls back to the legacy shared `include`: workflows saved
// before the split stored their value there, and dropping it would
// silently stop embedding what they asked for. The fallback is one-way
// and safe - Get Ticket accepts every value List Tickets does, plus
// `contract` and `skills` - while List Tickets never reads
// `ticketInclude`, so those two extra tokens can still never reach it.
const activeInclude =
params.operation === 'list_tickets'
? rawInclude
: params.operation === 'get_ticket'
? (orUndefined(rawTicketInclude) ?? rawInclude)
: params.operation === 'get_contact'
? rawContactInclude
: params.operation === 'get_thread'
? rawThreadInclude
: undefined
result.include = orUndefined(activeInclude)
// Gated for the same stale-value reason as the filters above: these three
// are list_tickets-only query params, and no other operation declares them.
const isListTickets = params.operation === 'list_tickets'
result.assignee = isListTickets ? orUndefined(rawAssigneeFilter) : undefined
result.channel = isListTickets ? orUndefined(rawChannelFilter) : undefined
// Forward whatever was supplied and let the tool judge it. Filtering here
// on shape would swallow 30.5 or a non-numeric value, and the tool would
// then run without the filter and return the entire queue as though the
// requested window had applied. Only the empty "Any time" option is
// dropped, because that genuinely means no filter.
const receivedInDays = isListTickets ? orUndefined(rawReceivedInDays) : undefined
result.receivedInDays = receivedInDays === undefined ? undefined : Number(receivedInDays)
result.customFields =
params.operation === 'update_ticket' ? parseCustomFields(rawCustomFields) : undefined
return result
},
},
@@ -537,6 +720,12 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
status: { type: 'string', description: 'Ticket status to set' },
statusFilter: { type: 'string', description: 'Status filter for listing tickets' },
priorityFilter: { type: 'string', description: 'Priority filter for listing tickets' },
assigneeFilter: { type: 'string', description: 'Assignee filter for listing tickets' },
channelFilter: { type: 'string', description: 'Channel filter for listing tickets' },
receivedInDays: {
type: 'number',
description: 'Only tickets with a customer response in the last N days',
},
priority: { type: 'string', description: 'Ticket priority' },
assigneeId: { type: 'string', description: 'Assignee (agent) ID' },
description: { type: 'string', description: 'Ticket description' },
@@ -550,8 +739,13 @@ export const ZohoDeskBlock: BlockConfig<ZohoDeskResponse> = {
customFields: { type: 'json', description: 'Custom field values' },
href: { type: 'string', description: 'Attachment download href' },
fileName: { type: 'string', description: 'Downloaded file name' },
include: { type: 'string', description: 'Related data to include' },
sortBy: { type: 'string', description: 'Sort field' },
include: { type: 'string', description: 'Related data to include when listing tickets' },
ticketInclude: { type: 'string', description: 'Related data to include on a single ticket' },
contactInclude: { type: 'string', description: 'Related data to include on a contact' },
threadInclude: { type: 'string', description: 'Related data to include on a thread' },
sortBy: { type: 'string', description: 'Sort field for listing tickets' },
commentSortBy: { type: 'string', description: 'Sort field for listing comments' },
threadSortBy: { type: 'string', description: 'Sort field for listing threads' },
from: { type: 'number', description: 'Pagination start index' },
limit: { type: 'number', description: 'Maximum results' },
},
@@ -659,7 +853,7 @@ export const ZohoDeskBlockMeta = {
description:
'Read an incoming Zoho Desk ticket, classify it, and set priority, classification, category, and assignee.',
content:
'# Triage a Zoho Desk Ticket\n\nClassify a newly created ticket and route it to the right owner.\n\n## Steps\n1. If the organization ID is unknown, List Organizations and pick the portal to work in.\n2. Get Ticket for the ticket ID and read subject, descriptionText, channel, and status.\n3. Decide the urgency and the owning team from the content, and pick a classification of Problem, Request, Question, or Others.\n4. Update Ticket to set priority, classification, category and subCategory, and the assigneeId or departmentId that should own it.\n5. Add Comment as an internal note explaining the triage decision so the agent who picks it up has the reasoning.\n\n## Output\nReport the ticket ID and number, the classification and priority set, the assignee or department it was routed to, and anything ambiguous that needs a human decision.',
"# Triage a Zoho Desk Ticket\n\nClassify a newly created ticket and route it to the right owner.\n\n## Steps\n1. If the organization ID is unknown, List Organizations and pick the portal to work in.\n2. Get Ticket for the ticket ID and read subject, descriptionText, channel, and status.\n3. Decide the urgency and the owning team from the content, and pick a classification — Problem, Request and Question are Zoho's system-defined values, but the portal may define its own.\n4. Update Ticket to set priority, classification, category and subCategory, and the assigneeId or departmentId that should own it.\n5. Add Comment as an internal note explaining the triage decision so the agent who picks it up has the reasoning.\n\n## Output\nReport the ticket ID and number, the classification and priority set, the assignee or department it was routed to, and anything ambiguous that needs a human decision.",
},
{
name: 'escalate-overdue-tickets',
@@ -17,6 +17,11 @@ export const CLIENT_CREDENTIAL_ACCOUNT_SECRET_TYPE = 'client_credential_account'
/** Contract field ids a client-credential connect modal collects. */
export type ClientCredentialAccountFieldId = 'clientId' | 'clientSecret' | 'orgId' | 'dataCenter'
export interface ClientCredentialAccountOption {
value: string
label: string
}
export interface ClientCredentialAccountField {
id: ClientCredentialAccountFieldId
label: string
@@ -29,6 +34,15 @@ export interface ClientCredentialAccountField {
* validation never demands it. Omitted (default) means required.
*/
optional?: boolean
/**
* Fixed value set, rendered by the connect modal as a dropdown — which removes
* the need for a format hint on the field. Required on `dataCenter`: the modal
* renders that field only when it carries options, so a region selector added
* without them would silently not appear.
*/
options?: ReadonlyArray<ClientCredentialAccountOption>
/** Always-visible guidance, for a field whose value is not self-explanatory. */
hint?: string
/** Soft-format hint shown while the current value doesn't match `hintPattern`. */
hintPattern?: RegExp
hintMessage?: string
@@ -152,6 +166,26 @@ export const ZOHO_DESK_DATA_CENTER_IDS = Object.keys(
ZOHO_DESK_DATA_CENTERS
) as ZohoDeskDataCenterId[]
/** Region names, paired with the ids so a label can never name the wrong host. */
const ZOHO_DESK_DATA_CENTER_LABELS: Record<ZohoDeskDataCenterId, string> = {
us: 'United States',
eu: 'Europe',
in: 'India',
au: 'Australia',
}
/**
* Connect-modal dropdown options, derived from {@link ZOHO_DESK_DATA_CENTERS} so
* adding a region cannot leave the picker behind. Each label carries the accounts
* host the region mints against, which is what an admin recognizes from the URL
* they sign in to Zoho with.
*/
export const ZOHO_DESK_DATA_CENTER_OPTIONS: ReadonlyArray<ClientCredentialAccountOption> =
ZOHO_DESK_DATA_CENTER_IDS.map((id) => ({
value: id,
label: `${ZOHO_DESK_DATA_CENTER_LABELS[id]} (${ZOHO_DESK_DATA_CENTERS[id].accountsBase.replace('https://', '')})`,
}))
/** Accepts exactly the supported region codes, case-insensitively after normalization. */
export const ZOHO_DESK_DATA_CENTER_REGEX = new RegExp(`^(${ZOHO_DESK_DATA_CENTER_IDS.join('|')})$`)
@@ -194,7 +228,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
{
id: 'clientId',
label: 'Client ID',
placeholder: 'Client ID from the App Credentials page',
placeholder: 'Paste the client ID',
secret: false,
},
{
@@ -206,13 +240,13 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
{
id: 'orgId',
label: 'Account ID',
placeholder: 'Account ID from the App Credentials page',
placeholder: 'Paste the account ID',
secret: false,
},
],
docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account',
helpText:
"Copy all three values from the Server-to-Server OAuth app's App Credentials page — the Account ID there is not the account number shown in the Zoom web portal. The app must be activated before tokens can be issued.",
'The Account ID on the App Credentials page is not the account number shown in the Zoom web portal. The app must be activated before tokens can be issued.',
},
[BOX_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: BOX_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -222,7 +256,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
{
id: 'clientId',
label: 'Client ID',
placeholder: 'Client ID from Configuration > OAuth 2.0 Credentials',
placeholder: 'Paste the client ID',
secret: false,
},
{
@@ -252,7 +286,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
{
id: 'clientId',
label: 'Consumer key',
placeholder: "Consumer Key from the Connected App's Manage Consumer Details page",
placeholder: 'Paste the consumer key',
secret: false,
},
{
@@ -274,7 +308,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
],
docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account',
helpText:
'The Connected App must have "Enable Client Credentials Flow" checked with a "Run As" integration user set under Edit Policies — every call executes with that user\'s permissions, and deactivating or freezing the user stops all runs. Selecting the "openid" scope lets Sim record which run-as user the credential authenticates as; without it the connection still works but the identity is not captured.',
'Every call executes as the Connected App\'s "Run As" user, so deactivating or freezing that user stops all runs. Without the "openid" scope the connection still works, but Sim cannot record which user it authenticates as.',
},
[ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: ZOHO_DESK_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -284,7 +318,7 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
{
id: 'clientId',
label: 'Client ID',
placeholder: "Client ID from the Self Client's Client Secret tab",
placeholder: 'Paste the client ID',
secret: false,
},
{
@@ -301,22 +335,22 @@ export const CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS: Record<
hintPattern: ZOHO_DESK_SOID_REGEX,
hintNormalize: normalizeZohoDeskSoid,
hintMessage:
'Paste the numeric Zoho Desk organization ID from Setup → Developer Space → API, or the full ZohoDesk.<orgId> value.',
'Expected a numeric organization ID like 600123456, or a full ZohoDesk.600123456 value.',
},
{
id: 'dataCenter',
label: 'Data center',
placeholder: 'us',
// Deliberately region-neutral: on a reconnect an unset value keeps the
// credential's stored region, so naming a region here would be wrong.
placeholder: 'Select a data center',
secret: false,
optional: true,
hintPattern: ZOHO_DESK_DATA_CENTER_REGEX,
hintNormalize: normalizeZohoDeskDataCenter,
hintMessage: `Enter one of ${ZOHO_DESK_DATA_CENTER_IDS.join(', ')}. Leave blank for us (accounts.zoho.com).`,
options: ZOHO_DESK_DATA_CENTER_OPTIONS,
hint: 'The region your Zoho account signs in to. New credentials default to the United States.',
},
],
docsUrl: 'https://docs.sim.ai/integrations/zoho-desk-service-account',
helpText:
'Create the Self Client in the Zoho API Console, add the Zoho Desk scopes, and use the organization ID from Setup → Developer Space → API. Self Clients work with the US, EU, IN, and AU data centers — leave the data center blank for US. Zoho Desk triggers still require an OAuth connection, which is US-only.',
helpText: 'Zoho Desk triggers still require an OAuth connection, which is US-only.',
},
}
@@ -270,7 +270,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
],
docsUrl: 'https://docs.sim.ai/integrations/shopify-service-account',
helpText:
'Legacy admin-created custom apps reveal the shpat_ token once; new Dev Dashboard apps issue tokens via OAuth, not a UI reveal. The token is store-bound and does not expire.',
'The token is revealed once, is bound to a single store, and does not expire. Dev Dashboard apps issue tokens through OAuth rather than a UI reveal.',
invalidCredentialsHelp:
'Shopify rejected this token. Make sure you copied the Admin API access token (starts with shpat_) — not the API key or API secret key — for an app installed on this exact store domain, and that it has not since been revoked or regenerated.',
},
@@ -289,7 +289,7 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
],
docsUrl: 'https://docs.sim.ai/integrations/webflow-service-account',
helpText:
'Create the token with at least the sites:read and CMS read/write scopes. Site tokens expire after 365 days without API activity, and each token grants access to a single site.',
'Site tokens expire after 365 days without API activity, and each token grants access to a single site.',
},
[TRELLO_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: TRELLO_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -300,15 +300,13 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
{
id: 'apiToken',
label: 'API token',
placeholder: 'ATTA...',
placeholder: 'Paste API token',
secret: true,
hintPattern: /^ATTA/,
hintMessage: 'Trello API tokens usually start with ATTA.',
},
],
docsUrl: 'https://docs.sim.ai/integrations/trello-service-account',
helpText:
"Generate the token with the setup guide's authorize link (expiration=never) so it works with Sim and doesn't expire.",
'A read-only or short-expiration token validates here and then fails at run time — Sim cannot tell either from the token itself.',
},
[CALCOM_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: CALCOM_SERVICE_ACCOUNT_PROVIDER_ID,
@@ -326,7 +324,8 @@ export const TOKEN_SERVICE_ACCOUNT_DESCRIPTORS: Record<
},
],
docsUrl: 'https://docs.sim.ai/integrations/calcom-service-account',
helpText: 'Choose a non-expiring key (or note the expiry date) when creating it in Cal.com.',
helpText:
'Cal.com preselects a 30-day expiry when you create a key — switch on "Never expires" or runs stop on that date.',
},
[WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID]: {
providerId: WEALTHBOX_SERVICE_ACCOUNT_PROVIDER_ID,
+6 -1
View File
@@ -1116,7 +1116,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
serviceAccountProviderId: 'zoho-desk-service-account',
icon: ZohoDeskIcon,
baseProviderIcon: ZohoDeskIcon,
// Kept to exactly what the tools and the webhook trigger exercise:
// Kept to what the tools and the webhook trigger exercise. NOTE: Zoho
// lists `Desk.organization.READ , Desk.basic.READ` for GET /organizations
// and `Desk.departments.READ , Desk.basic.READ` for GET /departments, and
// does not document whether that comma means AND or OR. Both bootstrap
// endpoints are assumed covered by Desk.basic.READ alone - verify against
// a live Desk org and widen here if either returns SCOPE_MISMATCH.
// tickets (incl. threads/comments), contacts (get_contact), basic
// (list_organizations), agents (the `assigneeId` picker lists agents),
// webhook create/delete (the trigger provisions and tears down its own
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -5,6 +5,7 @@ import {
buildZohoDeskHeaders,
getZohoDeskApiBase,
getZohoDeskErrorMessage,
normalizeZohoDeskCommaList,
requireZohoDeskId,
} from '@/tools/zoho_desk/utils'
@@ -41,11 +42,22 @@ export const zohoDeskGetContactTool: ToolConfig<ZohoDeskGetContactParams, ZohoDe
visibility: 'user-or-llm',
description: 'Contact ID to retrieve',
},
include: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Comma-separated related data to embed. Allowed: accounts, owner',
},
},
request: {
url: (params) =>
`${getZohoDeskApiBase(params)}/contacts/${encodeURIComponent(requireZohoDeskId(params.contactId, 'Contact ID'))}`,
url: (params) => {
const query = new URLSearchParams()
const include = normalizeZohoDeskCommaList(params.include)
if (include) query.set('include', include)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/contacts/${encodeURIComponent(requireZohoDeskId(params.contactId, 'Contact ID'))}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => buildZohoDeskHeaders(params),
},
+15 -2
View File
@@ -5,6 +5,7 @@ import {
buildZohoDeskHeaders,
getZohoDeskApiBase,
getZohoDeskErrorMessage,
normalizeZohoDeskCommaList,
requireZohoDeskId,
withDerivedContentText,
} from '@/tools/zoho_desk/utils'
@@ -48,11 +49,23 @@ export const zohoDeskGetThreadTool: ToolConfig<ZohoDeskGetThreadParams, ZohoDesk
visibility: 'user-or-llm',
description: 'Thread ID',
},
include: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
"Related data to embed. Allowed: plainText — Zoho's own plain-text rendering of the thread",
},
},
request: {
url: (params) =>
`${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/threads/${encodeURIComponent(requireZohoDeskId(params.threadId, 'Thread ID'))}`,
url: (params) => {
const query = new URLSearchParams()
const include = normalizeZohoDeskCommaList(params.include)
if (include) query.set('include', include)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/threads/${encodeURIComponent(requireZohoDeskId(params.threadId, 'Thread ID'))}${qs ? `?${qs}` : ''}`
},
method: 'GET',
headers: (params) => buildZohoDeskHeaders(params),
},
+3 -1
View File
@@ -5,6 +5,7 @@ import {
buildZohoDeskHeaders,
getZohoDeskApiBase,
getZohoDeskErrorMessage,
normalizeZohoDeskCommaList,
requireZohoDeskId,
withDerivedContentText,
} from '@/tools/zoho_desk/utils'
@@ -54,7 +55,8 @@ export const zohoDeskGetTicketTool: ToolConfig<ZohoDeskGetTicketParams, ZohoDesk
request: {
url: (params) => {
const query = new URLSearchParams()
if (params.include) query.set('include', params.include)
const include = normalizeZohoDeskCommaList(params.include)
if (include) query.set('include', include)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}${qs ? `?${qs}` : ''}`
},
@@ -54,6 +54,13 @@ export const zohoDeskListCommentsTool: ToolConfig<ZohoDeskListCommentsParams, Zo
visibility: 'user-or-llm',
description: 'Number of comments to return (1-100, default 50)',
},
sortBy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Sort by commentedTime. Ascending by default; prefix with - for descending (-commentedTime).',
},
},
request: {
@@ -61,6 +68,7 @@ export const zohoDeskListCommentsTool: ToolConfig<ZohoDeskListCommentsParams, Zo
const query = new URLSearchParams()
if (params.from !== undefined) query.set('from', String(params.from))
if (params.limit !== undefined) query.set('limit', String(params.limit))
if (params.sortBy) query.set('sortBy', params.sortBy)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/comments${qs ? `?${qs}` : ''}`
},
+8
View File
@@ -55,6 +55,13 @@ export const zohoDeskListThreadsTool: ToolConfig<ZohoDeskListThreadsParams, Zoho
visibility: 'user-or-llm',
description: 'Number of threads to return (1-200, default 100)',
},
sortBy: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Sort by sendDateTime. Zoho sorts descending (newest first) when unset; pass sendDateTime for oldest first.',
},
},
request: {
@@ -62,6 +69,7 @@ export const zohoDeskListThreadsTool: ToolConfig<ZohoDeskListThreadsParams, Zoho
const query = new URLSearchParams()
if (params.from !== undefined) query.set('from', String(params.from))
if (params.limit !== undefined) query.set('limit', String(params.limit))
if (params.sortBy) query.set('sortBy', params.sortBy)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/tickets/${encodeURIComponent(requireZohoDeskId(params.ticketId, 'Ticket ID'))}/threads${qs ? `?${qs}` : ''}`
},
@@ -0,0 +1,248 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { ZohoDeskBlock } from '@/blocks/blocks/zoho-desk'
import { zohoDeskListTicketsTool } from '@/tools/zoho_desk/list_tickets'
describe('zohoDeskListTicketsTool request url', () => {
const base = { accessToken: 'tok', orgId: '700' }
const buildUrl = zohoDeskListTicketsTool.request.url as (p: Record<string, unknown>) => string
const queryOf = (params: Record<string, unknown>) =>
new URL(buildUrl({ ...base, ...params })).searchParams
it('omits the query string entirely when no filters are set', () => {
expect(buildUrl(base)).toBe('https://desk.zoho.com/api/v1/tickets')
})
it('forwards the documented filters', () => {
const query = queryOf({ assignee: 'Unassigned', channel: 'Email,Web', receivedInDays: 30 })
expect(query.get('assignee')).toBe('Unassigned')
expect(query.get('channel')).toBe('Email,Web')
expect(query.get('receivedInDays')).toBe('30')
})
// Zoho documents exactly 15/30/90. `receivedInDays` is LLM-writable, so a
// dropped out-of-range value would hand back the whole unfiltered queue while
// the caller believes it was filtered - fail instead of lying.
it('rejects a receivedInDays value Zoho does not accept', () => {
for (const receivedInDays of [7, 0, 45, 91, 30.5, '7', 'abc']) {
expect(() => buildUrl({ ...base, receivedInDays })).toThrow(/must be 15, 30, or 90/)
}
})
// The tool layer does not coerce declared param types, and the agent tool
// panel stores every picked value as a string - so the documented values must
// survive arriving as '15' / '30' / '90'.
it('accepts the string form the agent tool panel stores', () => {
expect(queryOf({ receivedInDays: '30' }).get('receivedInDays')).toBe('30')
})
it('collapses "a, b" to "a,b" on every comma-separated filter', () => {
const query = queryOf({
include: 'contacts, assignee',
channel: 'Email, Web',
status: 'Open, On Hold',
})
expect(query.get('include')).toBe('contacts,assignee')
expect(query.get('channel')).toBe('Email,Web')
// Interior spaces survive - "On Hold" is one status, not two.
expect(query.get('status')).toBe('Open,On Hold')
})
})
/**
* Models the real block-to-tool seam. The executor merges the mapper's output
* ON TOP of the serialized inputs (`{ ...inputs, ...transformedParams }` in
* executor/handlers/generic/generic-handler.ts), so the mapper can overwrite a
* key but never remove one. A test that passes only the mapper's return value
* would miss every stale input the merge restores — which is precisely the
* class of bug these cover.
*/
function throughSeam(inputs: Record<string, unknown>): string {
const buildParams = ZohoDeskBlock.tools.config?.params
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
const buildUrl = zohoDeskListTicketsTool.request.url as (p: Record<string, unknown>) => string
const withCreds = { accessToken: 'tok', orgId: '700', ...inputs }
const transformed = buildParams(withCreds) as Record<string, unknown>
return buildUrl({ ...withCreds, ...transformed })
}
describe('ZohoDeskBlock receivedInDays reaches the tool intact', () => {
const throughBlock = (receivedInDays: unknown) =>
throughSeam({ operation: 'list_tickets', receivedInDays })
it('carries a supported window through to the query', () => {
expect(new URL(throughBlock('30')).searchParams.get('receivedInDays')).toBe('30')
})
it('lets an unsupported value reach the tool and throw instead of silently unfiltering', () => {
for (const value of [7, '7', 30.5, 'abc']) {
expect(() => throughBlock(value)).toThrow(/must be 15, 30, or 90/)
}
})
it('treats the "Any time" option as no filter at all', () => {
expect(new URL(throughBlock('')).searchParams.has('receivedInDays')).toBe(false)
})
})
/**
* Get Ticket accepts `contract` and `skills`; List Tickets does not document
* either. A shared subBlock would carry them across an operation switch and put
* an undocumented token on the wire, so each endpoint owns its own field.
*/
describe('ZohoDeskBlock keeps the two include vocabularies apart', () => {
const buildParams = ZohoDeskBlock.tools.config?.params
const merged = (operation: string) => {
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
const inputs = {
operation,
include: 'contacts,assignee',
ticketInclude: 'contacts,skills',
}
return { ...inputs, ...(buildParams(inputs) as Record<string, unknown>) }
}
it('sends the list vocabulary to list_tickets', () => {
expect(merged('list_tickets').include).toBe('contacts,assignee')
})
it('sends the single-ticket vocabulary to get_ticket', () => {
expect(merged('get_ticket').include).toBe('contacts,skills')
})
it('never leaks skills onto the list endpoint', () => {
expect(merged('list_tickets').include).not.toContain('skills')
})
// A workflow saved before the split stored its value under `include`. Dropping
// it would silently stop embedding what the workflow asked for.
it('falls back to the legacy include for a workflow saved before the split', () => {
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
const legacy = { operation: 'get_ticket', ticketId: '1', include: 'contacts,products' }
const result = { ...legacy, ...(buildParams(legacy) as Record<string, unknown>) }
expect(result.include).toBe('contacts,products')
})
it('prefers the new field once the workflow sets it', () => {
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
const both = {
operation: 'get_ticket',
ticketId: '1',
include: 'contacts',
ticketInclude: 'skills',
}
const result = { ...both, ...(buildParams(both) as Record<string, unknown>) }
expect(result.include).toBe('skills')
})
})
/**
* A `mode: 'advanced'` subBlock keeps its value when the operation changes, and
* the serializer emits it for ANY operation while the block's advanced toggle is
* off — it returns on `isNonEmptyValue` without evaluating the subBlock's
* `condition`. So these stale values genuinely arrive under the wrong operation,
* and the mapper has to overwrite them rather than merely decline to set them.
*/
describe('ZohoDeskBlock overwrites stale advanced values from another operation', () => {
const buildParams = ZohoDeskBlock.tools.config?.params
const merged = (inputs: Record<string, unknown>) => {
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
return { ...inputs, ...(buildParams(inputs) as Record<string, unknown>) }
}
it('does not carry a List Tickets sort field onto List Comments', () => {
const result = merged({ operation: 'list_comments', ticketId: '1', sortBy: 'createdTime' })
expect(result.sortBy).toBeUndefined()
})
it('does not carry a ticket include onto Get Contact or Get Thread', () => {
expect(
merged({ operation: 'get_contact', contactId: '9', include: 'contacts,assignee' }).include
).toBeUndefined()
expect(
merged({ operation: 'get_thread', ticketId: '1', threadId: '2', include: 'contacts' }).include
).toBeUndefined()
})
it('does not carry list-only filters onto another operation', () => {
const result = merged({
operation: 'get_ticket',
ticketId: '1',
assigneeFilter: 'Unassigned',
channelFilter: 'Email',
receivedInDays: '30',
})
expect(result.assignee).toBeUndefined()
expect(result.channel).toBeUndefined()
expect(result.receivedInDays).toBeUndefined()
})
it('discards an out-of-range pagination value instead of forwarding it', () => {
const result = merged({ operation: 'list_tickets', from: '-5', limit: '0' })
expect(result.from).toBeUndefined()
expect(result.limit).toBeUndefined()
})
it('survives an emptied department multi-select without throwing', () => {
expect(() => throughSeam({ operation: 'list_tickets', departmentIds: [] })).not.toThrow()
expect(
new URL(throughSeam({ operation: 'list_tickets', departmentIds: [] })).searchParams.has(
'departmentIds'
)
).toBe(false)
})
it('does not throw on the "Any time" option the dropdown seeds', () => {
expect(() => throughSeam({ operation: 'list_tickets', receivedInDays: '' })).not.toThrow()
})
})
/**
* On the agent-tool path `operation` is a sibling of the tool call rather than a
* member of params, so the mapper cannot scope by it — and must not try. The
* model addresses tool params by their real names, and the tool has already been
* selected, so scoping there would erase the model's own arguments.
*/
describe('ZohoDeskBlock leaves agent-supplied params alone', () => {
const buildParams = ZohoDeskBlock.tools.config?.params
const merged = (llmArgs: Record<string, unknown>) => {
if (!buildParams) throw new Error('ZohoDeskBlock is missing tools.config.params')
return { ...llmArgs, ...(buildParams({ ...llmArgs }) as Record<string, unknown>) }
}
it('preserves every tool param the model supplied', () => {
const result = merged({
status: 'Open',
priority: 'High',
sortBy: '-createdTime',
include: 'contacts',
assignee: 'Unassigned',
channel: 'Email',
receivedInDays: 30,
ticketId: '123',
})
expect(result).toMatchObject({
status: 'Open',
priority: 'High',
sortBy: '-createdTime',
include: 'contacts',
assignee: 'Unassigned',
channel: 'Email',
receivedInDays: 30,
ticketId: '123',
})
})
it('still coerces a JSON custom-field string, which is a type fix not a gate', () => {
expect(merged({ customFields: '{"cf_severity":"High"}' }).customFields).toEqual({
cf_severity: 'High',
})
expect(() => merged({ customFields: '{not json' })).toThrow(/Invalid JSON/)
})
})
+63 -7
View File
@@ -5,8 +5,12 @@ import {
buildZohoDeskHeaders,
getZohoDeskApiBase,
getZohoDeskErrorMessage,
normalizeZohoDeskCommaList,
} from '@/tools/zoho_desk/utils'
/** The only `receivedInDays` windows Zoho documents. */
const ZOHO_DESK_RECEIVED_IN_DAYS = new Set([15, 30, 90])
export const zohoDeskListTicketsTool: ToolConfig<ZohoDeskListTicketsParams, ZohoDeskResponse> = {
id: 'zoho_desk_list_tickets',
name: 'Zoho Desk List Tickets',
@@ -39,13 +43,13 @@ export const zohoDeskListTicketsTool: ToolConfig<ZohoDeskListTicketsParams, Zoho
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Pagination start index (0-based, max 4999)',
description: 'Pagination start index (0-based)',
},
limit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Number of tickets to return (1-100, default 10)',
description: 'Number of tickets to return (1-100)',
},
departmentIds: {
type: 'string',
@@ -66,6 +70,31 @@ export const zohoDeskListTicketsTool: ToolConfig<ZohoDeskListTicketsParams, Zoho
visibility: 'user-or-llm',
description: 'Filter by priority. Comma-separate to match multiple (e.g. "High,Urgent")',
},
assignee: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by assignee: an agent ID, or "Unassigned". Comma-separate to match multiple.',
},
channel: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description:
'Filter by origin channel, spelled as your portal spells it. Comma-separate to match multiple.',
},
receivedInDays: {
type: 'number',
required: false,
visibility: 'user-or-llm',
// Named for receipt, but Zoho documents it against customerResponseTime:
// "Time period (in days) for fetching tickets based on customerResponseTime".
// Describing it as "received" would silently drop every ticket the
// customer has not replied to recently.
description:
'Only tickets whose last customer response was within the last 15, 30, or 90 days (Zoho filters on customerResponseTime, despite the name)',
},
sortBy: {
type: 'string',
required: false,
@@ -87,13 +116,40 @@ export const zohoDeskListTicketsTool: ToolConfig<ZohoDeskListTicketsParams, Zoho
const query = new URLSearchParams()
if (params.from !== undefined) query.set('from', String(params.from))
if (params.limit !== undefined) query.set('limit', String(params.limit))
// Zoho names this query param `departmentIds` (plural). A singular
// Every comma-separated filter goes through the same normalizer so a
// pasted "High, Urgent" never reaches Zoho with the separator space
// encoded. Interior spaces survive, so "On Hold" still matches.
// Zoho names the department param `departmentIds` (plural). A singular
// `departmentId` is silently ignored, returning every department's tickets.
if (params.departmentIds) query.set('departmentIds', params.departmentIds)
if (params.status) query.set('status', params.status)
if (params.priority) query.set('priority', params.priority)
const commaFilters = {
departmentIds: params.departmentIds,
status: params.status,
priority: params.priority,
assignee: params.assignee,
channel: params.channel,
}
for (const [key, raw] of Object.entries(commaFilters)) {
const value = normalizeZohoDeskCommaList(raw)
if (value) query.set(key, value)
}
// Zoho documents exactly 15, 30 and 90. Fail loudly rather than dropping
// the filter: this param is LLM-writable, so an agent asked for "the last
// week" plausibly sends 7 — and silently omitting it would return the
// entire unfiltered queue presented as a filtered result.
// Coerced, not just checked: the tool layer does not enforce declared
// param types, and the agent tool panel stores every picked value as a
// string — so a user choosing "Last 30 days" sends '30', which a Set of
// numbers would reject as invalid.
if (params.receivedInDays !== undefined && params.receivedInDays !== null) {
const receivedInDays = Number(params.receivedInDays)
if (!ZOHO_DESK_RECEIVED_IN_DAYS.has(receivedInDays)) {
throw new Error('receivedInDays must be 15, 30, or 90.')
}
query.set('receivedInDays', String(receivedInDays))
}
if (params.sortBy) query.set('sortBy', params.sortBy)
if (params.include) query.set('include', params.include)
const include = normalizeZohoDeskCommaList(params.include)
if (include) query.set('include', include)
const qs = query.toString()
return `${getZohoDeskApiBase(params)}/tickets${qs ? `?${qs}` : ''}`
},
+14 -1
View File
@@ -18,6 +18,9 @@ export interface ZohoDeskListTicketsParams extends ZohoDeskBaseParams {
departmentIds?: string
status?: string
priority?: string
assignee?: string
channel?: string
receivedInDays?: number
sortBy?: string
include?: string
}
@@ -47,6 +50,7 @@ export interface ZohoDeskListCommentsParams extends ZohoDeskBaseParams {
ticketId: string
from?: number
limit?: number
sortBy?: string
}
export interface ZohoDeskAddCommentParams extends ZohoDeskBaseParams {
@@ -60,15 +64,18 @@ export interface ZohoDeskListThreadsParams extends ZohoDeskBaseParams {
ticketId: string
from?: number
limit?: number
sortBy?: string
}
export interface ZohoDeskGetThreadParams extends ZohoDeskBaseParams {
ticketId: string
threadId: string
include?: string
}
export interface ZohoDeskGetContactParams extends ZohoDeskBaseParams {
contactId: string
include?: string
}
export type ZohoDeskListOrganizationsParams = Pick<ZohoDeskBaseParams, 'accessToken' | 'apiDomain'>
@@ -146,6 +153,12 @@ export const ZOHO_DESK_TICKET_PROPERTIES: Record<string, ToolOutputProperty> = {
},
createdTime: { type: 'string', description: 'Created timestamp', optional: true },
modifiedTime: { type: 'string', description: 'Last modified timestamp', optional: true },
customerResponseTime: {
type: 'string',
description: 'Time the last customer response was received',
optional: true,
nullable: true,
},
closedTime: { type: 'string', description: 'Closed timestamp', optional: true, nullable: true },
resolution: { type: 'string', description: 'Resolution text', optional: true, nullable: true },
threadCount: { type: 'string', description: 'Number of threads', optional: true },
@@ -258,7 +271,7 @@ export const ZOHO_DESK_THREAD_PROPERTIES: Record<string, ToolOutputProperty> = {
},
status: {
type: 'string',
description: 'Delivery status of an outgoing thread (SUCCESS/FAILED/DRAFT)',
description: 'Delivery status of the thread (e.g. SUCCESS, PENDING, FAILED, DRAFT)',
optional: true,
},
isDescriptionThread: {
+15 -11
View File
@@ -19,12 +19,13 @@ import {
* `{"subject": null, "status": "Closed"}`, which either fails the PATCH or blanks
* the ticket's subject.
*
* An empty string is NOT treated as unset. Zoho documents `""` as its idiom for
* clearing a field - its own PATCH sample carries `"classification": ""` and
* `"productId": ""` - so collapsing `''` into "leave unchanged" would make
* clearing `classification`, `category`, `subCategory`, `resolution` or
* `description` impossible through this tool. `null` (never touched) and `''`
* (deliberately emptied) are different intents and are kept distinct.
* An empty string is NOT treated as unset. Zoho's own PATCH sample payload
* carries `"classification": ""` and `"productId": ""`, so `''` is the only
* clear-a-field signal the API surfaces (Zoho states no prose contract for it) -
* collapsing `''` into "leave unchanged" would make clearing `classification`,
* `category`, `subCategory`, `resolution` or `description` impossible through
* this tool. `null` (never touched) and `''` (deliberately emptied) are
* different intents and are kept distinct.
*/
function omitUnset(fields: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {}
@@ -132,7 +133,11 @@ export const zohoDeskUpdateTicketTool: ToolConfig<ZohoDeskUpdateTicketParams, Zo
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Ticket classification: Problem, Request, Question, or Others',
// Not a closed set: Zoho marks the field `x-dynamic-enum` and states
// "Custom values are also supported", so a portal can rename or replace
// the system-defined values entirely.
description:
'Ticket classification. Zoho\'s system-defined values are Problem, Request, and Question; portals can define custom values. Pass "" to clear it.',
},
customFields: {
type: 'json',
@@ -160,10 +165,9 @@ export const zohoDeskUpdateTicketTool: ToolConfig<ZohoDeskUpdateTicketParams, Zo
description: params.description,
resolution: params.resolution,
classification: params.classification,
// Zoho's ticket PATCH names the custom-field object `cf`. `customFields`
// exists only as a deprecated alias on other Desk resources (e.g. events)
// and on the separate validate-field-updates endpoint - sending it here
// is silently ignored, so the update reports success without applying.
// Zoho's ticket PATCH documents both `cf` and `customFields`, but marks
// `customFields` deprecated. Its own sample body sends `cf`, so that is
// what we use.
cf: params.customFields,
})
// Zoho rejects an empty PATCH; fail early with an actionable message
+59
View File
@@ -10,6 +10,7 @@ import {
deriveZohoContentText,
getZohoDeskApiBase,
getZohoDeskErrorMessage,
normalizeZohoDeskCommaList,
resolveZohoAttachmentUrl,
withDerivedContentText,
} from '@/tools/zoho_desk/utils'
@@ -76,6 +77,64 @@ describe('zoho desk tool utils', () => {
)
expect(getZohoDeskErrorMessage(null, 'fallback')).toBe('fallback')
})
it('names the offending fields from a validation failure', () => {
expect(
getZohoDeskErrorMessage(
{
errorCode: 'INVALID_DATA',
message: 'The data does not comply to the validation restrictions defined.',
errors: [
{ fieldName: '/contactId', errorType: 'invalid' },
{ fieldName: '/departmentId', errorType: 'invalid' },
],
},
'fallback'
)
).toBe(
'The data does not comply to the validation restrictions defined. (/contactId: invalid; /departmentId: invalid)'
)
})
it('accepts the prose errorMessage shape and skips unusable entries', () => {
expect(
getZohoDeskErrorMessage(
{
message: 'Invalid',
errors: [{ fieldName: '/status', errorMessage: 'is not a valid status' }, {}, null],
},
'fallback'
)
).toBe('Invalid (/status: is not a valid status)')
})
it('leaves the message untouched when there are no field errors', () => {
expect(getZohoDeskErrorMessage({ message: 'Not found', errors: [] }, 'fallback')).toBe(
'Not found'
)
})
})
describe('normalizeZohoDeskCommaList', () => {
it('collapses "a, b" to "a,b"', () => {
expect(normalizeZohoDeskCommaList('accounts, owner')).toBe('accounts,owner')
})
it('preserves interior spaces so multi-word filter values still match', () => {
expect(normalizeZohoDeskCommaList('Open, On Hold')).toBe('Open,On Hold')
})
it('accepts the array a multi-select subBlock stores', () => {
expect(normalizeZohoDeskCommaList(['a', 'b'])).toBe('a,b')
// An emptied picker stores `[]`; calling .split on it would throw.
expect(normalizeZohoDeskCommaList([])).toBeUndefined()
})
it('drops empty entries and returns undefined when nothing is left', () => {
expect(normalizeZohoDeskCommaList(' contacts , , assignee ')).toBe('contacts,assignee')
expect(normalizeZohoDeskCommaList(' ')).toBeUndefined()
expect(normalizeZohoDeskCommaList(undefined)).toBeUndefined()
})
})
describe('deriveAttachmentName', () => {
+60 -2
View File
@@ -142,6 +142,32 @@ export function resolveZohoAttachmentUrl(href: string, apiBase: string): URL {
return new URL(`${apiBase.replace(/\/+$/, '')}/${path}`)
}
/**
* Normalize a comma-separated Zoho query value to the bare `a,b` form Zoho's
* samples use. A pasted or LLM-written `accounts, owner` would otherwise reach
* Zoho as `accounts,+owner`; Zoho does not document whether it tolerates the
* separator space, so strip it rather than find out in production. Only the
* padding around each entry is removed, so multi-word values like `On Hold`
* survive intact. Returns `undefined` when nothing usable remains, so the
* caller omits the param entirely.
*/
export function normalizeZohoDeskCommaList(value: unknown): string | undefined {
// Accepts an array as well as a string: a multi-select subBlock stores its
// value as one, and an emptied picker stores `[]`. Calling `.split` on that
// would throw and take the whole run down.
const entries = Array.isArray(value)
? value
: typeof value === 'string'
? value.split(',')
: undefined
if (!entries) return undefined
const normalized = entries
.map((entry) => String(entry).trim())
.filter(Boolean)
.join(',')
return normalized || undefined
}
/**
* Trim an identifier destined for a URL path segment, rejecting a missing or
* whitespace-only value. A pasted trailing space would otherwise be encoded as
@@ -205,12 +231,44 @@ export function deriveAttachmentName(
return 'attachment'
}
/**
* Summarize the per-field entries Zoho attaches to a validation failure. Zoho
* documents the array form as `{"fieldName": "/contactId", "errorType": "invalid"}`;
* `errorMessage` is also accepted because other Desk surfaces carry a prose
* message under that key. Without this, every `INVALID_DATA` reads as the
* useless "The data does not comply to the validation restrictions defined."
* with no field named. A non-array `errors` value is ignored.
*/
function summarizeZohoDeskFieldErrors(errors: unknown): string {
if (!Array.isArray(errors)) return ''
const parts = errors
.map((entry) => {
if (!entry || typeof entry !== 'object') return undefined
const { fieldName, errorType, errorMessage } = entry as Record<string, unknown>
const detail =
typeof errorMessage === 'string' && errorMessage.trim()
? errorMessage
: typeof errorType === 'string' && errorType.trim()
? errorType
: undefined
if (!detail) return undefined
return typeof fieldName === 'string' && fieldName.trim() ? `${fieldName}: ${detail}` : detail
})
.filter((part): part is string => Boolean(part))
return parts.length > 0 ? ` (${parts.join('; ')})` : ''
}
/** Extract a human-readable error message from a Zoho Desk error response body. */
export function getZohoDeskErrorMessage(data: unknown, fallback: string): string {
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
if (typeof record.message === 'string' && record.message.trim()) return record.message
if (typeof record.errorCode === 'string' && record.errorCode.trim()) return record.errorCode
const fieldErrors = summarizeZohoDeskFieldErrors(record.errors)
if (typeof record.message === 'string' && record.message.trim()) {
return `${record.message}${fieldErrors}`
}
if (typeof record.errorCode === 'string' && record.errorCode.trim()) {
return `${record.errorCode}${fieldErrors}`
}
}
return fallback
}