import type { AmoConfig } from './config.ts' /* -------------------------------------------------------------------------- */ /* amoCRM REST types (only the parts this integration touches) */ /* -------------------------------------------------------------------------- */ export interface AmoEnum { id: number value: string enum_code?: string | null } export interface AmoCustomField { id: number name: string code: string | null type: string enums?: AmoEnum[] | null } interface AmoFieldValue { value: string | number | boolean | null enum_id?: number enum_code?: string } export interface AmoFieldEntry { field_id?: number field_code?: string values: AmoFieldValue[] } export interface AmoContact { id: number name?: string custom_fields_values?: AmoFieldEntry[] | null } export interface AmoLead { id: number } export class AmoError extends Error { constructor( message: string, readonly status?: number, readonly detail?: unknown, ) { super(message) this.name = 'AmoError' } } /* -------------------------------------------------------------------------- */ /* Client */ /* -------------------------------------------------------------------------- */ const RETRYABLE = new Set([429, 500, 502, 503, 504]) export class AmoClient { private leadFields?: Promise private contactFields?: Promise constructor(private readonly config: AmoConfig) {} get baseUrl(): string { return this.config.baseUrl } /** * A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses * for "found nothing" on every collection endpoint. */ private async request( path: string, init: { method?: string; body?: unknown; query?: Record } = {}, attempt = 1, ): Promise { const url = new URL(path, this.config.baseUrl) for (const [key, value] of Object.entries(init.query ?? {})) { if (value !== undefined) url.searchParams.set(key, String(value)) } let response: Response try { response = await fetch(url, { method: init.method ?? 'GET', headers: { Authorization: `Bearer ${this.config.token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: init.body === undefined ? undefined : JSON.stringify(init.body), signal: AbortSignal.timeout(15_000), }) } catch (cause) { if (attempt < 3) { await sleep(attempt * 400) return this.request(path, init, attempt + 1) } throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`) } if (response.status === 204) return null if (!response.ok) { const detail = await response.text().catch(() => '') if (RETRYABLE.has(response.status) && attempt < 3) { await sleep(attempt * 700) return this.request(path, init, attempt + 1) } if (response.status === 401) { throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail) } throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail) } return (await response.json()) as T } /* ---------------------------------------------------------------------- */ /* Field metadata — fetched once per process, then reused */ /* ---------------------------------------------------------------------- */ private async fetchFields(entity: 'leads' | 'contacts'): Promise { const collected: AmoCustomField[] = [] for (let page = 1; page <= 10; page++) { const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>( `/api/v4/${entity}/custom_fields`, { query: { page, limit: 250 } }, ) const batch = res?._embedded?.custom_fields ?? [] collected.push(...batch) if (batch.length < 250) break } return collected } getLeadFields(): Promise { this.leadFields ??= this.fetchFields('leads') return this.leadFields } getContactFields(): Promise { this.contactFields ??= this.fetchFields('contacts') return this.contactFields } /* ---------------------------------------------------------------------- */ /* Contacts */ /* ---------------------------------------------------------------------- */ /** Full-text search. amoCRM matches phones and emails regardless of format. */ async findContact(queries: string[]): Promise { for (const query of queries) { if (!query) continue const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', { query: { query, limit: 1, with: 'leads' }, }) const contact = res?._embedded?.contacts?.[0] if (contact) return contact } return null } async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise { const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', { method: 'POST', body: [ { name: input.name, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, }, ], }) const contact = res?._embedded?.contacts?.[0] if (!contact) throw new AmoError('amoCRM did not return the created contact') return contact } /** Adds phone/email to an existing contact without dropping what is there. */ async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise { if (!fields.length) return await this.request(`/api/v4/contacts/${contactId}`, { method: 'PATCH', body: { custom_fields_values: fields }, }) } /* ---------------------------------------------------------------------- */ /* Leads */ /* ---------------------------------------------------------------------- */ /** * Creates the lead in the configured pipeline. Omitting `status_id` makes * amoCRM drop it into that pipeline's first stage, which is what we want. */ async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { const embedded: Record = { contacts: [{ id: input.contactId }] } const tags = [...new Set([...this.config.tags, ...input.tags])] if (tags.length) embedded.tags = tags.map((name) => ({ name })) const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', { method: 'POST', body: [ { name: input.name, pipeline_id: this.config.pipelineId, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, _embedded: embedded, }, ], }) const lead = res?._embedded?.leads?.[0] if (!lead) throw new AmoError('amoCRM did not return the created lead') return lead } async addLeadNote(leadId: number, text: string): Promise { await this.request(`/api/v4/leads/${leadId}/notes`, { method: 'POST', body: [{ note_type: 'common', params: { text } }], }) } /* ---------------------------------------------------------------------- */ /* Diagnostics */ /* ---------------------------------------------------------------------- */ async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> { return this.request('/api/v4/account') } async getPipeline(id: number): Promise<{ id: number name: string _embedded?: { statuses?: { id: number; name: string; sort: number }[] } } | null> { return this.request(`/api/v4/leads/pipelines/${id}`) } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) }