chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { buildConversationTranscript } from '@botpress/common'
|
||||
import { AxiosError } from 'axios'
|
||||
import { getFreshchatClient } from 'src/client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const startHitl: bp.IntegrationProps['actions']['startHitl'] = async ({ ctx, client, input, logger }) => {
|
||||
try {
|
||||
const freshchatClient = getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
|
||||
const { user } = await client.getUser({
|
||||
// Retrieve the user ID created in the createUser action
|
||||
id: input.userId,
|
||||
})
|
||||
|
||||
if (!user.tags.id?.length) {
|
||||
throw new RuntimeError("Input user doesn't have a Freshchat User Id")
|
||||
}
|
||||
|
||||
const { title, description, messageHistory } = input
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { channelId },
|
||||
},
|
||||
} = await client.getState({
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
name: 'freshchat',
|
||||
})
|
||||
|
||||
const messages = [
|
||||
{
|
||||
message_parts: [
|
||||
{
|
||||
text: {
|
||||
content: `New Conversation Started
|
||||
|
||||
Title: ${title}
|
||||
Description: ${description}
|
||||
`,
|
||||
},
|
||||
},
|
||||
],
|
||||
channel_id: channelId,
|
||||
message_type: 'normal',
|
||||
actor_type: 'user',
|
||||
actor_id: user.tags.id,
|
||||
},
|
||||
]
|
||||
|
||||
messages.push({
|
||||
message_parts: [
|
||||
{
|
||||
text: {
|
||||
content:
|
||||
'Transcript:\n\n' +
|
||||
(await buildConversationTranscript({
|
||||
ctx,
|
||||
client,
|
||||
messages: messageHistory,
|
||||
customTranscriptFormatter: (msgs) =>
|
||||
msgs.map((msg) => (msg.isBot ? 'Bot: ' : 'User: ') + msg.text.join('\n')).join('\n\n'),
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
channel_id: channelId,
|
||||
message_type: 'normal',
|
||||
actor_type: 'user',
|
||||
actor_id: user.tags.id,
|
||||
})
|
||||
|
||||
const freshchatConversation = await freshchatClient.createConversation({
|
||||
userId: user.tags.id as string,
|
||||
messages,
|
||||
channelId,
|
||||
priority: input.hitlSession?.priority,
|
||||
})
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'hitl',
|
||||
tags: {
|
||||
id: `${freshchatConversation.conversation_id}`,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.forBot().error('Error Starting Freshchat Hitl: ' + error.message, error?.response?.data)
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
export const stopHitl: bp.IntegrationProps['actions']['stopHitl'] = async ({ ctx, input, client, logger }) => {
|
||||
const { conversation } = await client.getConversation({
|
||||
id: input.conversationId,
|
||||
})
|
||||
|
||||
const freshchatConversationId: string | undefined = conversation.tags.id
|
||||
|
||||
if (!freshchatConversationId) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const freshchatClient = getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
|
||||
try {
|
||||
await freshchatClient.setConversationAsResolved(freshchatConversationId)
|
||||
} catch (thrown: unknown) {
|
||||
const error: AxiosError = thrown instanceof AxiosError ? thrown : new AxiosError(String(thrown))
|
||||
logger.forBot().error('Error resolving HITL conversation on Freshchat: ' + error.message, error?.response?.data)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// create a user in both platforms
|
||||
export const createUser: bp.IntegrationProps['actions']['createUser'] = async ({ client, input, ctx, logger }) => {
|
||||
try {
|
||||
const freshchatClient = getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
|
||||
const { user: botpressUser } = await client.getOrCreateUser({
|
||||
...input,
|
||||
tags: {
|
||||
email: input.email,
|
||||
},
|
||||
})
|
||||
|
||||
const freshchatUser = await freshchatClient.getOrCreateUser({ ...input, botpressUserId: botpressUser.id })
|
||||
|
||||
if (!freshchatUser.id) {
|
||||
throw new RuntimeError('Failed to create Freshchat User')
|
||||
}
|
||||
|
||||
await client.updateUser({
|
||||
...input,
|
||||
id: botpressUser.id,
|
||||
tags: {
|
||||
id: freshchatUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
userId: botpressUser.id, // always return the newly created botpress user id
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw new RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { startHitl, stopHitl, createUser } from './hitl'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
startHitl,
|
||||
stopHitl,
|
||||
createUser,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,173 @@
|
||||
import * as bpCommon from '@botpress/common'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { getFreshchatClient } from './client'
|
||||
import { getMediaMetadata } from './util'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const wrapChannel = bpCommon.createChannelWrapper<bp.IntegrationProps>()({
|
||||
toolFactories: {
|
||||
freshchatClient({ ctx, logger }) {
|
||||
return getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
},
|
||||
|
||||
async freshchatUserId({ client, payload, user: attachedUser }) {
|
||||
const user = payload.userId ? (await client.getUser({ id: payload.userId })).user : attachedUser
|
||||
return user.tags.id
|
||||
},
|
||||
|
||||
async freshchatConversationId({ conversation }) {
|
||||
const freshchatConversationId = conversation.tags.id
|
||||
|
||||
if (!freshchatConversationId) {
|
||||
throw new sdk.RuntimeError('Freshchat conversation id not found')
|
||||
}
|
||||
|
||||
return freshchatConversationId
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const channels = {
|
||||
hitl: {
|
||||
messages: {
|
||||
text: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'text' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
const freshchatMessageId = await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ text: { content: payload.text } },
|
||||
])
|
||||
await ack({
|
||||
tags: { id: freshchatMessageId },
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
image: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'image' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
const freshchatMessageId = await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ image: { url: payload.imageUrl } },
|
||||
])
|
||||
await ack({
|
||||
tags: { id: freshchatMessageId },
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
audio: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'audio' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
const metadata = await getMediaMetadata(payload.audioUrl)
|
||||
const freshchatMessageId = await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ video: { url: payload.audioUrl, content_type: metadata.mimeType } },
|
||||
])
|
||||
|
||||
await ack({
|
||||
tags: { id: freshchatMessageId },
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
video: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'video' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
const metadata = await getMediaMetadata(payload.videoUrl)
|
||||
const freshchatMessageId = await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ video: { url: payload.videoUrl, content_type: metadata.mimeType } },
|
||||
])
|
||||
await ack({
|
||||
tags: { id: freshchatMessageId },
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
file: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'file' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
const metadata = await getMediaMetadata(payload.fileUrl)
|
||||
const freshchatMessageId = await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{
|
||||
file: {
|
||||
url: payload.fileUrl,
|
||||
name: payload.title || metadata.fileName || 'file',
|
||||
contentType: metadata.mimeType,
|
||||
},
|
||||
},
|
||||
])
|
||||
await ack({
|
||||
tags: { id: freshchatMessageId },
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
bloc: wrapChannel(
|
||||
{ channelName: 'hitl', messageType: 'bloc' },
|
||||
async ({ ack, payload, freshchatClient, freshchatUserId, freshchatConversationId }) => {
|
||||
for (const item of payload.items) {
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ text: { content: item.payload.text } },
|
||||
])
|
||||
break
|
||||
case 'markdown':
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ text: { content: item.payload.markdown } },
|
||||
])
|
||||
break
|
||||
case 'image':
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ image: { url: item.payload.imageUrl } },
|
||||
])
|
||||
break
|
||||
case 'video':
|
||||
const videoMetadata = await getMediaMetadata(item.payload.videoUrl)
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ video: { url: item.payload.videoUrl, content_type: videoMetadata.mimeType } },
|
||||
])
|
||||
break
|
||||
case 'audio':
|
||||
const audioMetadata = await getMediaMetadata(item.payload.audioUrl)
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ video: { url: item.payload.audioUrl, content_type: audioMetadata.mimeType } },
|
||||
])
|
||||
break
|
||||
case 'file':
|
||||
const fileMetadata = await getMediaMetadata(item.payload.fileUrl)
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{
|
||||
file: {
|
||||
url: item.payload.fileUrl,
|
||||
name: item.payload.title || fileMetadata.fileName || 'file',
|
||||
contentType: fileMetadata.mimeType,
|
||||
},
|
||||
},
|
||||
])
|
||||
break
|
||||
case 'location':
|
||||
const { title, address, latitude, longitude } = item.payload
|
||||
const messageParts = []
|
||||
|
||||
if (title) {
|
||||
messageParts.push(title, '')
|
||||
}
|
||||
if (address) {
|
||||
messageParts.push(address, '')
|
||||
}
|
||||
messageParts.push(`Latitude: ${latitude}`, `Longitude: ${longitude}`)
|
||||
|
||||
await freshchatClient.sendMessage(freshchatUserId, freshchatConversationId, [
|
||||
{ text: { content: messageParts.join('\n') } },
|
||||
])
|
||||
break
|
||||
default:
|
||||
item satisfies never
|
||||
}
|
||||
}
|
||||
|
||||
await ack({ tags: {} })
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import axios, { type Axios } from 'axios'
|
||||
import type {
|
||||
FreshchatMessage,
|
||||
FreshchatAgent,
|
||||
FreshchatChannel,
|
||||
FreshchatConfiguration,
|
||||
FreshchatUser,
|
||||
} from './definitions/schemas'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
// API docs: https://developers.freshchat.com/api/
|
||||
|
||||
class FreshchatClient {
|
||||
private _client: Axios
|
||||
|
||||
public constructor(
|
||||
private _config: FreshchatConfiguration,
|
||||
private _logger: bp.Logger
|
||||
) {
|
||||
this._client = axios.create({
|
||||
baseURL: `https://${this._config.domain}.freshchat.com/v2`,
|
||||
})
|
||||
|
||||
this._client.interceptors.request.use((axionsConfig) => {
|
||||
// @ts-ignore
|
||||
axionsConfig.headers = {
|
||||
...axionsConfig.headers,
|
||||
Authorization: 'Bearer ' + this._config.token,
|
||||
}
|
||||
return axionsConfig
|
||||
})
|
||||
}
|
||||
|
||||
public async createConversation(args: {
|
||||
userId: string
|
||||
messages: FreshchatMessage[]
|
||||
channelId: string
|
||||
priority?: 'Low' | 'Medium' | 'High' | 'Urgent'
|
||||
}): Promise<{ conversation_id: string; channel_id: string }> {
|
||||
const { data } = await this._client.post('/conversations', {
|
||||
channel_id: args.channelId,
|
||||
messages: args.messages,
|
||||
users: [
|
||||
{
|
||||
id: args.userId,
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
...(args.priority ? { priority: args.priority } : {}),
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
public async setConversationAsResolved(freshchatConversationId: string) {
|
||||
await this._client.put(`/conversations/${freshchatConversationId}`, {
|
||||
status: 'resolved',
|
||||
})
|
||||
}
|
||||
|
||||
public async sendMessage(
|
||||
fromUserId: string | undefined,
|
||||
freshchatConversationId: string,
|
||||
messageParts: Array<
|
||||
| { text: { content: string } }
|
||||
| { image: { url: string } }
|
||||
| { video: { url: string; content_type?: string } }
|
||||
| {
|
||||
file: {
|
||||
url: string
|
||||
name: string
|
||||
file_size_in_bytes?: number
|
||||
contentType?: string
|
||||
file_extension?: string
|
||||
fileSource?: 'FRESHCHAT' | 'FRESHBOTS'
|
||||
}
|
||||
}
|
||||
>
|
||||
): Promise<string> {
|
||||
const response = await this._client.post<FreshchatMessage & { id: string }>(
|
||||
`/conversations/${freshchatConversationId}/messages`,
|
||||
{
|
||||
message_parts: messageParts,
|
||||
message_type: 'normal',
|
||||
...(fromUserId?.length && { user_id: fromUserId }),
|
||||
actor_type: fromUserId?.length ? 'user' : 'bot',
|
||||
...(fromUserId?.length && { actor_id: fromUserId }),
|
||||
}
|
||||
)
|
||||
|
||||
const messageId = response.data.id
|
||||
|
||||
if (!messageId) {
|
||||
throw new sdk.RuntimeError('Failed to send message')
|
||||
}
|
||||
|
||||
return messageId
|
||||
}
|
||||
|
||||
public async getOrCreateUser(props: {
|
||||
name?: string
|
||||
email?: string
|
||||
pictureUrl?: string
|
||||
botpressUserId: string
|
||||
}): Promise<FreshchatUser> {
|
||||
let user = await this._getUserByReferenceId(props.botpressUserId)
|
||||
|
||||
if (!user) {
|
||||
user = await this._getUserByEmail(props.email)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
user = await this._createUser(props)
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
private async _getUserByEmail(email?: string): Promise<FreshchatUser | undefined> {
|
||||
if (!email) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const result = await this._client.get('/users', {
|
||||
params: {
|
||||
email,
|
||||
},
|
||||
})
|
||||
return result?.data?.users[0]
|
||||
} catch (e: any) {
|
||||
this._logger.forBot().error('Failed to get user by email: ' + email, e.message, e?.response?.data)
|
||||
throw new sdk.RuntimeError('Failed to get user by email ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async _getUserByReferenceId(referenceId: string): Promise<FreshchatUser | undefined> {
|
||||
try {
|
||||
const result = await this._client.get('/users?reference_id=' + referenceId)
|
||||
return result?.data?.users[0]
|
||||
} catch (e: any) {
|
||||
this._logger.forBot().error('Failed to get user by reference_id: ' + referenceId, e.message, e?.response?.data)
|
||||
throw new sdk.RuntimeError('Failed to get user by reference_id ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async _createUser(props: {
|
||||
name?: string
|
||||
email?: string
|
||||
pictureUrl?: string
|
||||
botpressUserId: string
|
||||
}): Promise<FreshchatUser> {
|
||||
const result = await this._client.post('/users', {
|
||||
email: props.email,
|
||||
first_name: props.name,
|
||||
reference_id: props.botpressUserId,
|
||||
avatar: { url: props.pictureUrl },
|
||||
})
|
||||
return result.data
|
||||
}
|
||||
|
||||
public async getChannels(): Promise<FreshchatChannel[]> {
|
||||
try {
|
||||
const result = await this._client.get<{ channels: FreshchatChannel[] }>('/channels?items_per_page=9999')
|
||||
return result?.data?.channels
|
||||
} catch (e: any) {
|
||||
this._logger.forBot().error('Failed to get channels : ' + e.message, e?.response?.data)
|
||||
throw new sdk.RuntimeError('Failed to get channels: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyToken(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this._client.get<{ organisation_id: number }>('/accounts/configuration')
|
||||
return !!result?.data?.organisation_id
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public async getAgentById(id: string): Promise<FreshchatAgent | undefined> {
|
||||
try {
|
||||
const result = await this._client.get('/agents/' + id)
|
||||
return result?.data
|
||||
} catch (e: any) {
|
||||
this._logger.forBot().error('Failed to get user by id: ' + id, e.message, e?.response?.data)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getFreshchatClient = (config: FreshchatConfiguration, logger: bp.Logger) =>
|
||||
new FreshchatClient(config, logger)
|
||||
@@ -0,0 +1 @@
|
||||
export const INTEGRATION_NAME = 'freshchat'
|
||||
@@ -0,0 +1,3 @@
|
||||
import { IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const channels = undefined satisfies IntegrationDefinitionProps['channels']
|
||||
@@ -0,0 +1,137 @@
|
||||
// Enum for actor types
|
||||
type ActorType = 'user' | 'agent' | 'system'
|
||||
|
||||
// Enum for message types
|
||||
type MessageType = 'normal' | 'private'
|
||||
|
||||
// Actor definition
|
||||
type Actor = {
|
||||
actor_type: ActorType
|
||||
actor_id: string
|
||||
}
|
||||
|
||||
// Generic Event type that all specific event types will extend
|
||||
export type FreshchatEvent<T> = {
|
||||
actor: Actor
|
||||
action: 'message_create' | 'conversation_assignment' | 'conversation_resolution' | 'conversation_reopen'
|
||||
action_time: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// Message part and message for the message_create event
|
||||
export type TextMessagePart = {
|
||||
text: {
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FileMessagePart = {
|
||||
file: {
|
||||
name: string
|
||||
url: string
|
||||
file_size_in_bytes: number
|
||||
content_type: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ImageMessagePart = {
|
||||
image: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
type MessagePart = TextMessagePart | FileMessagePart | ImageMessagePart
|
||||
|
||||
type Message = {
|
||||
message_parts: MessagePart[]
|
||||
app_id: string
|
||||
actor_id: string
|
||||
id: string
|
||||
channel_id: string
|
||||
conversation_id: string
|
||||
interaction_id: string
|
||||
actor_type: ActorType
|
||||
created_time: string
|
||||
user_id: string
|
||||
restrictResponse?: boolean
|
||||
botsPrivateNote?: boolean
|
||||
message_type?: MessageType
|
||||
message_source?: string
|
||||
}
|
||||
|
||||
type MessageCreateData = {
|
||||
message: Message
|
||||
}
|
||||
|
||||
// Types for conversation events
|
||||
type Conversation = {
|
||||
conversation_id: string
|
||||
app_id: string
|
||||
status: string
|
||||
channel_id: string
|
||||
skill_id: number
|
||||
sla_policy_id: number
|
||||
sla_breached: boolean
|
||||
assigned_agent_id?: string
|
||||
assigned_org_agent_id?: string
|
||||
assigned_group_id?: string
|
||||
}
|
||||
|
||||
type ReopenData = {
|
||||
reopener: string
|
||||
reopener_id: string
|
||||
conversation: Conversation
|
||||
interaction_id: string
|
||||
}
|
||||
|
||||
type ResolveData = {
|
||||
resolve: Resolve
|
||||
}
|
||||
|
||||
type Resolve = {
|
||||
resolver: string
|
||||
resolver_id: string
|
||||
conversation: Conversation
|
||||
interaction_id: string
|
||||
user?: User
|
||||
}
|
||||
|
||||
type AssignmentData = {
|
||||
assignment: Assignment
|
||||
}
|
||||
|
||||
type Assignment = {
|
||||
assignor: string
|
||||
assignor_id: string
|
||||
to_agent_id: string
|
||||
to_group_id: string
|
||||
from_agent_id: string
|
||||
from_group_id: string
|
||||
conversation: Conversation
|
||||
interaction_id: string
|
||||
}
|
||||
|
||||
type User = {
|
||||
properties: Property[]
|
||||
created_time: string
|
||||
updated_time: string
|
||||
id: string
|
||||
first_name: string
|
||||
avatar: Avatar
|
||||
login_status: boolean
|
||||
}
|
||||
|
||||
type Property = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type Avatar = {
|
||||
url: string
|
||||
}
|
||||
|
||||
// Event type instances
|
||||
export type MessageCreateFreshchatEvent = FreshchatEvent<MessageCreateData>
|
||||
export type ConversationReopenFreshchatEvent = FreshchatEvent<ReopenData>
|
||||
export type ConversationResolutionFreshchatEvent = FreshchatEvent<ResolveData>
|
||||
export type ConversationAssignmentFreshchatEvent = FreshchatEvent<AssignmentData>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
import { FreshchatConfigurationSchema } from './schemas'
|
||||
|
||||
export { channels } from './channels'
|
||||
|
||||
export const events = {} satisfies IntegrationDefinitionProps['events']
|
||||
|
||||
export const configuration = {
|
||||
schema: FreshchatConfigurationSchema,
|
||||
} satisfies IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const states = {
|
||||
freshchat: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
channelId: z.string().title('Channel Id').describe('Id from the channel topic'),
|
||||
}),
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['states']
|
||||
|
||||
export const user = {
|
||||
tags: {
|
||||
id: { description: 'Freshchat User Id', title: 'Freshchat User Id' },
|
||||
email: { description: 'Freshchat User Email', title: 'Freshchat User Email' },
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['user']
|
||||
@@ -0,0 +1,152 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
export const CreateConversationResponseSchema = z.object({
|
||||
conversation_id: z.string(),
|
||||
channel_id: z.string(),
|
||||
})
|
||||
|
||||
export type CreateConversationResponse = z.output<typeof CreateConversationResponseSchema>
|
||||
|
||||
export type FreshchatConfiguration = z.infer<typeof FreshchatConfigurationSchema>
|
||||
|
||||
export const FreshchatConfigurationSchema = z.object({
|
||||
topic_name: z.string().title('Topic name').describe('Name from the default topic that is going to be used for HITL'),
|
||||
token: z.string().title('Api Key').describe('API key from Freshchat'),
|
||||
domain: z
|
||||
.string()
|
||||
.title('Domain Name')
|
||||
.describe('Your Freshchat domain from the Freshchat chat URL (example: yourcompany-5b321a95b1dfee217185497)'),
|
||||
agentAvatarUrl: z
|
||||
.string()
|
||||
.title('Default Agent Avatar URL')
|
||||
.optional()
|
||||
.describe(
|
||||
'Default URL for the agent avatar, if not provided, the first letter of the agent name will be used (Webchat Only)'
|
||||
),
|
||||
})
|
||||
|
||||
//Freshchat API Schemas
|
||||
|
||||
export const FreshchatAvatarSchema = z.object({
|
||||
url: z.string().optional(),
|
||||
})
|
||||
|
||||
export const FreshchatPropertySchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
|
||||
export const FreshchatUserSchema = z.object({
|
||||
id: z.string().optional(), // Optional as it's auto-generated
|
||||
created_time: z.string().optional(),
|
||||
updated_time: z.string().optional(),
|
||||
avatar: FreshchatAvatarSchema.optional(), // Optional as it's not stated if mandatory
|
||||
email: z.string(),
|
||||
first_name: z.string().optional(),
|
||||
last_name: z.string().optional(),
|
||||
login_status: z.boolean().optional(),
|
||||
org_contact_id: z.string().optional(),
|
||||
phone: z.string().optional(), // Optional as it's not stated if mandatory
|
||||
properties: z.array(FreshchatPropertySchema).optional(), // Optional as it's not stated if mandatory
|
||||
reference_id: z.string().optional(),
|
||||
restore_id: z.string().optional(),
|
||||
})
|
||||
|
||||
export const FreshchatChannelSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
icon: z.object({}).optional(), // Adjust schema for `icon` if it contains specific properties
|
||||
updated_time: z.string().datetime(),
|
||||
enabled: z.boolean(),
|
||||
public: z.boolean(),
|
||||
name: z.string(),
|
||||
tags: z.array(z.string()), // Assuming `tags` is an array of strings
|
||||
welcome_message: z.object({
|
||||
message_parts: z.array(
|
||||
z.object({
|
||||
text: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
message_type: z.enum(['normal']), // Adjust if there are other valid message types
|
||||
restrictResponse: z.boolean(),
|
||||
botsPrivateNote: z.boolean(),
|
||||
isBotsInput: z.boolean(),
|
||||
}),
|
||||
source: z.literal('FRESHCHAT'), // Assuming "FRESHCHAT" is the only valid value
|
||||
})
|
||||
|
||||
export const FreshchatAgentSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
created_time: z.string().datetime().optional(),
|
||||
agent_capacity: z.number().optional(),
|
||||
agent_status: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
availability_status: z.enum(['AVAILABLE', 'UNAVAILABLE', 'AWAY', 'BUSY']).optional(),
|
||||
avatar: z
|
||||
.object({
|
||||
url: z.string().url(),
|
||||
})
|
||||
.optional(),
|
||||
biography: z.string().optional(),
|
||||
email: z.string().email().optional(),
|
||||
first_name: z.string().optional(),
|
||||
freshid_group_ids: z.array(z.string().uuid()).optional(),
|
||||
freshid_uuid: z.string().optional(),
|
||||
groups: z.array(z.string().uuid()).optional(),
|
||||
is_deactivated: z.boolean().optional(),
|
||||
is_deleted: z.boolean().optional(),
|
||||
last_name: z.string().optional(),
|
||||
license_type: z.enum(['fulltime', 'parttime', 'contract']).optional(),
|
||||
locale: z.string().optional(),
|
||||
login_status: z.boolean().optional(),
|
||||
org_contact_id: z.string().optional(),
|
||||
role_id: z.string().optional(),
|
||||
role_name: z.string().optional(),
|
||||
routing_type: z.enum(['INTELLIASSIGN', 'MANUAL']).optional(),
|
||||
skill_id: z.string().uuid().optional(),
|
||||
social_profiles: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
|
||||
export const FreshchatConversationSchema = z.object({
|
||||
conversation_id: z.string().optional(),
|
||||
channel_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
})
|
||||
export type FreshchatConversation = z.infer<typeof FreshchatConversationSchema>
|
||||
|
||||
export type FreshchatUser = z.infer<typeof FreshchatUserSchema>
|
||||
export type FreshchatAgent = z.infer<typeof FreshchatAgentSchema>
|
||||
export type FreshchatChannel = z.infer<typeof FreshchatChannelSchema>
|
||||
|
||||
export type FreshchatMessage = {
|
||||
message_parts: {
|
||||
text: {
|
||||
content: string
|
||||
}
|
||||
}[]
|
||||
channel_id: string
|
||||
message_type: string
|
||||
actor_type: string
|
||||
actor_id: string
|
||||
}
|
||||
|
||||
// Extended Botpress User/Conversation Schemas
|
||||
|
||||
export const UserSchema = z.object({ id: z.string() })
|
||||
export const ConversationSchema = z.object({ id: z.string() })
|
||||
|
||||
// Event specific schema
|
||||
|
||||
export const MessageEventSchema = z.object({ text: z.string() })
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ConversationAssignmentFreshchatEvent } from '../definitions/freshchat-events'
|
||||
import { updateAgentUser } from '../util'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const executeConversationAssignment = async ({
|
||||
freshchatEvent,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
freshchatEvent: ConversationAssignmentFreshchatEvent
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
if (
|
||||
!freshchatEvent.data.assignment.to_agent_id.length ||
|
||||
freshchatEvent.data.assignment.from_agent_id === freshchatEvent.data.assignment.to_agent_id
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'hitl',
|
||||
tags: {
|
||||
id: freshchatEvent.data.assignment.conversation.conversation_id,
|
||||
},
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
id: freshchatEvent.data.assignment.to_agent_id,
|
||||
},
|
||||
})
|
||||
|
||||
await updateAgentUser(user, client, ctx, logger, true)
|
||||
|
||||
await client.createEvent({
|
||||
type: 'hitlAssigned',
|
||||
payload: {
|
||||
conversationId: conversation.id,
|
||||
userId: user.id as string,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ConversationResolutionFreshchatEvent } from '../definitions/freshchat-events'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const executeConversationResolution = async ({
|
||||
freshchatEvent,
|
||||
client,
|
||||
}: {
|
||||
freshchatEvent: ConversationResolutionFreshchatEvent
|
||||
client: bp.Client
|
||||
}) => {
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'hitl',
|
||||
tags: {
|
||||
id: freshchatEvent.data.resolve.conversation.conversation_id,
|
||||
},
|
||||
})
|
||||
|
||||
await client.createEvent({
|
||||
type: 'hitlStopped',
|
||||
payload: {
|
||||
conversationId: conversation.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Message } from '@botpress/common'
|
||||
import { MessageCreateFreshchatEvent } from '../definitions/freshchat-events'
|
||||
import { updateAgentUser } from '../util'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const executeMessageCreate = async ({
|
||||
freshchatEvent,
|
||||
client,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
freshchatEvent: MessageCreateFreshchatEvent
|
||||
client: bp.Client
|
||||
ctx: bp.Context
|
||||
logger: bp.Logger
|
||||
}) => {
|
||||
// Ignore non agent messages
|
||||
if (freshchatEvent.actor.actor_type === 'user') {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore private messages
|
||||
if (freshchatEvent.data.message.message_type === 'private') {
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'hitl',
|
||||
tags: {
|
||||
id: freshchatEvent.data.message.conversation_id,
|
||||
},
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
id: freshchatEvent.actor.actor_id,
|
||||
},
|
||||
})
|
||||
|
||||
const items: Extract<Message, { type: 'bloc' }>['payload']['items'] = []
|
||||
for (const messagePart of freshchatEvent.data.message.message_parts) {
|
||||
if ('text' in messagePart) {
|
||||
if (!messagePart.text.content.trim().length) {
|
||||
continue
|
||||
}
|
||||
|
||||
items.push({
|
||||
type: 'text',
|
||||
payload: {
|
||||
text: messagePart.text.content,
|
||||
},
|
||||
})
|
||||
} else if ('file' in messagePart) {
|
||||
const { name, url, content_type } = messagePart.file as {
|
||||
name: string
|
||||
url: string
|
||||
file_size_in_bytes: number
|
||||
content_type: string
|
||||
}
|
||||
if (content_type.startsWith('image/')) {
|
||||
items.push({
|
||||
type: 'image',
|
||||
payload: {
|
||||
imageUrl: url,
|
||||
},
|
||||
})
|
||||
} else if (content_type.startsWith('audio/')) {
|
||||
items.push({
|
||||
type: 'audio',
|
||||
payload: {
|
||||
audioUrl: url,
|
||||
},
|
||||
})
|
||||
} else if (content_type.startsWith('video/')) {
|
||||
items.push({
|
||||
type: 'video',
|
||||
payload: {
|
||||
videoUrl: url,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
type: 'file',
|
||||
payload: {
|
||||
title: name,
|
||||
fileUrl: url,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if ('image' in messagePart) {
|
||||
items.push({
|
||||
type: 'image',
|
||||
payload: {
|
||||
imageUrl: messagePart.image.url,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
logger.forBot().warn(`Unsupported message part type ${Object.keys(messagePart)[0]}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Only create message if there are items to send
|
||||
if (items.length > 0) {
|
||||
// An unassigned agent might send a message
|
||||
await updateAgentUser(user, client, ctx, logger)
|
||||
|
||||
await client.createMessage({
|
||||
type: 'bloc',
|
||||
payload: {
|
||||
items,
|
||||
},
|
||||
tags: {},
|
||||
userId: user?.id as string,
|
||||
conversationId: conversation.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FreshchatEvent } from './definitions/freshchat-events'
|
||||
import { executeConversationAssignment } from './events/conversationAssignment'
|
||||
import { executeConversationResolution } from './events/conversationResolution'
|
||||
import { executeMessageCreate } from './events/messageCreate'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async ({ ctx, req, logger, client }) => {
|
||||
if (!req.body) {
|
||||
logger.forBot().warn('Handler received an empty body')
|
||||
return
|
||||
}
|
||||
|
||||
logger.forBot().debug('Handler received request from Freshchat with payload:', req.body)
|
||||
|
||||
//https://crmsupport.freshworks.com/en/support/solutions/articles/50000004461-freshchat-webhooks-payload-structure-and-authentication
|
||||
const freshchatEvent = JSON.parse(req.body) as FreshchatEvent<any>
|
||||
|
||||
switch (freshchatEvent.action) {
|
||||
case 'message_create':
|
||||
await executeMessageCreate({ freshchatEvent, client, ctx, logger })
|
||||
break
|
||||
case 'conversation_assignment':
|
||||
await executeConversationAssignment({ freshchatEvent, client, ctx, logger })
|
||||
break
|
||||
case 'conversation_resolution':
|
||||
await executeConversationResolution({ freshchatEvent, client })
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn('Invalid Freshchat event of type: ' + freshchatEvent.action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { actions } from './actions'
|
||||
import { channels } from './channels'
|
||||
import { getFreshchatClient } from './client'
|
||||
import { handler } from './handler'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export default new bp.Integration({
|
||||
register: async ({ client, ctx, logger }) => {
|
||||
const freshchatClient = getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
|
||||
if (!(await freshchatClient.verifyToken())) {
|
||||
throw new RuntimeError('Invalid API token or domain name')
|
||||
}
|
||||
|
||||
const channels = await freshchatClient.getChannels()
|
||||
|
||||
if (!ctx.configuration?.topic_name?.length) {
|
||||
throw new RuntimeError('Topic name required')
|
||||
}
|
||||
|
||||
const channel = channels.find((channel) => channel.name === ctx.configuration.topic_name)
|
||||
|
||||
if (!channel) {
|
||||
throw new RuntimeError(`Topic with name '${ctx.configuration.topic_name}' doesn't exist on your account`)
|
||||
}
|
||||
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
name: 'freshchat',
|
||||
payload: {
|
||||
channelId: channel.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
unregister: async () => {},
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as bp from '../.botpress'
|
||||
import { getFreshchatClient } from './client'
|
||||
|
||||
type IntegrationUser = Awaited<ReturnType<bp.Client['getUser']>>['user']
|
||||
|
||||
export const updateAgentUser = async (
|
||||
user: IntegrationUser,
|
||||
client: bp.Client,
|
||||
ctx: bp.Context,
|
||||
logger: bp.Logger,
|
||||
forceUpdate?: boolean
|
||||
): Promise<{ updatedAgentUser: IntegrationUser }> => {
|
||||
if (!forceUpdate && user?.name?.length) {
|
||||
return { updatedAgentUser: user }
|
||||
}
|
||||
|
||||
let updatedFields: Record<string, any> = {}
|
||||
|
||||
try {
|
||||
const freshchatClient = getFreshchatClient({ ...ctx.configuration }, logger)
|
||||
const agentData = await freshchatClient.getAgentById(user.tags.id as string)
|
||||
updatedFields = {
|
||||
name: agentData?.first_name + ' ' + agentData?.last_name,
|
||||
...(agentData?.avatar?.url?.length && { pictureUrl: agentData?.avatar?.url }),
|
||||
}
|
||||
} catch (thrown) {
|
||||
const err = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
logger.forBot().error(`Couldn't get the agent profile from Freshchat: ${err.message}`)
|
||||
}
|
||||
|
||||
if (!updatedFields?.pictureUrl?.length && ctx.configuration?.agentAvatarUrl?.length) {
|
||||
updatedFields.pictureUrl = ctx.configuration.agentAvatarUrl
|
||||
}
|
||||
|
||||
if (updatedFields.name !== user.name || updatedFields.pictureUrl !== user.pictureUrl) {
|
||||
const { user: updatedUser } = await client.updateUser({
|
||||
...user,
|
||||
...updatedFields,
|
||||
})
|
||||
return { updatedAgentUser: updatedUser }
|
||||
}
|
||||
|
||||
return { updatedAgentUser: user }
|
||||
}
|
||||
|
||||
export type FileMetadata = { mimeType: string; fileSize?: number; fileName?: string }
|
||||
|
||||
export async function getMediaMetadata(url: string): Promise<FileMetadata> {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch metadata for URL: ${url}`)
|
||||
}
|
||||
|
||||
const mimeType = response.headers.get('content-type') ?? 'application/octet-stream'
|
||||
const contentLength = response.headers.get('content-length')
|
||||
const contentDisposition = response.headers.get('content-disposition')
|
||||
|
||||
const fileSize = contentLength ? Number(contentLength) : undefined
|
||||
if (fileSize !== undefined && isNaN(fileSize)) {
|
||||
throw new Error(`Failed to parse file size from response: ${contentLength}`)
|
||||
}
|
||||
|
||||
// Try to extract filename from content-disposition
|
||||
let fileName: string | undefined
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?"?([^"]+)"?/i)
|
||||
const rawFileName = match?.[1]
|
||||
if (rawFileName) {
|
||||
fileName = decodeURIComponent(rawFileName)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType,
|
||||
fileSize,
|
||||
fileName,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user