@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.
- package/package.json +2 -2
- package/src/client.ts +117 -0
- package/src/index.ts +6 -0
- package/src/namespaces/account.ts +729 -0
- package/src/namespaces/activecampaign.ts +285 -0
- package/src/namespaces/analytics.ts +230 -0
- package/src/namespaces/billing.ts +17 -0
- package/src/namespaces/builder.ts +52 -0
- package/src/namespaces/configurations.ts +19 -0
- package/src/namespaces/context.ts +67 -0
- package/src/namespaces/desk.ts +679 -0
- package/src/namespaces/media.ts +39 -0
- package/src/namespaces/namespace.ts +125 -0
- package/src/namespaces/plugins.ts +223 -0
- package/src/namespaces/portal.ts +402 -0
- package/src/namespaces/scheduler.ts +88 -0
- package/src/namespaces/whatsapp.ts +383 -0
- package/src/sender/bliperror.ts +42 -0
- package/src/sender/enveloperesolver.ts +148 -0
- package/src/sender/gateway/customgatewaysender.ts +43 -0
- package/src/sender/http/httpsender.ts +94 -0
- package/src/sender/index.ts +7 -0
- package/src/sender/plugin/communication.ts +72 -0
- package/src/sender/plugin/pluginsender.ts +75 -0
- package/src/sender/security.ts +33 -0
- package/src/sender/sender.ts +145 -0
- package/src/sender/sessionnegotiator.ts +175 -0
- package/src/sender/tcp/tcpsender.ts +252 -0
- package/src/sender/throttler.ts +36 -0
- package/src/sender/websocket/websocketsender.ts +175 -0
- package/src/types/account.ts +84 -0
- package/src/types/analytics.ts +18 -0
- package/src/types/billing.ts +15 -0
- package/src/types/command.ts +47 -0
- package/src/types/commons.ts +16 -0
- package/src/types/desk.ts +51 -0
- package/src/types/envelope.ts +9 -0
- package/src/types/flow.ts +327 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +116 -0
- package/src/types/node.ts +86 -0
- package/src/types/notification.ts +18 -0
- package/src/types/plugins.ts +51 -0
- package/src/types/portal.ts +39 -0
- package/src/types/reason.ts +22 -0
- package/src/types/session.ts +22 -0
- package/src/types/whatsapp.ts +84 -0
- package/src/utils/odata.ts +114 -0
- package/src/utils/random.ts +3 -0
- 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
|
+
}
|