import z from 'zod' import type { BlipClient } from '../client.ts' import { BlipError } from '../sender/bliperror.ts' import { type AccessKey, type Account, type ApplicationFlow, type BuilderConfiguration, type BuilderFlow, type BuilderLatestPublication, type BuilderLatestPublications, type Comment, type Contact, type HistoryIndex, type Identity, Node, type PossiblyNode, type Presence, type Publication, type State, type ThreadItem, } from '../types/index.ts' import type { Notification } from '../types/notification.ts' import { filter, type ODataFilter } from '../utils/odata.ts' import { uri } from '../utils/uri.ts' import { type ConsumeOptions, Namespace, type SendCommandOptions } from './namespace.ts' export class AccountNamespace extends Namespace { constructor(blipClient: BlipClient, defaultOptions?: SendCommandOptions) { super(blipClient, '', defaultOptions) } public async ping(opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/ping`, }, opts, ) } public async getPresence(opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/presence`, }, opts, ) } /** If you are initializing a chatbot, it usually is status 'available' with routingRule 'identity' */ public async setPresence(presence: Omit, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/presence`, type: 'application/vnd.lime.presence+json', resource: presence, }, opts, ) } public async me(opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/account`, }, opts, ) } public async getAccount(account: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/accounts/${account}`, }, opts, ) } public async setAccount( account: Partial> & { password?: string }, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/account`, type: 'application/vnd.lime.account+json', resource: account, }, opts, ) } public async getContexts(contact: Identity, opts?: ConsumeOptions): Promise> { try { return await this.sendCommand( { method: 'get', uri: uri`/contexts/${contact}`, }, { collection: true, ...opts, }, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return [] } throw err } } /** "fetchall" and "skip" `opts` are not supported for this endpoint, prefer using "take" with a large value */ public async getContextsWithValues( contact: Identity, opts?: Omit, ): Promise> { try { return await this.sendCommand( { method: 'get', uri: uri`/contexts/${contact}?withContextValues=true`, }, { collection: true, ...opts, }, ) } catch (err) { if ( err instanceof BlipError && (err.code === 67 || // This is a workaround for a bug in Blip where it returns an error when there are no contexts (err.code === 61 && err.message.includes('Object reference not set to an instance of an object.'))) ) { return [] } throw err } } public async getContext(contact: Identity, key: string, opts?: ConsumeOptions): Promise { try { return await this.sendCommand( { method: 'get', uri: uri`/contexts/${contact}/${key}`, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return undefined } throw err } } public async setContext( contact: Identity, key: string, value: string, ttlSeconds?: number, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/contexts/${contact}/${key}?${{ expiration: ttlSeconds ? ttlSeconds * 1000 : undefined, }}`, type: 'text/plain', resource: value, }, opts, ) } public async deleteContext(contact: Identity, key: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/contexts/${contact}/${key}`, }, opts, ) } public async getContacts( filter?: ODataFilter, order: { field: keyof Contact; ascending?: boolean } = { field: 'lastMessageDate', ascending: false }, opts?: ConsumeOptions, ): Promise> { try { return await this.sendCommand( { method: 'get', uri: uri`/contacts?${{ $filter: filter?.toString(), $orderBy: order.field, $ascending: order.ascending }}`, }, { collection: true, ...opts, }, ) } catch (err) { // TODO: Blip has updated and now have the ordenation feature, but it's not available for all contracts yet // Remove this after February 2026 if (err instanceof BlipError && err.code === 61) { return await this.sendCommand( { method: 'get', uri: uri`/contacts?${{ $filter: filter?.toString() }}`, }, { collection: true, ...opts, }, ) } throw err } } public async getContactsByIdentity(identities: Array, opts?: ConsumeOptions): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/contacts?${{ identities: identities.join(';') }}`, }, { collection: true, ...opts, }, ) } public async *streamContacts( filter?: ODataFilter, order: { field: keyof Contact; ascending?: boolean } = { field: 'lastMessageDate', ascending: false }, opts?: ConsumeOptions, ): AsyncIterable { const take = opts?.take ?? 100 const items = this.sendCommand<'get', Array>( { method: 'get', uri: uri`/contacts?${{ $filter: filter?.toString(), $orderBy: order.field, $ascending: order.ascending }}`, }, { collection: true, stream: true, ...opts, take, }, ) const seenIdentities = new Map() // best-effort to deduplicate without growing memory indefinitely const maxIdentityCacheSize = take * 5 for await (const contact of items) { if (!seenIdentities.has(contact.identity)) { seenIdentities.set(contact.identity, true) if (seenIdentities.size > maxIdentityCacheSize) { seenIdentities.delete(seenIdentities.keys().next().value!) } yield contact } } } public async *streamContactsInTimeframe( startDate: Date | string, endDate: Date | string, opts: Omit = {}, ): AsyncIterable { const take = opts.take ?? 100 const start = new Date(startDate) const end = new Date(endDate) if (start > end) return const seenNames = new Map() // best-effort to deduplicate without growing memory indefinitely const maxNameCacheSize = take * 5 let yieldedCount = 0 let cursorTime = end.getTime() const startTime = start.getTime() while (cursorTime >= startTime) { const items = this.sendCommand<'get', Array>( { method: 'get', uri: uri`/contacts?${{ $filter: filter() .ge('lastMessageDate', start) .le('lastMessageDate', new Date(cursorTime)), $orderBy: 'lastMessageDate', $ascending: false, }}`, }, { collection: true, stream: true, ...opts, take, fetchall: false, optimistic: false }, ) let oldestTime = Number.POSITIVE_INFINITY let count = 0 for await (const contact of items) { count++ if (contact.lastMessageDate) { const lastMessageTime = Date.parse(contact.lastMessageDate) if (lastMessageTime < oldestTime) { oldestTime = lastMessageTime } const name = Node.from(contact.identity).name if (!seenNames.has(name)) { seenNames.set(name, true) if (seenNames.size > maxNameCacheSize) { seenNames.delete(seenNames.keys().next().value!) } yield contact yieldedCount++ if (opts.max && yieldedCount >= opts.max) { return } } } } const reachedMax = opts.max && yieldedCount >= opts.max const hasMore = !reachedMax && count === take && cursorTime !== oldestTime - 1 if (!hasMore || !opts.fetchall) break if (oldestTime < Number.POSITIVE_INFINITY) { cursorTime = oldestTime - 1 } else { break } } } public async getContactComments(contact: Identity, opts?: ConsumeOptions): Promise> { try { return await this.sendCommand( { method: 'get', uri: uri`/contacts/${contact}/comments`, }, { collection: true, ...opts, }, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return [] } throw err } } public async addContactComment( contact: Identity, content: string, commenter?: Identity, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/contacts/${contact}/comments`, type: 'application/vnd.iris.crm.comment+json', resource: { content, authorIdentity: commenter, }, }, opts, ) } public async deleteContactComment(contact: Identity, commentId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/contacts/${contact}/comments/${commentId}`, }, opts, ) } public async getContact(contact: Identity, opts?: ConsumeOptions): Promise { try { return await this.sendCommand( { method: 'get', uri: uri`/contacts/${contact}`, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return undefined } throw err } } public async setContact(contact: Contact, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/contacts`, type: 'application/vnd.lime.contact+json', resource: contact, }, opts, ) } public async mergeContact( contact: Partial & Pick, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'merge', uri: uri`/contacts`, type: 'application/vnd.lime.contact+json', resource: contact, }, opts, ) } public async deleteContact(contact: Identity, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/contacts/${contact}`, }, opts, ) } public async getBuckets(opts?: ConsumeOptions): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/buckets`, }, { collection: true, ...opts, }, ) } public async getBucket(key: string, opts?: ConsumeOptions): Promise { try { return await this.sendCommand( { method: 'get', uri: uri`/buckets/${key}`, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return undefined } throw err } } public async setBucket( key: string, value: T, ttlSeconds?: number, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/buckets/${key}?${{ expiration: ttlSeconds ? ttlSeconds * 1000 : undefined, }}`, type: typeof value === 'string' ? 'text/plain' : 'application/json', resource: value, }, opts, ) } public async deleteBucket(key: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/buckets/${key}`, }, opts, ) } public async getCallerConfigurations( opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/configuration/caller`, }, { collection: true, ...opts, }, ) } public async getConfigurations(opts?: ConsumeOptions): Promise> { try { return await this.sendCommand( { method: 'get', uri: uri`/configuration`, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return {} } throw err } } /** * The caller will be the authenticated bot, and the owner can be set on opts.ownerIdentity */ public async setConfigurations(configuration: Record, opts?: ConsumeOptions) { return await this.sendCommand( { method: 'set', type: 'application/json', uri: uri`/configuration`, resource: configuration, }, opts, ) } // This doesn't ensure that the contact exists, it may have received a active message but never interacted public async getThreads(startDate: Date | string, endDate: Date | string, opts: Omit = {}) { const start = new Date(startDate) const end = new Date(endDate) if (start > end) return [] const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000 const parallel = opts.optimistic && opts.fetchall && end.getTime() - start.getTime() > ONE_WEEK_MS const segments: Array<{ start: Date; end: Date }> = [] if (parallel) { let segEnd = end while (segEnd >= start) { const segStart = new Date(Math.max(start.getTime(), segEnd.getTime() - ONE_WEEK_MS)) segments.push({ start: segStart, end: segEnd }) segEnd = new Date(segStart.getTime() - 1) } } else { segments.push({ start, end }) } const seenIdentities = new Set() let threads: Array<{ ownerIdentity: Identity; identity: Identity; lastMessage: ThreadItem }> = [] await Promise.all( segments.map(async ({ start: segStart, end: segEnd }) => { let cursor = new Date(segEnd) while (cursor >= segStart) { const take = Math.min(opts.take ?? 100, opts.max ?? Number.POSITIVE_INFINITY) if (take <= 0) break const items = await this.sendCommand<'get', typeof threads>( { method: 'get', uri: uri`/threads?${{ messageDate: cursor }}` }, { collection: true, ...opts, take, fetchall: false, optimistic: false }, ) for (const thread of items) { if (new Date(thread.lastMessage.date) >= start && !seenIdentities.has(thread.identity)) { seenIdentities.add(thread.identity) threads.push(thread) } } if (items.length < take || !opts.fetchall) break cursor = new Date(items[items.length - 1].lastMessage.date) } }), ) if (opts.max) { threads = threads.slice(0, opts.max) } return threads.toSorted((a, b) => a.lastMessage.date.localeCompare(b.lastMessage.date)) } /** * @param query.messageId The message ID to start the thread from * @param query.referenceDate A reference date in ISO 8601 format YYYY-MM-DD to fetch the thread from * @param query.referenceDateDirection The direction to fetch the thread from the reference date (inclusive if after). Can be 'before' or 'after' * @returns The thread items in descending order */ public async getThread( contact: Identity, query?: { messageId?: string referenceDate?: string | Date referenceDateDirection?: 'before' | 'after' refreshExpiredMedia?: boolean }, opts?: Omit, ): Promise> { const thread: Array = [] let lastMessage = query?.messageId let lastStorageDate = query?.referenceDate ? new Date(query.referenceDate).toISOString() : undefined let hasMore = true do { const take = Math.min(opts?.take ?? 100, opts?.max ? opts.max - thread.length : Number.POSITIVE_INFINITY) if (take <= 0) { break } const threadItems = await this.sendCommand<'get', Array>( { method: 'get', // Always prefer /threads-merged over /threads, because it handle additional whatsapp numbers automatically // For example, in Brazil the same number can work with or without a leading 9 uri: uri`/threads-merged/${contact}?${{ direction: query?.referenceDateDirection === 'after' ? 'asc' : 'desc', refreshExpiredMedia: query?.refreshExpiredMedia, messageId: lastMessage, storageDate: lastStorageDate, // Tunnels have empty threads (at least with router context enabled) // This is needed to fetch the entire thread from the originator // Can contain messages from other subbots getFromOriginator: true, }}`, }, { collection: true, ...opts, take, fetchall: false, }, ) thread.push(...threadItems) hasMore = threadItems.length === take && lastMessage !== threadItems.at(-1)?.id lastMessage = threadItems.at(-1)?.id lastStorageDate = threadItems.at(-1)?.date } while (hasMore && opts?.fetchall) return thread.sort((a, b) => b.date.localeCompare(a.date)) } public async getNotifications( filter?: { message?: string }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/notifications?${{ id: filter?.message }}`, }, { collection: true, ...opts, }, ) } public async getTemplateType(opts?: ConsumeOptions): Promise<'master' | 'builder'> { const configuration = await this.getCallerConfigurations(opts) const template = configuration.find((config) => config.name === 'Template')?.value if (template === 'master') { return 'master' } else if (template === 'builder') { return 'builder' } else { const account = await this.me(opts) if (account.extras?.template) { return account.extras.template as 'master' | 'builder' } else { throw new Error('Could not determine template type') } } } public async getChildren( opts?: ConsumeOptions, ): Promise> { const configuration = await this.getCallerConfigurations(opts) const template = configuration.find((config) => config.name === 'Template')?.value if (template === 'master') { const application = configuration.find((config) => config.name === 'Application')?.value if (application) { try { const children = JSON.parse(application)?.settings?.children if (children) { return children.map((child: { shortName: string; longName: string; service: string }) => ({ identifier: child.shortName, fullName: child.longName, service: child.service, })) } } catch (err) { throw new Error(`Failed to parse children configuration: ${err}`) } } } return [] } public async getBuilderFlow(state: 'published' | 'working', opts?: ConsumeOptions) { switch (state) { case 'published': return await this.getBucket('blip_portal:builder_published_flow', opts) case 'working': return await this.getBucket('blip_portal:builder_working_flow', opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async getBuilderGlobalActions(state: 'published' | 'working', opts?: ConsumeOptions) { switch (state) { case 'published': return await this.getBucket('blip_portal:builder_published_global_actions', opts) case 'working': return await this.getBucket('blip_portal:builder_working_global_actions', opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async getBuilderConfiguration(state: 'published' | 'working', opts?: ConsumeOptions) { switch (state) { case 'published': return await this.getBucket('blip_portal:builder_published_configuration', opts) case 'working': return await this.getBucket('blip_portal:builder_working_configuration', opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async setBuilderFlow(state: 'published' | 'working', value: BuilderFlow, opts?: ConsumeOptions) { switch (state) { case 'published': return await this.setBucket('blip_portal:builder_published_flow', value, undefined, opts) case 'working': return await this.setBucket('blip_portal:builder_working_flow', value, undefined, opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async setBuilderGlobalActions(state: 'published' | 'working', value: State, opts?: ConsumeOptions) { switch (state) { case 'published': return await this.setBucket('blip_portal:builder_published_global_actions', value, undefined, opts) case 'working': return await this.setBucket('blip_portal:builder_working_global_actions', value, undefined, opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async setBuilderConfiguration( state: 'published' | 'working', value: BuilderConfiguration, opts?: ConsumeOptions, ) { switch (state) { case 'published': return await this.setBucket('blip_portal:builder_published_configuration', value, undefined, opts) case 'working': return await this.setBucket('blip_portal:builder_working_configuration', value, undefined, opts) default: throw new Error('Invalid state. Use "published" or "working".') } } public async getBuilderLatestPublications(opts?: ConsumeOptions) { return await this.getBucket('blip_portal:builder_latestpublications', opts) } public async getBuilderLatestPublicationByIndex(index: HistoryIndex, opts?: ConsumeOptions) { return await this.getBucket(`blip_portal:builder_latestpublications:${index}`, opts) } public async setBuilderLatestPublications( publication: Publication, value: BuilderLatestPublication, opts?: ConsumeOptions, ) { const latestPublications = await this.getBuilderLatestPublications(opts) if (!latestPublications) { throw new Error('Latest publications not found') } const indexMax = 10 const newIndex = ( latestPublications.lastInsertedIndex === indexMax ? 1 : latestPublications.lastInsertedIndex + 1 ) as HistoryIndex const newPublication = { ...publication, publishedAt: new Date().toISOString(), index: newIndex, } if (latestPublications.publications.length >= indexMax) { latestPublications.publications = latestPublications.publications.slice(0, -1) } latestPublications.publications = [newPublication, ...latestPublications.publications] latestPublications.lastInsertedIndex = newIndex await Promise.all([ this.setBucket(`blip_portal:builder_latestpublications:${newIndex}`, value, undefined, opts), this.setBucket('blip_portal:builder_latestpublications', latestPublications, undefined, opts), ]) } public async getApplicationPublishedFlowConfiguration(opts?: ConsumeOptions) { const configurations = await this.getCallerConfigurations(opts) const application = configurations.find((c) => c.name === 'Application' && !c.owner.startsWith('construction.')) if (application?.value) { return JSON.parse(application.value) as ApplicationFlow } throw new Error('Application flow not found value') } public async setApplicationPublishedFlowConfiguration(value: ApplicationFlow, opts?: ConsumeOptions) { const configurations = await this.getCallerConfigurations(opts) const application = configurations.find((c) => c.name === 'Application' && !c.owner.startsWith('construction.')) if (application) { return await this.setConfigurations( { [application.name]: JSON.stringify(value), }, { ownerIdentity: application.owner, }, ) } throw new Error('Application flow not found') } public async publishBuilderFlow( flowId: string, author: string, flow: BuilderFlow, configuration: BuilderConfiguration, globalActions: State, opts?: ConsumeOptions, ) { const publishedFlowConfiguration = await this.getApplicationPublishedFlowConfiguration(opts) publishedFlowConfiguration.settings.flow = JSON.parse( JSON.stringify( { id: flowId, version: 1, states: Object.keys(flow).map((stateId) => ({ id: stateId, root: flow[stateId].root, name: flow[stateId].$title, deskStateVersion: flow[stateId].deskStateVersion, inputActions: flow[stateId].$enteringCustomActions.concat( flow[stateId].$contentActions.filter((a) => a.action).map((a) => a.action!), ), input: flow[stateId].$contentActions.find((a) => a.input)?.input ?? { bypass: true }, outputActions: flow[stateId].$leavingCustomActions, afterStateChangedActions: flow[stateId].$afterStateChangedActions ?? [], outputs: [...flow[stateId].$conditionOutputs, flow[stateId].$defaultOutput], })), inputActions: globalActions.$enteringCustomActions, outputActions: globalActions.$leavingCustomActions, configuration, type: 'flow', } satisfies ApplicationFlow['settings']['flow'], (key, value) => (key.startsWith('$') ? undefined : value), ), ) await Promise.all([ this.setBuilderFlow('published', flow, opts), this.setBuilderConfiguration('published', configuration, opts), this.setBuilderGlobalActions('published', globalActions, opts), this.setBuilderLatestPublications( { author, authorIdentity: new Node(author, 'blip.ai').toIdentity() }, { flow, configuration, globalActions }, opts, ), this.setApplicationPublishedFlowConfiguration(publishedFlowConfiguration, opts), ]) } public async getAccessKeys(opts?: ConsumeOptions): Promise>> { try { return await this.sendCommand( { method: 'get', uri: uri`/account/keys`, }, { collection: true, ...opts, }, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return [] } throw err } } public async getAccessKey(id: string, opts?: ConsumeOptions): Promise | undefined> { try { return await this.sendCommand( { method: 'get', uri: uri`/account/keys/${id}`, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return undefined } throw err } } /** * @param key.purpose Optional purpose of the key, can be used to identify * @param key.temporary Indicates if the key must be removed after the first use * @param key.ttl Time to live in milliseconds */ public async createAccessKey( key?: Partial<{ purpose: string temporary: boolean ttl: number }>, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/account/keys`, type: 'application/vnd.iris.keyRequest+json', resource: key, }, opts, ) } public async deleteAccessKey(id: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/account/keys/${id}`, }, opts, ) } public emailFromIdentity(possiblyNode: PossiblyNode): string | undefined { const node = Node.from(possiblyNode) const email = z.email().safeParse(node.name) return email.success ? email.data : undefined } }