scratch-l10n
Version:
Localization for the Scratch 3.0 components
561 lines (525 loc) • 24.8 kB
text/typescript
// 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