@whitewall/blip-sdk 0.0.136 → 0.0.137

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +2 -2
  2. package/src/client.ts +117 -0
  3. package/src/index.ts +6 -0
  4. package/src/namespaces/account.ts +729 -0
  5. package/src/namespaces/activecampaign.ts +285 -0
  6. package/src/namespaces/analytics.ts +230 -0
  7. package/src/namespaces/billing.ts +17 -0
  8. package/src/namespaces/builder.ts +52 -0
  9. package/src/namespaces/configurations.ts +19 -0
  10. package/src/namespaces/context.ts +67 -0
  11. package/src/namespaces/desk.ts +679 -0
  12. package/src/namespaces/media.ts +39 -0
  13. package/src/namespaces/namespace.ts +125 -0
  14. package/src/namespaces/plugins.ts +223 -0
  15. package/src/namespaces/portal.ts +402 -0
  16. package/src/namespaces/scheduler.ts +88 -0
  17. package/src/namespaces/whatsapp.ts +383 -0
  18. package/src/sender/bliperror.ts +42 -0
  19. package/src/sender/enveloperesolver.ts +148 -0
  20. package/src/sender/gateway/customgatewaysender.ts +43 -0
  21. package/src/sender/http/httpsender.ts +94 -0
  22. package/src/sender/index.ts +7 -0
  23. package/src/sender/plugin/communication.ts +72 -0
  24. package/src/sender/plugin/pluginsender.ts +75 -0
  25. package/src/sender/security.ts +33 -0
  26. package/src/sender/sender.ts +145 -0
  27. package/src/sender/sessionnegotiator.ts +175 -0
  28. package/src/sender/tcp/tcpsender.ts +252 -0
  29. package/src/sender/throttler.ts +36 -0
  30. package/src/sender/websocket/websocketsender.ts +175 -0
  31. package/src/types/account.ts +84 -0
  32. package/src/types/analytics.ts +18 -0
  33. package/src/types/billing.ts +15 -0
  34. package/src/types/command.ts +47 -0
  35. package/src/types/commons.ts +16 -0
  36. package/src/types/desk.ts +51 -0
  37. package/src/types/envelope.ts +9 -0
  38. package/src/types/flow.ts +327 -0
  39. package/src/types/index.ts +13 -0
  40. package/src/types/message.ts +116 -0
  41. package/src/types/node.ts +86 -0
  42. package/src/types/notification.ts +18 -0
  43. package/src/types/plugins.ts +51 -0
  44. package/src/types/portal.ts +39 -0
  45. package/src/types/reason.ts +22 -0
  46. package/src/types/session.ts +22 -0
  47. package/src/types/whatsapp.ts +84 -0
  48. package/src/utils/odata.ts +114 -0
  49. package/src/utils/random.ts +3 -0
  50. package/src/utils/uri.ts +46 -0
