UNPKG

scratch-l10n

Version:
561 lines (525 loc) 24.8 kB
// interface to FreshDesk Solutions (knowledge base) api import { messageOf } from './errors.mts' import { emitWarning } from './warnings.mts' /** * Extend */ export interface HttpError extends Error { /** HTTP error code (if any) */ code: number | null | undefined /** Retry-After header value (if any) */ retryAfter: string | null | undefined } /** * Properties to provide when creating a Category through the Freshdesk API. Also works for updates. * @see https://developers.freshdesk.com/api/#create_solution_category */ export interface FreshdeskCategoryCreate { /** Name of the solution category. Mandatory for Create. */ name: string /** Description of the solution category */ description?: string /** List of portal IDs where this category is visible */ visible_in_portals?: number[] } /** * Categories broadly classify your solutions page into several sections. * @see https://developers.freshdesk.com/api/#solution_category_attributes */ export interface FreshdeskCategory extends FreshdeskCategoryCreate { /** Unique ID of the solution category */ id?: number /** Solution Category creation timestamp */ created_at?: string /** Solution Category update timestamp */ updated_at?: string } /** * Properties to provide when creating a Folder through the Freshdesk API. Also works for updates. * @see https://developers.freshdesk.com/api/#create_solution_folder */ export interface FreshdeskFolderCreate { /** Description of the solution folder */ description?: string /** Name of the solution folder */ name: string /** ID of the parent folder */ parent_folder_id?: number /** Accessibility of this folder. Please refer to Folder Properties table. */ visibility?: FreshdeskFolderVisibility /** IDs of the companies to whom this solution folder is visible */ company_ids?: number[] /** IDs of the contact segments to whom this solution folder is visible */ contact_segment_ids?: number[] /** IDs of the company segments to whom this solution folder is visible */ company_segment_ids?: number[] } /** * Related Solutions Articles and/or Folders are organized into Folders. Folders make it convenient for users to read * similar articles or navigate to other possible solutions to their problem. * @see https://developers.freshdesk.com/api/#solution_folder_attributes */ export interface FreshdeskFolder extends FreshdeskFolderCreate { /** Unique ID of the solution folder */ id?: number /** Parent category and folders in which the folder is placed */ hierarchy?: object[] /** Number of articles present inside a folder */ articles_count?: number /** Number of folders present inside a folder */ sub_folders_count?: number /** Solution Folder creation timestamp */ created_at?: string /** Solution Folder updated timestamp */ updated_at?: string } export enum FreshdeskFolderVisibility { AllUsers = 1, LoggedInUsers = 2, Agents = 3, SelectedCompanies = 4, Bots = 5, SelectedContactSegments = 6, SelectedCompanySegments = 7, } /** * Properties to provide when creating an Article through the Freshdesk API. Also works for updates. * @see https://developers.freshdesk.com/api/#create_solution_article */ export interface FreshdeskArticleCreate { /** ID of the agent who created the solution article */ agent_id?: number /** Description of the solution article */ description: string /** Status of the solution article */ status: FreshdeskArticleStatus /** Meta data for search engine optimization. Allows meta_title, meta_description and meta_keywords */ seo_data?: FreshdeskSEOData /** Tags that have been associated with the solution article */ tags?: string[] /** Title of the solution article */ title: string } /** * Solution Articles or knowledge base posts promote self-help in your support portal. These should ideally cover all * aspects of your product or service like "how-to" instructions and FAQs. * @see https://developers.freshdesk.com/api/#solution_article_attributes */ export interface FreshdeskArticle extends FreshdeskArticleCreate { /** Unique ID of the solution article */ id?: number /** ID of the category to which the solution article belongs */ category_id?: number /** Description of the solution article in plain text */ description_text?: string /** ID of the folder to which the solution article belongs */ folder_id?: number /** Parent category and folders in which the article is placed */ hierarchy?: object[] /** Number of views for the solution article */ hits?: number /** Number of down votes for the solution article */ thumbs_down?: number /** Number of upvotes for the solution article */ thumbs_up?: number /** Solution Article creation timestamp */ created_at?: string /** Solution Article updated timestamp */ updated_at?: string } export enum FreshdeskArticleStatus { draft = 1, published = 2, } export interface FreshdeskSEOData { meta_title?: string meta_description?: string meta_keywords?: string[] } /** * A Freshdesk agent, as returned by the `agents/me` endpoint. * @see https://developers.freshdesk.com/api/#me */ export interface FreshdeskAgent { /** Unique ID of the agent */ id: number /** Scope of tickets the agent can access */ ticket_scope?: number /** The agent's contact details */ contact: { name: string email: string active?: boolean } } /** * Helpdesk settings, as returned by `GET /api/v2/settings/helpdesk`. These determine which languages * the knowledge base can hold translations in, so they gate which locales we can push. * @see https://developers.freshdesk.com/api/#settings */ export interface FreshdeskHelpdeskSettings { /** The account's primary language code; its content lives on the base resource, not a translation. */ primary_language?: string /** Language codes translations may be created for (configured in Admin > Helpdesk Settings). */ supported_languages?: string[] /** Subset of supported languages exposed on the support portal. */ portal_languages?: string[] } const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)) /** Interval used before the server has told us its limit, in ms (conservative, avoids an opening burst). */ const FD_START_INTERVAL_MS = 300 /** Never pace faster than this, even if the advertised limit would allow it, in milliseconds. */ const FD_MIN_REQUEST_INTERVAL_MS = 100 /** Ceiling on the interval the backoff will grow to, in milliseconds. */ const FD_MAX_REQUEST_INTERVAL_MS = 10_000 /** Fraction of the advertised per-minute budget we aim to use, leaving headroom for bursts and other clients. */ const FD_RATE_SAFETY_FACTOR = 0.75 /** When the advertised remaining budget drops to this fraction of the total, pause to let it refill. */ const FD_LOW_REMAINING_FRACTION = 0.1 /** How long to hold the schedule when the remaining budget runs low, in milliseconds. */ const FD_LOW_REMAINING_PAUSE_MS = 3_000 /** Extra multiplier applied to the interval on a 429, a safety net for when the headers under-report. */ const FD_RATE_LIMIT_BACKOFF_FACTOR = 1.5 /** Maximum attempts (the initial request plus retries) for a single request that keeps getting 429ed. */ const FD_MAX_RATE_LIMIT_ATTEMPTS = 5 /** Fallback wait when a 429 arrives without a usable `Retry-After` header, in milliseconds. */ const FD_DEFAULT_RETRY_AFTER_MS = 30_000 /** * Cap on how long we will actually wait for a single `Retry-After`, in milliseconds. Freshdesk can * return a punitive multi-minute delay once it decides a client is abusing the API; honoring that * verbatim would hang the job past its timeout. We cap the wait so the run fails fast and reports * instead. Correct pacing should keep us from ever earning such a penalty in the first place. */ const FD_MAX_RETRY_AFTER_MS = 120_000 /** * Parse a `Retry-After` header into milliseconds. Freshdesk sends a delta in seconds, but the HTTP * spec also allows an HTTP-date, so both forms are handled. * @param value - the raw header value, or null if absent * @returns the delay in milliseconds, or null if the header was missing or unparseable */ const parseRetryAfterMs = (value: string | null): number | null => { if (!value) { return null } const seconds = Number(value) if (Number.isFinite(seconds)) { // A negative delay is malformed; fall back to the default (null) rather than retrying immediately. return seconds < 0 ? null : seconds * 1000 } const dateMs = Date.parse(value) return Number.isNaN(dateMs) ? null : Math.max(0, dateMs - Date.now()) } /** * Parse a non-negative integer rate-limit header (for example `X-RateLimit-Total`). * @param value - the raw header value, or null if absent * @returns the parsed value, or null if the header was missing or not a finite number */ const parseIntHeader = (value: string | null): number | null => { if (!value) { return null } const n = Number(value) // Reject anything that isn't a whole, non-negative count: this value drives request pacing, so a // stray decimal or negative from a malformed header should fall back to the defaults, not skew it. return Number.isInteger(n) && n >= 0 ? n : null } /** * Wrapper for Freshdesk's REST API */ export class FreshdeskApi { baseUrl: string private _auth: string defaultHeaders: { 'Content-Type': string; Authorization: string } /** * Current minimum spacing between request starts, in milliseconds, shared across every concurrent * caller. Starts conservative and is set from the server's advertised rate-limit headers once they * are seen (see {@link applyRateHeaders}); a 429 nudges it up as a safety net. This is what keeps * the fan-out over folders and locales under the limit. */ private requestIntervalMs = FD_START_INTERVAL_MS /** Serializes request starts: each `gate()` call chains onto this so only one is timed at a time. */ private queue: Promise<void> = Promise.resolve() /** Timestamp (ms) when the previous request actually started, used to space the next one. */ private lastRequestStart = 0 /** Timestamp (ms) before which no request may start; set by a 429 or a low remaining-budget signal. */ private pauseUntil = 0 /** Whether the server's advertised limit has been logged yet (logged once, to aid diagnosis). */ private limitLogged = false /** Count of 429 responses seen this run; a nonzero value means the limiter had to back off. */ rateLimitHits = 0 /** Whether the one-time "rate limiting activated" warning has been emitted this run. */ private rateLimitReported = false constructor(baseUrl: string, apiKey: string) { this.baseUrl = baseUrl this._auth = 'Basic ' + Buffer.from(`${apiKey}:X`).toString('base64') this.defaultHeaders = { 'Content-Type': 'application/json', Authorization: this._auth, } } /** * Wait for this request's turn. Requests are serialized on a single queue and each starts at least * `requestIntervalMs` after the previous one (and never before {@link pauseUntil}). The wait is * computed when the request reaches the front of the queue — not reserved up front — so an interval * change from a fresh rate-limit header takes effect immediately. That is what keeps the fan-out * over folders and locales under the limit without a burst locking in a too-fast schedule. * @returns a promise that resolves when it is this request's turn to start */ private async gate(): Promise<void> { const run = this.queue.then(async () => { const target = Math.max(this.lastRequestStart + this.requestIntervalMs, this.pauseUntil) const wait = target - Date.now() if (wait > 0) { await sleep(wait) } this.lastRequestStart = Date.now() }) // Keep the chain alive whether or not the timing step rejects (it shouldn't, but be safe). this.queue = run.catch(() => undefined) return run } /** * Steer pacing from Freshdesk's advertised rate-limit headers so we throttle *before* hitting the * wall rather than only reacting to 429s. Freshdesk returns `X-RateLimit-Total` (the per-minute * budget) and `X-RateLimit-Remaining` on every response: we pace to a safe fraction of the budget * and pause as the remaining budget runs low. When the headers are absent the interval is left * alone and we fall back to the 429 backoff. * @param headers - the response headers to read the advertised limits from */ private applyRateHeaders(headers: Headers): void { const total = parseIntHeader(headers.get('X-RateLimit-Total')) if (total === null || total <= 0) { return } // Aim for a fraction of the budget: interval = one minute / (budget * safety). const target = 60_000 / (total * FD_RATE_SAFETY_FACTOR) this.requestIntervalMs = Math.min(Math.max(target, FD_MIN_REQUEST_INTERVAL_MS), FD_MAX_REQUEST_INTERVAL_MS) if (!this.limitLogged) { this.limitLogged = true console.log( `Freshdesk advertised rate limit: ${total}/min; pacing requests ` + `~${Math.round(this.requestIntervalMs)}ms apart.`, ) } const remaining = parseIntHeader(headers.get('X-RateLimit-Remaining')) if (remaining !== null && remaining <= total * FD_LOW_REMAINING_FRACTION) { // Budget nearly exhausted: hold the shared schedule briefly so the window can refill before we // spend the last of it and trip a 429. this.pauseUntil = Math.max(this.pauseUntil, Date.now() + FD_LOW_REMAINING_PAUSE_MS) } } /** * Perform a fetch through the rate limiter: paced against the shared schedule, and transparently * retried on 429 while honoring `Retry-After`. Any non-429 response (success or a real error) is * returned as-is for the caller's {@link checkStatus} to interpret; a 429 that survives every * retry is likewise returned so it surfaces as a genuine, reported failure rather than a silent * skip. Only rate limiting is handled here — other transient failures keep their existing behavior. * @param url - absolute endpoint to call * @param init - method, body, and headers for the fetch * @returns the final response */ private async request(url: string, init: RequestInit): Promise<Response> { for (let attempt = 1; ; attempt++) { await this.gate() const res = await fetch(url, init) // Learn the server's limit from every response (success or 429) and pace to it. this.applyRateHeaders(res.headers) if (res.status !== 429) { return res } this.rateLimitHits++ // Surface the first back-off through the shared warnings channel (job summary + Slack), once // per run, so a job that recovers and stays green still flags that it is brushing the limit. if (!this.rateLimitReported) { this.rateLimitReported = true emitWarning( 'Freshdesk rate limiting was encountered during the help sync. Requests are being paced ' + 'and retried (honoring Retry-After), so the run should still complete; if this recurs, ' + 'consider reducing request volume or raising the Freshdesk rate limit.', ) } const requested = parseRetryAfterMs(res.headers.get('Retry-After')) ?? FD_DEFAULT_RETRY_AFTER_MS // Cap the honored wait so a punitive multi-minute Retry-After can't hang the job; give up // rather than sleep past the timeout. Return the 429 with its body still intact so // checkStatus can read and surface Freshdesk's actual reason for the failure. if (requested > FD_MAX_RETRY_AFTER_MS || attempt >= FD_MAX_RATE_LIMIT_ATTEMPTS) { return res } // We are going to retry and abandon this response, so drain its body now to free the socket // before we wait. (The give-up paths above deliberately leave it unread for checkStatus.) await res.text().catch(() => undefined) // Safety net on top of the header-driven pacing: widen the interval and hold the shared // schedule until the retry window passes, so every request backs off together rather than only // the one that was rejected. this.requestIntervalMs = Math.min( this.requestIntervalMs * FD_RATE_LIMIT_BACKOFF_FACTOR, FD_MAX_REQUEST_INTERVAL_MS, ) this.pauseUntil = Math.max(this.pauseUntil, Date.now() + requested) console.warn( `Freshdesk rate limited (429); waiting ~${Math.round(requested)}ms then retrying ` + `(attempt ${attempt}/${FD_MAX_RATE_LIMIT_ATTEMPTS}, pace now ${Math.round(this.requestIntervalMs)}ms).`, ) } } /** * Checks the status of a response. If status is not ok, or the body is not JSON, raise exception. * On error, the response body is included in the message: Freshdesk reports the actual reason for * a failure there (for example `{"code":"access_denied","message":"..."}`), so surfacing it turns * an opaque "HTTP 403" into something diagnosable. * @param res - The response object * @returns the response if it is ok */ async checkStatus(res: Response) { if (res.ok) { if (res.headers.get('content-type')?.includes('application/json')) { return res } throw new Error(`response not json: ${res.headers.get('content-type')}`) } let message = `HTTP ${res.status} ${res.statusText} for ${res.url}` if (res.status === 403) { message += ` -- this item may have been deleted in Freshdesk, or the API key lacks permission.` } // Read the body for the underlying reason. It is safe to consume here because we only reach this // point on error and are about to throw; the caller never reads the body of a failed response. const body = (await res.text().catch(() => '')).trim() if (body) { message += ` -- response: ${body.length > 500 ? `${body.slice(0, 500)}...` : body}` } const err = new Error(message) as HttpError err.code = res.status if (res.status === 429) { err.retryAfter = res.headers.get('Retry-After') } throw err } async listCategories(): Promise<FreshdeskCategory[]> { const res = await this.request(`${this.baseUrl}/api/v2/solutions/categories`, { headers: this.defaultHeaders }) await this.checkStatus(res) return (await res.json()) as FreshdeskCategory[] } async listFolders(category: FreshdeskCategory): Promise<FreshdeskFolder[]> { const res = await this.request(`${this.baseUrl}/api/v2/solutions/categories/${category.id}/folders`, { headers: this.defaultHeaders, }) await this.checkStatus(res) return (await res.json()) as FreshdeskFolder[] } async listArticles(folder: FreshdeskFolder) { const res = await this.request(`${this.baseUrl}/api/v2/solutions/folders/${folder.id}/articles`, { headers: this.defaultHeaders, }) await this.checkStatus(res) return (await res.json()) as FreshdeskArticle[] } /** * Fetch the agent that the configured API key authenticates as. The account behind the token * determines what the sync may read and write, so surfacing it makes a permission problem (such * as a `403 access_denied` on a specific item) much easier to route to the right person. * @returns the agent's id and contact details */ async getAuthenticatedAgent(): Promise<FreshdeskAgent> { const res = await this.request(`${this.baseUrl}/api/v2/agents/me`, { headers: this.defaultHeaders }) await this.checkStatus(res) return (await res.json()) as FreshdeskAgent } /** * Fetch the account's helpdesk settings, including the primary language and the set of supported * languages that gate which locales the knowledge base can hold translations for. * @returns the primary language, supported languages, and portal languages */ async getHelpdeskSettings(): Promise<FreshdeskHelpdeskSettings> { const res = await this.request(`${this.baseUrl}/api/v2/settings/helpdesk`, { headers: this.defaultHeaders }) await this.checkStatus(res) return (await res.json()) as FreshdeskHelpdeskSettings } /** * Send a translation write and confirm it succeeded, then drain the response body so undici can * release (and reuse) the connection promptly. A large sync issues thousands of writes and we do * not use the returned translation object, so an undrained body would keep connections busy. * @param url - the translation endpoint * @param method - `put` to update, or `post` to create * @param payload - the serialized translation body */ private async writeRequest(url: string, method: 'put' | 'post', payload: string): Promise<void> { const res = await this.request(url, { method, body: payload, headers: this.defaultHeaders }) await this.checkStatus(res) await res.text().catch(() => undefined) } /** * Write one translation with an optimistic update-then-create flow: `PUT` assuming it exists and, * only on a 404, `POST` to create it. If the create is refused because the translation already * exists (`409 duplicate_value`, or `405` where the endpoint only allows GET/PUT), Freshdesk is * telling us the record exists even though `PUT` reported 404 — a state we cannot resolve through * the API (typically an unsupported or primary language). Rather than fail the whole run, surface * it as a warning and move on. Any other error is re-thrown for the caller to record. * @param resourcePath - the solutions sub-path: `categories`, `folders`, or `articles` * @param id - the primary resource id the translation belongs to * @param locale - the Freshdesk language code * @param body - the translation payload * @returns true if the translation was written; false if Freshdesk reported it as an unresolvable * already-exists skip, so callers can avoid advancing their synced baseline for a pair not written */ private async writeTranslation( resourcePath: 'categories' | 'folders' | 'articles', id: number, locale: string, body: FreshdeskCategoryCreate | FreshdeskFolderCreate | FreshdeskArticleCreate, ): Promise<boolean> { const url = `${this.baseUrl}/api/v2/solutions/${resourcePath}/${id}/${locale}` const payload = JSON.stringify(body) try { await this.writeRequest(url, 'put', payload) return true } catch (err) { if ((err as HttpError).code !== 404) { process.stdout.write(`Error processing id ${id} for locale ${locale}: ${(err as HttpError).message}\n`) throw err } // PUT 404: the translation does not exist yet, so fall through and create it. } try { await this.writeRequest(url, 'post', payload) return true } catch (err) { const code = (err as HttpError).code if (code === 409 || code === 405) { emitWarning( `Skipping ${resourcePath} ${id} translation for "${locale}": Freshdesk refused the create ` + `(HTTP ${code}) after PUT returned 404. This usually means "${locale}" is the primary ` + `language or is not enabled in Freshdesk; enable it there to sync it.`, ) return false } process.stdout.write(`Error processing id ${id} for locale ${locale}: ${(err as HttpError).message}\n`) throw err } } async updateCategoryTranslation(id: number, locale: string, body: FreshdeskCategoryCreate): Promise<boolean> { return this.writeTranslation('categories', id, locale, body) } async updateFolderTranslation(id: number, locale: string, body: FreshdeskFolderCreate): Promise<boolean> { return this.writeTranslation('folders', id, locale, body) } async updateArticleTranslation(id: number, locale: string, body: FreshdeskArticleCreate): Promise<boolean> { return this.writeTranslation('articles', id, locale, body) } } /** * Log which agent the given Freshdesk client authenticates as. Never throws: identifying the agent * is a diagnostic aid, so a failure here (for example an invalid token) is reported and swallowed * rather than aborting the caller, which will hit the same problem with a clearer error of its own. * The agent's email is intentionally omitted to keep it out of public CI logs; the name and id are * enough to find the account in Freshdesk. * @param fd - a configured Freshdesk client */ export const logAuthenticatedAgent = async (fd: FreshdeskApi): Promise<void> => { try { const agent = await fd.getAuthenticatedAgent() console.log(`Freshdesk token authenticated as "${agent.contact.name}" (agent id ${agent.id}).`) } catch (error) { console.warn(`Could not identify the Freshdesk agent for the configured token: ${messageOf(error)}`) } } export default FreshdeskApi