Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

275 lines
12 KiB
TypeScript

import { htmlToText } from 'html-to-text'
import { DEFAULT_ZOHO_DESK_BASE, isZohoHost } from '@/tools/zoho_desk/host-allowlist'
import type { ZohoDeskBaseParams } from '@/tools/zoho_desk/types'
/**
* Convert Zoho Desk rich-text HTML (comment / thread / ticket bodies) to
* readable plain text. Mirrors the per-integration `html-to-text` configuration
* used elsewhere in the codebase (Outlook, Gmail, Confluence): anchors collapse
* to their text, and img / script / style subtrees are dropped. Configured
* locally because each integration's markup differs; there is no shared helper.
*/
export function convertZohoHtmlToText(html: string): string {
if (!html) return ''
return htmlToText(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { hideLinkHrefIfSameAsText: true, noAnchorUrl: true } },
{ selector: 'img', format: 'skip' },
{ selector: 'script', format: 'skip' },
{ selector: 'style', format: 'skip' },
],
preserveNewlines: true,
})
}
/**
* Derive a plain-text rendering of a Zoho Desk content value.
*
* Zoho is not consistent about how it spells the HTML discriminator: comment
* bodies come back as `contentType: 'html'` while thread bodies use the MIME
* form `contentType: 'text/html'`. A strict `=== 'html'` check therefore passes
* thread HTML straight through as "plain text". Match either spelling (and any
* other `text/html;charset=...` variant) case-insensitively.
*
* A plain-text (or unrecognized) content value is returned unchanged so callers
* can mirror it into a parallel text field. Returns `undefined` when there is no
* string content to derive from.
*/
export function deriveZohoContentText(content: unknown, contentType: unknown): string | undefined {
if (typeof content !== 'string') return undefined
if (typeof contentType !== 'string') return content
const normalized = contentType.trim().toLowerCase()
const isHtml = normalized === 'html' || normalized.startsWith('text/html')
return isHtml ? convertZohoHtmlToText(content) : content
}
/**
* True when a string carries HTML markup worth stripping - an element tag or a
* character entity. Used where Zoho gives no content-type discriminator, so a
* genuinely plain body is never run through html-to-text (which would decode
* entities and delete tag-shaped text that was never markup).
*/
function looksLikeHtml(value: string): boolean {
// Requires a real element - a paired <tag>...</tag>, a self-closing <tag/>, or
// an HTML comment/doctype. A bare `<`...`>` pair is NOT enough: plain support
// text like "if x<y then z>0" or "replace <username> with the real name" would
// otherwise be run through html-to-text and silently lose everything between
// the brackets. Entity form covers named, decimal and hex references.
return (
/<([a-z][a-z0-9]*)\b[^>]*>[\s\S]*<\/\1\s*>/i.test(value) ||
/<[a-z][a-z0-9]*\b[^>]*\/>/i.test(value) ||
/<!(?:--|doctype)/i.test(value) ||
/&(?:[a-z]+|#\d+|#x[0-9a-f]+);/i.test(value)
)
}
/**
* Return a shallow copy of a Zoho Desk resource (comment / thread / event
* payload) augmented with a derived `contentText` field alongside the raw
* `content` + `contentType`. The raw HTML is never mutated or replaced - some
* consumers want the markup. Resources without a string `content` (or a
* non-object value) are returned unchanged.
*/
export function withDerivedContentText(resource: unknown): unknown {
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) return resource
const record = resource as Record<string, unknown>
const contentText = deriveZohoContentText(record.content, record.contentType)
// Ticket resources carry their body on `description`, not `content`, and Zoho
// ships NO content-type discriminator for it: the Ticket_Add webhook sample
// has `"description": "<div>Description</div>"` with no `descriptionContentType`
// key, and the ticket GET/PATCH field lists have no content-type sibling.
//
// But the shape is not consistently HTML either - Zoho's own REST samples show
// plain descriptions ("Hi. There is a sudden delay in the processing of the
// orders."), and the webhook path runs this over contact/account/department
// payloads whose `description` Zoho documents as plain text. Converting
// unconditionally is therefore lossy on the plain case: html-to-text decodes
// entities (`&amp;` -> `&`) and deletes anything tag-shaped (`a < b > c`, an
// XML snippet). Sniff for markup instead and pass anything without it through
// untouched, so neither shape is mangled. An explicit descriptionContentType
// still wins if Zoho ever starts sending one.
const descriptionText =
typeof record.description === 'string'
? typeof record.descriptionContentType === 'string'
? deriveZohoContentText(record.description, record.descriptionContentType)
: looksLikeHtml(record.description)
? convertZohoHtmlToText(record.description)
: record.description
: undefined
if (contentText === undefined && descriptionText === undefined) return record
return {
...record,
...(contentText !== undefined ? { contentText } : {}),
...(descriptionText !== undefined ? { descriptionText } : {}),
}
}
/**
* Resolve the Zoho Desk REST API base (`{deskBase}/api/v1`). `apiDomain` is the
* data-center-scoped Desk base persisted from the OAuth token response, so calls
* always reach the correct data center instead of assuming `desk.zoho.com`.
*/
export function getZohoDeskApiBase(params: Pick<ZohoDeskBaseParams, 'apiDomain'>): string {
const candidate = (params.apiDomain || DEFAULT_ZOHO_DESK_BASE).replace(/\/+$/, '')
// Anchor to the Zoho apex allowlist before this host receives the OAuth token.
// `apiDomain` is injected server-side from the credential and is hidden from
// the LLM tool schema, but the injection in tools/index.ts lets a pre-existing
// context value win over the credential-derived one - so validate rather than
// trust precedence, and fall back to the US base on anything unrecognized.
try {
const url = new URL(candidate)
if (url.protocol === 'https:' && isZohoHost(url.hostname)) return `${candidate}/api/v1`
} catch {
// fall through to the default base
}
return `${DEFAULT_ZOHO_DESK_BASE}/api/v1`
}
/**
* Resolve an attachment `href` into an absolute download URL. Absolute hrefs are
* used as-is; a relative href is resolved against the Desk API base (`apiBase`,
* which ends in `/api/v1`). A leading slash and an already-present `api/v1/`
* prefix are stripped first so a Zoho href like `/api/v1/tickets/1/.../content`
* does not produce a duplicated `/api/v1/api/v1/...` path. Throws on an
* unparseable result (the caller maps that to a 400).
*/
export function resolveZohoAttachmentUrl(href: string, apiBase: string): URL {
if (/^https?:\/\//i.test(href)) return new URL(href)
const path = href.replace(/^\/+/, '').replace(/^api\/v1\//i, '')
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
* `%20` and 404 against Zoho with no indication of the real cause.
*/
export function requireZohoDeskId(value: string | undefined, label: string): string {
const trimmed = value?.trim()
if (!trimmed) throw new Error(`${label} is required.`)
return trimmed
}
/** Build the auth + org headers required on every Zoho Desk API call. */
export function buildZohoDeskHeaders(
params: Pick<ZohoDeskBaseParams, 'accessToken' | 'orgId'>
): Record<string, string> {
if (!params.accessToken) throw new Error('Zoho Desk access token is required')
if (!params.orgId) throw new Error('Zoho Desk organization ID is required')
return {
Authorization: `Zoho-oauthtoken ${params.accessToken}`,
orgId: String(params.orgId),
'Content-Type': 'application/json',
}
}
/**
* Derive a sensible name for a downloaded attachment so files aren't all stored
* as a generic default: an explicit override wins, else the Content-Disposition
* filename, else the last path segment of the download URL when it looks like a
* real file name (has an extension and isn't a generic `.../content` endpoint),
* else a plain fallback.
*/
export function deriveAttachmentName(
explicit: string | null | undefined,
contentDisposition: string | null | undefined,
pathname: string
): string {
const trimmedExplicit = explicit?.trim()
if (trimmedExplicit) return trimmedExplicit
const dispositionMatch = contentDisposition
? /filename\*?=(?:UTF-8'')?["']?([^"';]+)/i.exec(contentDisposition)?.[1]
: undefined
if (dispositionMatch) {
try {
return decodeURIComponent(dispositionMatch)
} catch {
return dispositionMatch
}
}
let lastSegment = ''
try {
lastSegment = decodeURIComponent(pathname.split('/').filter(Boolean).pop() ?? '')
} catch {
lastSegment = pathname.split('/').filter(Boolean).pop() ?? ''
}
if (lastSegment?.includes('.') && lastSegment.toLowerCase() !== 'content') {
return lastSegment
}
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>
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
}