@@ -0,0 +1,94 @@
1
+ import type { Command, CommandMethods, Message, MessageTypes, UnknownCommandResponse } from '../../types/index.ts'
2
+ import { BlipError } from '../bliperror.ts'
3
+ import { ConnectionSender, type ConnectionSenderConstructor, type Sender } from '../sender.ts'
4
+ import { EnvelopeThrottler } from '../throttler.ts'
5
+
6
+ export class HttpSender extends ConnectionSender implements Sender {
7
+ private readonly baseurl: string
8
+ private readonly token: string
9
+ private readonly throttler = new EnvelopeThrottler()
10
+
11
+ constructor(options: ConstructorParameters<ConnectionSenderConstructor>[0]) {
12
+ super(options)
13
+
14
+ const prefix = options.tenantId ? `${options.tenantId}.` : ''
15
+ this.baseurl = `https://${prefix}http.${this.domain}`
16
+
17
+ if (options.authentication.scheme === 'key') {
18
+ this.token = HttpSender.createToken(options.node, options.authentication.key)
19
+ } else if (options.authentication.scheme === 'token') {
20
+ this.token = options.authentication.token
21
+ } else {
22
+ throw new Error('HttpSender only supports key or token authentication')
23
+ }
24
+ }
25
+
26
+ public sendMessage<Type extends MessageTypes>(message: Message<Type>): Promise<void> {
27
+ return this.withFetchRetryPolicy(async () => {
28
+ await this.throttler.throttle('message')
29
+ const response = await this.fetch('messages', JSON.stringify(message))
30
+
31
+ if (!response.ok) {
32
+ throw new Error(`Failed to send message: ${response.statusText} (${response.status})`)
33
+ }
34
+ })
35
+ }
36
+
37
+ public sendCommand(command: Command<CommandMethods>): Promise<unknown> {
38
+ return this.withFetchRetryPolicy(async () => {
39
+ await this.throttler.throttle('command')
40
+ const response = await this.fetch('commands', JSON.stringify(command))
41
+
42
+ if (!response.ok) {
43
+ throw new Error(`Failed to send command: ${response.statusText} (${response.status})`)
44
+ }
45
+
46
+ const result: UnknownCommandResponse = await response.json()
47
+ if (BlipError.isFailedCommandResponse(result)) {
48
+ throw BlipError.commandResponseToBlipError(command.uri, result)
49
+ } else if (result.status === 'success') {
50
+ return result.resource
51
+ } else {
52
+ throw new Error(`Unexpected response for command '${command.uri}': ${JSON.stringify(response)}`)
53
+ }
54
+ })
55
+ }
56
+
57
+ public static login = ConnectionSender.login<HttpSender>
58
+
59
+ private async fetch(path: 'commands' | 'messages', body: string) {
60
+ return await fetch(`${this.baseurl}/${path}`, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Content-Type': 'application/json',
64
+ Authorization: `Key ${this.token}`,
65
+ },
66
+ body,
67
+ })
68
+ }
69
+
70
+ private async withFetchRetryPolicy<T>(fn: () => Promise<T>, retries = 5): Promise<T> {
71
+ try {
72
+ return await fn()
73
+ } catch (err) {
74
+ if (
75
+ retries > 0 &&
76
+ err instanceof Error &&
77
+ (err.message.endsWith('(429)') ||
78
+ err.message.endsWith('(504)') ||
79
+ err.message.endsWith('(502)') ||
80
+ (err instanceof BlipError && BlipError.retryableBlipErrors.includes(err.code)) ||
81
+ // Probably cloudflare trying to mitigate a DDoS attack
82
+ (err.cause instanceof Error && err.cause?.name === 'ConnectTimeoutError') ||
83
+ err.message === 'terminated' ||
84
+ err.message === 'fetch failed')
85
+ ) {
86
+ // wait before retrying
87
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 1000))
88
+ return this.withFetchRetryPolicy(fn, retries - 1)
89
+ }
90
+
91
+ throw err
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,7 @@
1
+ export * from './sender.ts'
2
+ export * from './bliperror.ts'
3
+ export * from './gateway/customgatewaysender.ts'
4
+ export * from './plugin/pluginsender.ts'
5
+ export * from './websocket/websocketsender.ts'
6
+ export * from './tcp/tcpsender.ts'
7
+ export * from './http/httpsender.ts'
@@ -0,0 +1,72 @@
1
+ import { randomId } from '../../utils/random.ts'
2
+
3
+ export const startChannel = () => {
4
+ const caller = window.name ?? randomId()
5
+ let isDesk = false
6
+
7
+ const waitingMessages: Record<string, Pick<PromiseWithResolvers<unknown>, 'reject' | 'resolve'>> = {}
8
+ const messageHandler = ({ data, origin }: MessageEvent) => {
9
+ if (origin.endsWith('.desk.blip.ai')) {
10
+ isDesk = true
11
+ }
12
+
13
+ const deferred = waitingMessages[data?.trackingProperties?.id]
14
+ if (deferred) {
15
+ if (data.message?.caller === caller) {
16
+ // This is a echo message, ignore it
17
+ return
18
+ }
19
+
20
+ delete waitingMessages[data.trackingProperties.id]
21
+ if (data.error) {
22
+ deferred.reject(JSON.parse(data.error))
23
+ } else {
24
+ deferred.resolve(data.response)
25
+ }
26
+ }
27
+ }
28
+ window.addEventListener('message', messageHandler)
29
+
30
+ let isDestroyed = false
31
+ return {
32
+ get isDesk() {
33
+ return isDesk
34
+ },
35
+ post: <T>(action: string, content: unknown, options?: { fireAndForget: T extends void ? never : true }) => {
36
+ if (isDestroyed) {
37
+ throw new Error('Channel is destroyed')
38
+ }
39
+
40
+ const id = randomId()
41
+
42
+ let promise: Promise<unknown>
43
+ if (options?.fireAndForget) {
44
+ promise = Promise.resolve()
45
+ } else {
46
+ const deferred = Promise.withResolvers()
47
+ waitingMessages[id] = { reject: deferred.reject, resolve: deferred.resolve }
48
+ promise = deferred.promise
49
+ }
50
+
51
+ window.parent.postMessage(
52
+ {
53
+ message: {
54
+ action: `blipEvent:${action}`,
55
+ content,
56
+ caller,
57
+ },
58
+ trackingProperties: {
59
+ id,
60
+ },
61
+ },
62
+ '*',
63
+ )
64
+
65
+ return promise as Promise<T>
66
+ },
67
+ destroy: () => {
68
+ window.removeEventListener('message', messageHandler)
69
+ isDestroyed = true
70
+ },
71
+ }
72
+ }
@@ -0,0 +1,75 @@
1
+ import {
2
+ type BlipClient,
3
+ BlipError,
4
+ type Command,
5
+ type CommandMethods,
6
+ type Message,
7
+ type MessageTypes,
8
+ Node,
9
+ type Sender,
10
+ } from '../../index.ts'
11
+ import { isReason } from '../../types/reason.ts'
12
+ import { startChannel } from './communication.ts'
13
+
14
+ export class PluginSender implements Sender {
15
+ public readonly channel
16
+
17
+ constructor() {
18
+ this.channel = startChannel()
19
+ }
20
+
21
+ public async sendMessage<Type extends MessageTypes>(message: Message<Type>) {
22
+ await this.sendCommand({
23
+ id: message.id,
24
+ to: 'postmaster@scheduler.msging.net',
25
+ uri: '/schedules',
26
+ method: 'set',
27
+ type: 'application/vnd.iris.schedule+json',
28
+ resource: {
29
+ message,
30
+ when: new Date().toISOString(),
31
+ },
32
+ })
33
+ }
34
+
35
+ public async sendCommand(command: Command<CommandMethods>): Promise<unknown> {
36
+ const destination =
37
+ this.getRealm() === 'desk'
38
+ ? undefined
39
+ : command.to && Node.from(command.to).domain?.endsWith('msging.net')
40
+ ? 'MessagingHubService'
41
+ : 'BlipService'
42
+
43
+ try {
44
+ return await this.channel.post('sendCommand', { command, destination })
45
+ } catch (err) {
46
+ if (isReason(err)) {
47
+ throw new BlipError(command.uri, err)
48
+ } else if (BlipError.isFailedCommandResponse(err)) {
49
+ throw BlipError.commandResponseToBlipError(command.uri, err)
50
+ }
51
+
52
+ throw err
53
+ }
54
+ }
55
+
56
+ public static check(blipClient: BlipClient): PluginSender {
57
+ const isPluginSender = blipClient.sender instanceof PluginSender
58
+ if (!isPluginSender) {
59
+ throw new Error('This command is only allowed for plugin senders')
60
+ }
61
+ return blipClient.sender
62
+ }
63
+
64
+ public static getRealm(blipClient: BlipClient): 'portal' | 'desk' {
65
+ const isPluginSender = blipClient.sender instanceof PluginSender
66
+ if (!isPluginSender) {
67
+ throw new Error('This command is only allowed for plugin senders')
68
+ }
69
+ return blipClient.sender.getRealm()
70
+ }
71
+
72
+ private getRealm(): 'portal' | 'desk' {
73
+ return this.channel.isDesk ? 'desk' : 'portal'
74
+ }
75
+ }
@@ -0,0 +1,33 @@
1
+ // ref: https://github.com/takenet/lime-csharp/tree/master/src/Lime.Protocol/Security
2
+ export interface GuestAuthentication {
3
+ scheme: 'guest'
4
+ }
5
+
6
+ export interface KeyAuthentication {
7
+ scheme: 'key'
8
+ key: string
9
+ }
10
+
11
+ // This auth method doesn't exist in reality, it is only used to facilitate usage
12
+ export interface TokenAuthentication {
13
+ scheme: 'token'
14
+ token: string
15
+ }
16
+
17
+ export interface ExternalAuthentication {
18
+ scheme: 'external'
19
+ issuer: string
20
+ token: string
21
+ }
22
+
23
+ export interface PlainAuthentication {
24
+ scheme: 'plain'
25
+ password: string
26
+ }
27
+
28
+ export type Authentication =
29
+ | GuestAuthentication
30
+ | KeyAuthentication
31
+ | ExternalAuthentication
32
+ | PlainAuthentication
33
+ | TokenAuthentication
@@ -0,0 +1,145 @@
1
+ import {
2
+ type BlipDomain,
3
+ type Command,
4
+ type CommandMethods,
5
+ type Identity,
6
+ type Message,
7
+ type MessageTypes,
8
+ Node,
9
+ type NodeLike,
10
+ type Notification,
11
+ type UnknownCommandResponse,
12
+ } from '../types/index.ts'
13
+ import { EnvelopeResolver, type EventMap, type Listener } from './enveloperesolver.ts'
14
+ import type { Authentication } from './security.ts'
15
+ import type { SessionNegotiator } from './sessionnegotiator.ts'
16
+
17
+ export interface Sender {
18
+ sendMessage<Type extends MessageTypes>(message: Message<Type>): Promise<void>
19
+ sendCommand(command: Command<CommandMethods>): Promise<unknown>
20
+ }
21
+
22
+ export type ConnectionSenderConstructor<TSender = Sender> = new (options: {
23
+ node: NodeLike
24
+ authentication: Authentication
25
+ tenantId?: string
26
+ }) => TSender
27
+
28
+ export class ConnectionSender {
29
+ private readonly _domain: BlipDomain
30
+
31
+ constructor(options: ConstructorParameters<ConnectionSenderConstructor>[0]) {
32
+ this._domain = (Node.from(options.node).domain as BlipDomain) ?? 'msging.net'
33
+ }
34
+
35
+ public get domain() {
36
+ return this._domain
37
+ }
38
+
39
+ // this method is a mess but provides a good and flexible api with a single method to login
40
+ protected static login<S extends ConnectionSender>(bot: string | Identity, accessKey: string, tenantId?: string): S
41
+ protected static login<S extends ConnectionSender>(token: string, tenantId?: string): S
42
+ protected static login<S extends ConnectionSender>(
43
+ tokenOrBot: string | Identity,
44
+ accessKeyOrTenantId?: string,
45
+ tenantIdOrUndefined?: string,
46
+ ) {
47
+ let node: NodeLike
48
+ let accessKey: string
49
+ let tenantId: string | undefined
50
+
51
+ const isTokenBasedAuth = !tenantIdOrUndefined && typeof tokenOrBot === 'string'
52
+
53
+ if (isTokenBasedAuth) {
54
+ try {
55
+ const { identity, secret } = ConnectionSender.parseToken(tokenOrBot)
56
+ node = identity
57
+ accessKey = secret
58
+ tenantId = accessKeyOrTenantId
59
+ } catch {
60
+ if (!accessKeyOrTenantId) {
61
+ throw new Error('Invalid token format and no access key provided')
62
+ }
63
+ node = Node.from(tokenOrBot, 'msging.net')
64
+ accessKey = accessKeyOrTenantId
65
+ tenantId = tenantIdOrUndefined
66
+ }
67
+ } else {
68
+ if (!accessKeyOrTenantId) {
69
+ throw new Error('Access key must be provided for bot-based authentication')
70
+ }
71
+ node = Node.from(tokenOrBot, 'msging.net')
72
+ accessKey = accessKeyOrTenantId
73
+ tenantId = tenantIdOrUndefined
74
+ }
75
+
76
+ // biome-ignore lint/complexity/noThisInStatic: fair use-case
77
+ return new this({
78
+ node,
79
+ authentication: {
80
+ scheme: 'key',
81
+ key: accessKey,
82
+ },
83
+ tenantId,
84
+ }) as S
85
+ }
86
+
87
+ public static createToken(node: NodeLike, secret: string) {
88
+ const identity = Node.from(node).toIdentity()
89
+ return btoa(`${identity}:${atob(secret)}`)
90
+ }
91
+
92
+ public static parseToken(token: string) {
93
+ let cleantoken = token.trim()
94
+ const parts = cleantoken.split(' ')
95
+ if (parts.length > 1) {
96
+ cleantoken = parts[1]
97
+ }
98
+
99
+ const [identityOrIdentifier, key] = atob(cleantoken).split(':')
100
+ if (!identityOrIdentifier || !key) {
101
+ throw new Error('Invalid token format')
102
+ }
103
+
104
+ const identity = Node.isValid(identityOrIdentifier)
105
+ ? identityOrIdentifier
106
+ : new Node(identityOrIdentifier, 'msging.net').toIdentity()
107
+
108
+ return {
109
+ identity,
110
+ secret: btoa(key),
111
+ }
112
+ }
113
+ }
114
+
115
+ export abstract class OpenConnectionSender extends ConnectionSender implements Sender {
116
+ protected readonly envelopeResolver = new EnvelopeResolver(this)
117
+ protected sessionNegotiator: SessionNegotiator | null = null
118
+
119
+ abstract sendMessage<Type extends MessageTypes>(message: Message<Type>): Promise<void>
120
+ abstract sendCommand(command: Command<CommandMethods>): Promise<unknown>
121
+ abstract sendNotification(notification: Notification): Promise<void>
122
+ abstract close(): Promise<void>
123
+ abstract sendCommandResponse(response: UnknownCommandResponse): Promise<void>
124
+
125
+ public on<K extends keyof EventMap>(
126
+ ev: K,
127
+ listener: Listener<K>['callback'],
128
+ predicate?: Listener<K>['predicate'],
129
+ ): this {
130
+ this.envelopeResolver.addListener(ev, {
131
+ callback: listener,
132
+ predicate: predicate,
133
+ })
134
+ return this
135
+ }
136
+
137
+ public off<K extends keyof EventMap>(ev: K, listener: Listener<K>['callback']): this {
138
+ this.envelopeResolver.removeListener(ev, listener)
139
+ return this
140
+ }
141
+
142
+ public get session() {
143
+ return this.sessionNegotiator?.session ?? null
144
+ }
145
+ }
@@ -0,0 +1,175 @@
1
+ import type { Envelope } from '../types/envelope.ts'
2
+ import { Node, type NodeLike } from '../types/node.ts'
3
+ import type { Session } from '../types/session.ts'
4
+ import type { Authentication } from './security.ts'
5
+ import { OpenConnectionSender } from './sender.ts'
6
+
7
+ export type ConnectionSession = {
8
+ id: string
9
+ localNode: Node
10
+ remoteNode: Node
11
+ scheme: Authentication['scheme']
12
+ }
13
+
14
+ export class SessionNegotiator {
15
+ public state: Session['state'] | 'present' = 'new'
16
+ public session: ConnectionSession | null = null
17
+
18
+ private presencePromise: Promise<void> | null = null
19
+ private currentSessionResolver: Pick<PromiseWithResolvers<Session>, 'reject' | 'resolve'> | null = null
20
+
21
+ constructor(
22
+ private readonly sender: OpenConnectionSender,
23
+ private readonly sendSession: (session: Session) => void,
24
+ ) {}
25
+
26
+ public async negotiate(options: {
27
+ node: NodeLike
28
+ authentication: Authentication
29
+ }): Promise<void> {
30
+ const timeout = setTimeout(() => {
31
+ if (this.negotiating) {
32
+ this.sender.close()
33
+ throw new Error('Negotiation timeout')
34
+ }
35
+ }, 60000) // 60 seconds
36
+
37
+ try {
38
+ this.sendSession({
39
+ state: 'new',
40
+ } as Session)
41
+
42
+ const negotiation = await this.waitForSessionResponse()
43
+ let authenticating: Session
44
+ if (negotiation.state === 'negotiating') {
45
+ if (!negotiation.encryptionOptions?.includes('none')) {
46
+ throw new Error('Unsupported encryption options')
47
+ }
48
+
49
+ this.sendSession({
50
+ id: negotiation.id,
51
+ state: 'negotiating',
52
+ encryption: 'none',
53
+ compression: negotiation.compressionOptions?.at(-1),
54
+ })
55
+ await this.waitForSessionResponse()
56
+ authenticating = await this.waitForSessionResponse()
57
+ } else if (negotiation.state === 'authenticating') {
58
+ authenticating = negotiation
59
+ } else {
60
+ throw new Error('Unexpected session state')
61
+ }
62
+
63
+ if (options.authentication.scheme === 'token') {
64
+ const { secret } = OpenConnectionSender.parseToken(options.authentication.token)
65
+ options.authentication = {
66
+ scheme: 'key',
67
+ key: secret,
68
+ }
69
+ }
70
+
71
+ if (!authenticating.schemeOptions?.includes(options.authentication.scheme)) {
72
+ throw new Error(
73
+ `Unsupported authentication scheme: ${options.authentication.scheme} (${authenticating.schemeOptions})`,
74
+ )
75
+ }
76
+
77
+ const { scheme, ...authenticationOptions } = options.authentication
78
+ this.sendSession({
79
+ id: authenticating.id,
80
+ from: options.node,
81
+ state: 'authenticating',
82
+ scheme,
83
+ authentication: authenticationOptions,
84
+ })
85
+ const authenticated = await this.waitForSessionResponse()
86
+ if (authenticated.state !== 'established') {
87
+ throw new Error('Authentication failed')
88
+ }
89
+
90
+ this.session = {
91
+ id: authenticated.id,
92
+ localNode: Node.from(authenticated.to!),
93
+ remoteNode: Node.from(authenticated.from!),
94
+ scheme,
95
+ }
96
+ } finally {
97
+ clearTimeout(timeout)
98
+ }
99
+ }
100
+
101
+ public handleEnvelope(envelope: Envelope) {
102
+ if (this.currentSessionResolver) {
103
+ this.currentSessionResolver.resolve(envelope as Session)
104
+ this.currentSessionResolver = null
105
+ }
106
+ }
107
+
108
+ public async ensurePresence(currentCommandUri = ''): Promise<void> {
109
+ if (!this.session) {
110
+ throw new Error('Session not established')
111
+ }
112
+
113
+ if (this.state === 'present') {
114
+ return
115
+ }
116
+
117
+ if (this.session.scheme === 'guest') {
118
+ return
119
+ }
120
+
121
+ if (currentCommandUri === '/presence') {
122
+ // someone is already setting the presence manually
123
+ if (!this.presencePromise) {
124
+ this.state = 'present'
125
+ }
126
+
127
+ // its actually setting the presence
128
+ return
129
+ }
130
+
131
+ if (this.presencePromise) {
132
+ return this.presencePromise
133
+ }
134
+
135
+ this.presencePromise = this.sender.sendCommand({
136
+ id: Date.now().toString(),
137
+ method: 'set',
138
+ uri: '/presence',
139
+ type: 'application/vnd.lime.presence+json',
140
+ resource: {
141
+ status: 'available',
142
+ },
143
+ }) as Promise<void>
144
+
145
+ await this.presencePromise
146
+ this.state = 'present'
147
+ }
148
+
149
+ public finish() {
150
+ if (!this.session) {
151
+ throw new Error('Session not established')
152
+ }
153
+
154
+ this.sendSession({
155
+ id: this.session?.id,
156
+ state: 'finishing',
157
+ })
158
+ }
159
+
160
+ public get negotiating(): boolean {
161
+ return this.state !== 'established' && this.state !== 'present'
162
+ }
163
+
164
+ private async waitForSessionResponse(): Promise<Session> {
165
+ const { promise, resolve, reject } = Promise.withResolvers<Session>()
166
+ this.currentSessionResolver = { resolve, reject }
167
+ const session = await promise
168
+ if (session.state === 'failed') {
169
+ throw new Error(`Session negotiation failed: ${session.reason?.description} (${session.reason?.code})`)
170
+ }
171
+
172
+ this.state = session.state
173
+ return session
174
+ }
175
+ }