chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z, IntegrationDefinitionProps } from '@botpress/sdk'
|
||||
|
||||
export const events = {
|
||||
conversationCreated: {
|
||||
title: 'Conversation Created',
|
||||
description: 'This event occurs when a conversation is created',
|
||||
schema: z.object({
|
||||
userId: z.string().title('User ID').describe('The Botpress user ID'),
|
||||
conversationId: z.string().title('Conversation ID').describe('The Botpress conversation ID'),
|
||||
}),
|
||||
ui: {},
|
||||
},
|
||||
} satisfies IntegrationDefinitionProps['events']
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
identifier = null
|
||||
body = parse_json!(.body)
|
||||
|
||||
identifier = get(body, ["events", 0, "payload", "message", "source", "integrationId"]) ?? null
|
||||
|
||||
if identifier == null {
|
||||
identifier = get(body, ["events", 0, "payload", "conversation", "metadata", "botpressIntegrationOwner"]) ?? null
|
||||
}
|
||||
|
||||
identifier
|
||||
@@ -0,0 +1 @@
|
||||
The Sunshine Conversations integration enables your AI-powered chatbot to seamlessly connect with Sunshine Conversations, a powerful omnichannel messaging platform. Integrate your chatbot with Sunshine Conversations to engage with your customers across various messaging channels, including WhatsApp, Facebook Messenger, SMS, and more. With this integration, you can automate customer interactions, provide personalized support, and deliver rich messaging experiences. Leverage Sunshine Conversations' features such as chat routing, message templates, conversation history, and analytics to optimize your customer engagement strategy. Take your chatbot's reach and capabilities to new heights with the Sunshine Conversations Integration for Botpress.
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="5.2 5.1 50.13 35.27">
|
||||
<g fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(5 3)">
|
||||
<path d="m48.201554 35.4957912c1.415664-3.239532 2.141244-6.737976 2.1288755-10.27296-.0356555-13.84344-11.2857995-25.036164-25.1292395-25.00075219-13.84344.03558019-25.037208 11.28676819-25.00179619 25.13020819.00948019 3.496356.74967619 6.950952 2.17369219 10.143504z" fill="#eec83d" transform="translate(0 1.880244)"/>
|
||||
<g fill="#05363d"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 520 B |
@@ -0,0 +1,143 @@
|
||||
import { z, IntegrationDefinition, messages } from '@botpress/sdk'
|
||||
import proactiveConversation from 'bp_modules/proactive-conversation'
|
||||
import proactiveUser from 'bp_modules/proactive-user'
|
||||
import typingIndicator from 'bp_modules/typing-indicator'
|
||||
import { events } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'sunco'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: '2.0.3',
|
||||
title: 'Sunshine Conversations',
|
||||
description: 'Give your bot access to a powerful omnichannel messaging platform.',
|
||||
icon: 'icon.svg',
|
||||
readme: 'hub.md',
|
||||
states: {
|
||||
credentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
token: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Token')
|
||||
.describe('The bearer token obtained after completing the OAuth flow'),
|
||||
appId: z.string().optional().title('App ID').describe('The registered app ID'),
|
||||
subdomain: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Subdomain')
|
||||
.describe('The subdomain of the authenticated app if there is one'),
|
||||
}),
|
||||
},
|
||||
webhook: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
id: z.string().title('ID').describe('The webhook ID'),
|
||||
secret: z.string().title('Secret').describe('The webhook secret'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
identifier: {
|
||||
extractScript: 'extract.vrl',
|
||||
},
|
||||
configuration: {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
required: false,
|
||||
},
|
||||
schema: z.object({}),
|
||||
},
|
||||
channels: {
|
||||
channel: {
|
||||
title: 'Sunshine Conversations Channel',
|
||||
description: 'Channel for a Sunshine conversation',
|
||||
messages: { ...messages.defaults, markdown: messages.markdown, bloc: messages.markdownBloc },
|
||||
message: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Message ID',
|
||||
description: 'The Sunshine Conversations message ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'Conversation ID',
|
||||
description: 'The Sunshine Conversations conversation ID',
|
||||
},
|
||||
origin: {
|
||||
title: 'Origin',
|
||||
description: 'The Sunshine Conversations conversation origin type',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
events,
|
||||
secrets: {
|
||||
CLIENT_ID: {
|
||||
description: 'Botpress SunCo OAuth Client ID',
|
||||
},
|
||||
CLIENT_SECRET: {
|
||||
description: 'Botpress SunCo OAuth Client Secret',
|
||||
},
|
||||
MARKETPLACE_BOT_NAME: {
|
||||
description: 'The name of the marketplace bot',
|
||||
},
|
||||
MARKETPLACE_ORG_ID: {
|
||||
description: 'The ID of the marketplace organization',
|
||||
},
|
||||
MARKETPLACE_BOT_ID: {
|
||||
description: 'The bot ID for the Zendesk marketplace',
|
||||
},
|
||||
},
|
||||
user: {
|
||||
tags: {
|
||||
id: {
|
||||
title: 'User ID',
|
||||
description: 'The Sunshine Conversations user ID',
|
||||
},
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
user: {
|
||||
title: 'User',
|
||||
description: 'A Sunshine Conversations user',
|
||||
schema: z
|
||||
.object({
|
||||
id: z.string().title('ID').describe('The Sunshine Conversations user ID'),
|
||||
})
|
||||
.title('User')
|
||||
.describe('The user object fields'),
|
||||
},
|
||||
conversation: {
|
||||
title: 'Conversation',
|
||||
description: 'A Sunshine Conversations conversation',
|
||||
schema: z
|
||||
.object({
|
||||
id: z.string().title('ID').describe('The Sunshine Conversations conversation ID'),
|
||||
})
|
||||
.title('Conversation')
|
||||
.describe('The conversation object fields'),
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
category: 'Communication & Channels',
|
||||
guideSlug: 'sunco',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
.extend(typingIndicator, () => ({ entities: {} }))
|
||||
.extend(proactiveUser, ({ entities }) => ({
|
||||
entities: {
|
||||
user: entities.user,
|
||||
},
|
||||
}))
|
||||
.extend(proactiveConversation, ({ entities }) => ({
|
||||
entities: {
|
||||
conversation: entities.conversation,
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,9 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
env = to_string!(.env)
|
||||
|
||||
clientId = "botpress"
|
||||
|
||||
redirectUri = "{{ webhookUrl }}/oauth"
|
||||
|
||||
"https://oauth-bridge.zendesk.com/sc/oauth/authorize?response_type=code&client_id={{ clientId }}&redirect_uri={{ redirectUri }}&state={{ webhookId }}"
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@botpresshub/sunco",
|
||||
"description": "Sunco integration for Botpress",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpress/sdk-addons": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"sunshine-conversations-client": "^9.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"typing-indicator": "../../interfaces/typing-indicator",
|
||||
"proactive-user": "../../interfaces/proactive-user",
|
||||
"proactive-conversation": "../../interfaces/proactive-conversation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getSuncoClient } from 'src/client'
|
||||
import { getStoredCredentials } from 'src/get-stored-credentials'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getOrCreateConversation: bp.IntegrationProps['actions']['getOrCreateConversation'] = async ({
|
||||
client,
|
||||
input,
|
||||
ctx,
|
||||
}) => {
|
||||
const credentials = await getStoredCredentials(client, ctx)
|
||||
const suncoConversation = await getSuncoClient(credentials).getConversation(input.conversation.id)
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: { id: `${suncoConversation.conversation?.id}` },
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getSuncoClient } from 'src/client'
|
||||
import { getStoredCredentials } from 'src/get-stored-credentials'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getOrCreateUser: bp.IntegrationProps['actions']['getOrCreateUser'] = async ({ client, input, ctx }) => {
|
||||
const credentials = await getStoredCredentials(client, ctx)
|
||||
const suncoUser = await getSuncoClient(credentials).getUser(input.user.id)
|
||||
const suncoProfile = suncoUser.user?.profile
|
||||
|
||||
const name = input.name ?? [suncoProfile?.givenName, suncoProfile?.surname].join(' ').trim()
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: { id: `${suncoUser.user?.id}` },
|
||||
name,
|
||||
pictureUrl: input.pictureUrl ?? suncoProfile?.avatarUrl,
|
||||
})
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getOrCreateConversation } from './get-or-create-conversation'
|
||||
import { getOrCreateUser } from './get-or-create-user'
|
||||
import { startTypingIndicator, stopTypingIndicator } from './typing-indicator'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const actions = {
|
||||
startTypingIndicator,
|
||||
stopTypingIndicator,
|
||||
getOrCreateUser,
|
||||
getOrCreateConversation,
|
||||
} satisfies bp.IntegrationProps['actions']
|
||||
@@ -0,0 +1,60 @@
|
||||
import { getSuncoClient } from 'src/client'
|
||||
import { getStoredCredentials } from 'src/get-stored-credentials'
|
||||
import { getSuncoConversationId } from '../util'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type SendActivityProps = Pick<bp.AnyMessageProps, 'ctx' | 'client'> & {
|
||||
conversationId: string
|
||||
typingStatus?: 'start' | 'stop'
|
||||
markAsRead?: boolean
|
||||
}
|
||||
|
||||
async function sendActivity({ client, ctx, conversationId, typingStatus, markAsRead }: SendActivityProps) {
|
||||
const credentials = await getStoredCredentials(client, ctx)
|
||||
const { conversation } = await client.getConversation({ id: conversationId })
|
||||
const suncoConversationId = getSuncoConversationId(conversation)
|
||||
const suncoClient = getSuncoClient(credentials)
|
||||
if (markAsRead) {
|
||||
await suncoClient.postActivity(suncoConversationId, {
|
||||
type: 'conversation:read',
|
||||
author: { type: 'business' },
|
||||
})
|
||||
}
|
||||
if (typingStatus) {
|
||||
await suncoClient.postActivity(suncoConversationId, {
|
||||
type: `typing:${typingStatus}`,
|
||||
author: { type: 'business' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const startTypingIndicator: bp.IntegrationProps['actions']['startTypingIndicator'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
}) => {
|
||||
const { conversationId } = input
|
||||
await sendActivity({
|
||||
client,
|
||||
ctx,
|
||||
conversationId,
|
||||
typingStatus: 'start',
|
||||
markAsRead: true,
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
export const stopTypingIndicator: bp.IntegrationProps['actions']['stopTypingIndicator'] = async ({
|
||||
client,
|
||||
ctx,
|
||||
input,
|
||||
}) => {
|
||||
const { conversationId } = input
|
||||
await sendActivity({
|
||||
client,
|
||||
ctx,
|
||||
conversationId,
|
||||
typingStatus: 'stop',
|
||||
})
|
||||
return {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const BASE_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Zendesk-Marketplace-Name': bp.secrets.MARKETPLACE_BOT_NAME,
|
||||
'X-Zendesk-Marketplace-Organization-Id': bp.secrets.MARKETPLACE_ORG_ID,
|
||||
'X-Zendesk-Marketplace-Bot-Id': bp.secrets.MARKETPLACE_BOT_ID,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { BASE_HEADERS } from './const'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const { z } = sdk
|
||||
const TOKEN_URL = 'https://oauth-bridge.zendesk.com/sc/oauth/token'
|
||||
const TOKEN_INFO_URL = 'https://oauth-bridge.zendesk.com/sc/v2/tokenInfo'
|
||||
|
||||
type ClientCredentials = { clientId: string; clientSecret: string }
|
||||
|
||||
const getTokenSchema = z.object({ access_token: z.string() }).passthrough()
|
||||
|
||||
const getTokenInfoSchema = z
|
||||
.object({ app: z.object({ id: z.string(), subdomain: z.string().optional() }) })
|
||||
.passthrough()
|
||||
|
||||
export const getCredentials = async ({
|
||||
authorizationCode,
|
||||
logger,
|
||||
}: {
|
||||
authorizationCode: string
|
||||
logger: bp.Logger
|
||||
}): Promise<{ token: string; appId: string; subdomain?: string }> => {
|
||||
const token = await _getToken({ authorizationCode, clientCredentials: _getBotpressClientCredentials(), logger })
|
||||
const tokenInfo = await getTokenInfo({ token, logger })
|
||||
return { token, appId: tokenInfo.app.id, subdomain: tokenInfo.app.subdomain }
|
||||
}
|
||||
|
||||
const _getToken = async ({
|
||||
authorizationCode,
|
||||
clientCredentials,
|
||||
logger,
|
||||
}: {
|
||||
authorizationCode: string
|
||||
clientCredentials: ClientCredentials
|
||||
logger: bp.Logger
|
||||
}): Promise<string> => {
|
||||
logger.forBot().debug('Exchanging authorization code for Sunco token')
|
||||
|
||||
const params = {
|
||||
grant_type: 'authorization_code',
|
||||
code: authorizationCode,
|
||||
client_id: clientCredentials.clientId,
|
||||
client_secret: clientCredentials.clientSecret,
|
||||
}
|
||||
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: BASE_HEADERS,
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
|
||||
const payload = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
logger.forBot().error('Failed to exchange authorization code for SunCo token', { status: response.status, payload })
|
||||
throw new sdk.RuntimeError('failed to get token')
|
||||
}
|
||||
|
||||
const token = getTokenSchema.parse(payload)
|
||||
logger.forBot().debug('Successfully obtained SunCo token')
|
||||
|
||||
return token.access_token
|
||||
}
|
||||
|
||||
export const getTokenInfo = async ({ token, logger }: { token: string; logger: bp.Logger }) => {
|
||||
logger.forBot().debug('Fetching SunCo token info')
|
||||
|
||||
const response = await fetch(TOKEN_INFO_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...BASE_HEADERS,
|
||||
},
|
||||
})
|
||||
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
logger.forBot().error('Failed to fetch token info', { status: response.status, payload })
|
||||
throw new sdk.RuntimeError('failed to get token')
|
||||
}
|
||||
|
||||
const tokenInfo = getTokenInfoSchema.parse(payload)
|
||||
logger.forBot().debug('Successfully obtained SunCo token info')
|
||||
return tokenInfo
|
||||
}
|
||||
|
||||
const _getBotpressClientCredentials = (): ClientCredentials => {
|
||||
return { clientId: bp.secrets.CLIENT_ID, clientSecret: bp.secrets.CLIENT_SECRET }
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
// @ts-expect-error No types for sunshine-conversations-client
|
||||
import * as SunshineConversationsClientModule from 'sunshine-conversations-client'
|
||||
|
||||
// The typings below were generated using AI based on dist from sunshine-conversations-client
|
||||
|
||||
// ============================================================================
|
||||
// ApiClient Types
|
||||
// ============================================================================
|
||||
|
||||
export type BasicAuth = {
|
||||
type: 'basic'
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export type BearerAuth = {
|
||||
type: 'bearer'
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
export type ApiClientAuthentications = {
|
||||
basicAuth: BasicAuth
|
||||
bearerAuth: BearerAuth
|
||||
}
|
||||
|
||||
// This is a very simplified type for the ApiClient
|
||||
export type ApiClient = {
|
||||
authentications: ApiClientAuthentications
|
||||
basePath: string
|
||||
defaultHeaders: Record<string, string>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AppsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type App = {
|
||||
id: string
|
||||
displayName?: string
|
||||
subdomain?: string
|
||||
settings?: unknown
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type CreateAppRequest = {
|
||||
displayName?: string
|
||||
settings?: unknown
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type UpdateAppRequest = {
|
||||
displayName?: string
|
||||
settings?: unknown
|
||||
metadata?: unknown
|
||||
}
|
||||
|
||||
export type AppListFilter = {
|
||||
serviceAccountId?: string
|
||||
}
|
||||
|
||||
export type AppsApi = {
|
||||
getApp(appId: string): Promise<{ app: App }>
|
||||
createApp(appPost: CreateAppRequest): Promise<{ app: App }>
|
||||
updateApp(appId: string, appUpdate: UpdateAppRequest): Promise<{ app: App }>
|
||||
deleteApp(appId: string): Promise<object>
|
||||
listApps(opts?: { page?: Page; filter?: AppListFilter }): Promise<{ apps?: App[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UsersApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type UserProfile = {
|
||||
givenName?: string
|
||||
surname?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: string
|
||||
externalId?: string
|
||||
profile?: UserProfile
|
||||
}
|
||||
|
||||
export type CreateUserRequest = {
|
||||
externalId: string
|
||||
profile?: UserProfile
|
||||
}
|
||||
|
||||
export type UpdateUserRequest = {
|
||||
profile?: UserProfile
|
||||
}
|
||||
|
||||
export type UsersApi = {
|
||||
getUser(appId: string, userIdOrExternalId: string): Promise<{ user: User }>
|
||||
createUser(appId: string, userPost: CreateUserRequest): Promise<{ user: User }>
|
||||
updateUser(appId: string, userIdOrExternalId: string, userUpdate: UpdateUserRequest): Promise<{ user: User }>
|
||||
deleteUser(appId: string, userIdOrExternalId: string): Promise<object>
|
||||
deleteUserPersonalInformation(appId: string, userIdOrExternalId: string): Promise<{ user: User }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConversationsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Conversation = {
|
||||
id: string
|
||||
type?: 'personal' | 'sdkGroup'
|
||||
displayName?: string
|
||||
activeSwitchboardIntegration?: {
|
||||
id: string
|
||||
name?: string
|
||||
integrationId?: string
|
||||
integrationType?: string
|
||||
}
|
||||
pendingSwitchboardIntegration?: {
|
||||
id: string
|
||||
name?: string
|
||||
integrationId?: string
|
||||
integrationType?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ConversationParticipant = {
|
||||
userId: string
|
||||
subscribeSDKClient?: boolean
|
||||
}
|
||||
|
||||
export type CreateConversationRequest = {
|
||||
type: 'personal' | 'sdkGroup'
|
||||
displayName?: string
|
||||
participants: ConversationParticipant[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateConversationRequest = {
|
||||
displayName?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ConversationListFilter = {
|
||||
userId?: string
|
||||
userExternalId?: string
|
||||
}
|
||||
|
||||
export type Page = {
|
||||
after?: string
|
||||
before?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type ConversationsApi = {
|
||||
getConversation(appId: string, conversationId: string): Promise<{ conversation: Conversation }>
|
||||
createConversation(
|
||||
appId: string,
|
||||
conversationPost: CreateConversationRequest
|
||||
): Promise<{ conversation: Conversation }>
|
||||
updateConversation(
|
||||
appId: string,
|
||||
conversationId: string,
|
||||
conversationUpdate: UpdateConversationRequest
|
||||
): Promise<{ conversation: Conversation }>
|
||||
deleteConversation(appId: string, conversationId: string): Promise<object>
|
||||
listConversations(
|
||||
appId: string,
|
||||
filter: ConversationListFilter,
|
||||
opts?: { page?: Page }
|
||||
): Promise<{ conversations?: Conversation[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MessagesApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type MessageAuthor = {
|
||||
type?: 'user' | 'business'
|
||||
userId?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
subtypes?: string[]
|
||||
}
|
||||
|
||||
export type TextMessageContent = {
|
||||
type: 'text'
|
||||
text: string
|
||||
actions?: Action[]
|
||||
}
|
||||
|
||||
export type ImageMessageContent = {
|
||||
type: 'image'
|
||||
mediaUrl: string
|
||||
mediaType?: string
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export type FileMessageContent = {
|
||||
type: 'file'
|
||||
mediaUrl: string
|
||||
mediaType?: string
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export type LocationMessageContent = {
|
||||
type: 'location'
|
||||
coordinates: {
|
||||
lat: number
|
||||
long: number
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Action Types (for interactive messages like carousels)
|
||||
// ============================================================================
|
||||
|
||||
export type ActionBase = {
|
||||
type: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type LinkAction = {
|
||||
type: 'link'
|
||||
uri: string
|
||||
} & ActionBase
|
||||
|
||||
export type PostbackAction = {
|
||||
type: 'postback'
|
||||
payload: string
|
||||
} & ActionBase
|
||||
|
||||
export type ReplyAction = {
|
||||
type: 'reply'
|
||||
payload: string
|
||||
} & ActionBase
|
||||
|
||||
export type Action = LinkAction | PostbackAction | ReplyAction
|
||||
|
||||
export type CarouselItem = {
|
||||
title: string
|
||||
description?: string
|
||||
mediaUrl?: string
|
||||
actions: Action[]
|
||||
}
|
||||
|
||||
export type CarouselMessageContent = {
|
||||
type: 'carousel'
|
||||
items: CarouselItem[]
|
||||
}
|
||||
|
||||
export type ListMessageContent = {
|
||||
type: 'list'
|
||||
items: unknown[]
|
||||
}
|
||||
|
||||
export type MessageContent =
|
||||
| TextMessageContent
|
||||
| ImageMessageContent
|
||||
| FileMessageContent
|
||||
| LocationMessageContent
|
||||
| CarouselMessageContent
|
||||
| ListMessageContent
|
||||
|
||||
export type Message = {
|
||||
id: string
|
||||
type?: string
|
||||
author?: MessageAuthor
|
||||
content?: MessageContent
|
||||
received?: string
|
||||
source?: {
|
||||
type?: string
|
||||
originalMessageTimestamp?: string
|
||||
}
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type PostMessageRequest = {
|
||||
author: MessageAuthor
|
||||
content: MessageContent
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type MessagesApi = {
|
||||
postMessage(appId: string, conversationId: string, messagePost: PostMessageRequest): Promise<{ messages?: Message[] }>
|
||||
listMessages(appId: string, conversationId: string, opts?: { page?: Page }): Promise<{ messages?: Message[] }>
|
||||
deleteMessage(appId: string, conversationId: string, messageId: string): Promise<object>
|
||||
deleteAllMessages(appId: string, conversationId: string): Promise<object>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IntegrationsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Integration = {
|
||||
id: string
|
||||
type?: string
|
||||
status?: 'active' | 'inactive'
|
||||
displayName?: string
|
||||
webhooks?: Webhook[]
|
||||
defaultResponder?: {
|
||||
id?: string
|
||||
name?: string
|
||||
integrationId?: string
|
||||
integrationType?: string
|
||||
deliverStandbyEvents?: boolean
|
||||
nextSwitchboardIntegrationId?: string
|
||||
inherited?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateIntegrationRequest = {
|
||||
type: 'custom' | string
|
||||
status?: 'active' | 'inactive'
|
||||
displayName: string
|
||||
webhooks?: CreateWebhookRequest[]
|
||||
}
|
||||
|
||||
export type UpdateIntegrationRequest = {
|
||||
status?: 'active' | 'inactive'
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
export type IntegrationListFilter = {
|
||||
types?: string
|
||||
}
|
||||
|
||||
export type IntegrationsApi = {
|
||||
getIntegration(appId: string, integrationId: string): Promise<{ integration: Integration }>
|
||||
createIntegration(appId: string, integrationPost: CreateIntegrationRequest): Promise<{ integration: Integration }>
|
||||
updateIntegration(
|
||||
appId: string,
|
||||
integrationId: string,
|
||||
integrationUpdate: UpdateIntegrationRequest
|
||||
): Promise<{ integration: Integration }>
|
||||
deleteIntegration(appId: string, integrationId: string): Promise<object>
|
||||
listIntegrations(
|
||||
appId: string,
|
||||
opts?: { page?: Page; filter?: IntegrationListFilter }
|
||||
): Promise<{ integrations?: Integration[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WebhooksApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Webhook = {
|
||||
id: string
|
||||
target: string
|
||||
secret?: string
|
||||
triggers?: string[]
|
||||
includeFullUser?: boolean
|
||||
includeFullSource?: boolean
|
||||
}
|
||||
|
||||
export type CreateWebhookRequest = {
|
||||
target: string
|
||||
triggers: string[]
|
||||
includeFullUser?: boolean
|
||||
includeFullSource?: boolean
|
||||
}
|
||||
|
||||
export type UpdateWebhookRequest = {
|
||||
target?: string
|
||||
triggers?: string[]
|
||||
includeFullUser?: boolean
|
||||
includeFullSource?: boolean
|
||||
}
|
||||
|
||||
export type WebhooksApi = {
|
||||
getWebhook(appId: string, integrationId: string, webhookId: string): Promise<{ webhook: Webhook }>
|
||||
createWebhook(appId: string, integrationId: string, webhookPost: CreateWebhookRequest): Promise<{ webhook: Webhook }>
|
||||
updateWebhook(
|
||||
appId: string,
|
||||
integrationId: string,
|
||||
webhookId: string,
|
||||
webhookUpdate: UpdateWebhookRequest
|
||||
): Promise<{ webhook: Webhook }>
|
||||
deleteWebhook(appId: string, integrationId: string, webhookId: string): Promise<object>
|
||||
listWebhooks(appId: string, integrationId: string): Promise<{ webhooks?: Webhook[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SwitchboardsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Switchboard = {
|
||||
id: string
|
||||
enabled?: boolean
|
||||
defaultSwitchboardIntegrationId?: string
|
||||
}
|
||||
|
||||
export type SwitchboardUpdateBody = {
|
||||
enabled?: boolean
|
||||
defaultSwitchboardIntegrationId?: string
|
||||
}
|
||||
|
||||
export type SwitchboardsApi = {
|
||||
createSwitchboard(appId: string): Promise<{ switchboard: Switchboard }>
|
||||
updateSwitchboard(
|
||||
appId: string,
|
||||
switchboardId: string,
|
||||
switchboardUpdate: SwitchboardUpdateBody
|
||||
): Promise<{ switchboard: Switchboard }>
|
||||
deleteSwitchboard(appId: string, switchboardId: string): Promise<object>
|
||||
listSwitchboards(appId: string): Promise<{ switchboards?: Switchboard[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SwitchboardIntegrationsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type SwitchboardIntegration = {
|
||||
id: string
|
||||
name?: string
|
||||
integrationId?: string
|
||||
integrationType?: string
|
||||
deliverStandbyEvents?: boolean
|
||||
}
|
||||
|
||||
export type CreateSwitchboardIntegrationRequest = {
|
||||
name: string
|
||||
integrationId: string
|
||||
deliverStandbyEvents?: boolean
|
||||
}
|
||||
|
||||
export type UpdateSwitchboardIntegrationRequest = {
|
||||
name?: string
|
||||
deliverStandbyEvents?: boolean
|
||||
}
|
||||
|
||||
export type SwitchboardIntegrationsApi = {
|
||||
createSwitchboardIntegration(
|
||||
appId: string,
|
||||
switchboardId: string,
|
||||
switchboardIntegrationPost: CreateSwitchboardIntegrationRequest
|
||||
): Promise<{ switchboardIntegration: SwitchboardIntegration }>
|
||||
updateSwitchboardIntegration(
|
||||
appId: string,
|
||||
switchboardId: string,
|
||||
switchboardIntegrationId: string,
|
||||
switchboardIntegrationUpdate: UpdateSwitchboardIntegrationRequest
|
||||
): Promise<{ switchboardIntegration: SwitchboardIntegration }>
|
||||
deleteSwitchboardIntegration(appId: string, switchboardId: string, switchboardIntegrationId: string): Promise<object>
|
||||
listSwitchboardIntegrations(
|
||||
appId: string,
|
||||
switchboardId: string
|
||||
): Promise<{ switchboardIntegrations?: SwitchboardIntegration[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SwitchboardActionsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type PassControlBody = {
|
||||
switchboardIntegration: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export type OfferControlBody = {
|
||||
switchboardIntegration: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export type AcceptControlBody = {
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export type SwitchboardActionsApi = {
|
||||
passControl(appId: string, conversationId: string, passControlBody: PassControlBody): Promise<object>
|
||||
offerControl(appId: string, conversationId: string, offerControlBody: OfferControlBody): Promise<object>
|
||||
acceptControl(appId: string, conversationId: string, acceptControlBody: AcceptControlBody): Promise<object>
|
||||
releaseControl(appId: string, conversationId: string): Promise<object>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ClientsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Client = {
|
||||
id: string
|
||||
type?: string
|
||||
status?: 'active' | 'pending' | 'inactive' | 'blocked'
|
||||
integrationId?: string
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
info?: Record<string, unknown>
|
||||
raw?: Record<string, unknown>
|
||||
lastSeen?: string
|
||||
linkedAt?: string
|
||||
externalId?: string
|
||||
}
|
||||
|
||||
export type ClientMatchCriteria = {
|
||||
type: string
|
||||
integrationId?: string
|
||||
externalId?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ClientCreateBody = {
|
||||
matchCriteria: ClientMatchCriteria
|
||||
confirmation?: {
|
||||
type: 'immediate' | 'userActivity' | 'prompt'
|
||||
message?: { author: MessageAuthor; content: MessageContent }
|
||||
}
|
||||
target?: {
|
||||
conversationId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientsApi = {
|
||||
createClient(appId: string, userIdOrExternalId: string, clientCreate: ClientCreateBody): Promise<{ client: Client }>
|
||||
removeClient(appId: string, userIdOrExternalId: string, clientId: string): Promise<object>
|
||||
listClients(appId: string, userIdOrExternalId: string, opts?: { page?: Page }): Promise<{ clients?: Client[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ParticipantsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type Participant = {
|
||||
id: string
|
||||
oderId?: string
|
||||
userExternalId?: string
|
||||
unreadCount?: number
|
||||
lastRead?: string
|
||||
}
|
||||
|
||||
export type ParticipantJoinBody = {
|
||||
userId?: string
|
||||
userExternalId?: string
|
||||
subscribeSDKClient?: boolean
|
||||
}
|
||||
|
||||
export type ParticipantLeaveBody = {
|
||||
participantId?: string
|
||||
userId?: string
|
||||
userExternalId?: string
|
||||
}
|
||||
|
||||
export type ParticipantsApi = {
|
||||
joinConversation(
|
||||
appId: string,
|
||||
conversationId: string,
|
||||
participantJoinBody: ParticipantJoinBody
|
||||
): Promise<{ participant: Participant }>
|
||||
leaveConversation(appId: string, conversationId: string, participantLeaveBody: ParticipantLeaveBody): Promise<object>
|
||||
listParticipants(
|
||||
appId: string,
|
||||
conversationId: string,
|
||||
opts?: { page?: Page }
|
||||
): Promise<{ participants?: Participant[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentSchema = {
|
||||
mediaUrl?: string
|
||||
mediaType?: string
|
||||
mediaSize?: number
|
||||
}
|
||||
|
||||
export type AttachmentDeleteBody = {
|
||||
mediaUrl: string
|
||||
}
|
||||
|
||||
export type AttachmentMediaTokenBody = {
|
||||
attachmentPaths: string[]
|
||||
}
|
||||
|
||||
export type AttachmentMediaTokenResponse = {
|
||||
mediaToken?: string
|
||||
}
|
||||
|
||||
export type AttachmentsApi = {
|
||||
uploadAttachment(
|
||||
appId: string,
|
||||
access: 'public' | 'private',
|
||||
source: File | Blob,
|
||||
opts?: { _for?: string; conversationId?: string }
|
||||
): Promise<{ attachment: AttachmentSchema }>
|
||||
deleteAttachment(appId: string, attachmentDeleteBody: AttachmentDeleteBody): Promise<object>
|
||||
generateMediaJsonWebToken(
|
||||
appId: string,
|
||||
attachmentMediaTokenBody: AttachmentMediaTokenBody
|
||||
): Promise<AttachmentMediaTokenResponse>
|
||||
setCookie(appId: string): Promise<object>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ActivitiesApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type ActivityPostType = 'conversation:read' | 'typing:start' | 'typing:stop'
|
||||
|
||||
export type ActivityPost = {
|
||||
author: {
|
||||
type: 'user' | 'business'
|
||||
userId?: string
|
||||
}
|
||||
type: ActivityPostType
|
||||
}
|
||||
|
||||
export type ActivitiesApi = {
|
||||
postActivity(appId: string, conversationId: string, activityPost: ActivityPost): Promise<object>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AppKeysApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type AppKey = {
|
||||
id: string
|
||||
key?: string
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export type AppKeysApi = {
|
||||
createAppKey(appId: string, appKeyPost: { displayName?: string }): Promise<{ key: AppKey }>
|
||||
deleteAppKey(appId: string, keyId: string): Promise<void>
|
||||
listAppKeys(appId: string): Promise<{ keys?: AppKey[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CustomIntegrationApiKeysApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type CustomIntegrationApiKey = {
|
||||
id: string
|
||||
key?: string
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export type CustomIntegrationApiKeysApi = {
|
||||
createCustomIntegrationApiKey(
|
||||
appId: string,
|
||||
integrationId: string,
|
||||
customIntegrationApiKeyPost: { displayName?: string }
|
||||
): Promise<{ key: CustomIntegrationApiKey }>
|
||||
deleteCustomIntegrationApiKey(appId: string, integrationId: string, keyId: string): Promise<void>
|
||||
listCustomIntegrationApiKeys(appId: string, integrationId: string): Promise<{ keys?: CustomIntegrationApiKey[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuthEndpointsApi Types
|
||||
// ============================================================================
|
||||
|
||||
export type OAuthEndpoint = {
|
||||
id: string
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export type OAuthEndpointsApi = {
|
||||
createOAuthEndpoint(
|
||||
appId: string,
|
||||
integrationId: string,
|
||||
oAuthEndpointPost: { uri: string }
|
||||
): Promise<{ oauthEndpoint: OAuthEndpoint }>
|
||||
deleteOAuthEndpoint(appId: string, integrationId: string, oauthEndpointId: string): Promise<void>
|
||||
listOAuthEndpoints(appId: string, integrationId: string): Promise<{ oauthEndpoints?: OAuthEndpoint[] }>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SunshineConversationsApi Export
|
||||
// ============================================================================
|
||||
|
||||
// Helper type to create a constructor that returns an instance implementing the interface
|
||||
type TypedApiConstructor<T> = new (apiClient: ApiClient) => T
|
||||
|
||||
export const SunshineConversationsApi = SunshineConversationsClientModule as {
|
||||
ApiClient: new () => ApiClient
|
||||
ActivitiesApi: TypedApiConstructor<ActivitiesApi>
|
||||
AppKeysApi: TypedApiConstructor<AppKeysApi>
|
||||
AppsApi: TypedApiConstructor<AppsApi>
|
||||
AttachmentsApi: TypedApiConstructor<AttachmentsApi>
|
||||
ClientsApi: TypedApiConstructor<ClientsApi>
|
||||
ConversationsApi: TypedApiConstructor<ConversationsApi>
|
||||
CustomIntegrationApiKeysApi: TypedApiConstructor<CustomIntegrationApiKeysApi>
|
||||
IntegrationsApi: TypedApiConstructor<IntegrationsApi>
|
||||
MessagesApi: TypedApiConstructor<MessagesApi>
|
||||
OAuthEndpointsApi: TypedApiConstructor<OAuthEndpointsApi>
|
||||
ParticipantsApi: TypedApiConstructor<ParticipantsApi>
|
||||
SwitchboardActionsApi: TypedApiConstructor<SwitchboardActionsApi>
|
||||
SwitchboardIntegrationsApi: TypedApiConstructor<SwitchboardIntegrationsApi>
|
||||
SwitchboardsApi: TypedApiConstructor<SwitchboardsApi>
|
||||
UsersApi: TypedApiConstructor<UsersApi>
|
||||
WebhooksApi: TypedApiConstructor<WebhooksApi>
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import { Action, CarouselItem, MessageContent, PostMessageRequest } from './api/sunshine-api'
|
||||
import { getSuncoClient } from './client'
|
||||
import { getStoredCredentials } from './get-stored-credentials'
|
||||
import { Carousel, Choice, Conversation, StoredCredentials } from './types'
|
||||
import { getSuncoConversationId } from './util'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const channels = {
|
||||
channel: {
|
||||
messages: {
|
||||
text: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendMessage(props, { type: 'text', text: props.payload.text }, credentials)
|
||||
},
|
||||
image: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
const mediaUrl = await getMediaUrl(props.payload.imageUrl, props.conversation, credentials)
|
||||
await sendMessage(props, { type: 'image', mediaUrl }, credentials)
|
||||
},
|
||||
markdown: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendMessage(props, { type: 'text', text: props.payload.markdown }, credentials)
|
||||
},
|
||||
audio: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
const mediaUrl = await getMediaUrl(props.payload.audioUrl, props.conversation, credentials)
|
||||
await sendMessage(props, { type: 'file', mediaUrl }, credentials)
|
||||
},
|
||||
video: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
const mediaUrl = await getMediaUrl(props.payload.videoUrl, props.conversation, credentials)
|
||||
await sendMessage(props, { type: 'file', mediaUrl }, credentials)
|
||||
},
|
||||
file: async (props) => {
|
||||
try {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
const mediaUrl = await getMediaUrl(props.payload.fileUrl, props.conversation, credentials)
|
||||
await sendMessage(props, { type: 'file', mediaUrl }, credentials)
|
||||
} catch (e) {
|
||||
const err = e as any
|
||||
// 400 errors can be sent if file has unsupported type
|
||||
// See: https://docs.smooch.io/guide/validating-files/#rejections
|
||||
if (err.status === 400 && err.response?.text) {
|
||||
console.info(err.response.text)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
location: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendMessage(
|
||||
props,
|
||||
{
|
||||
type: 'location',
|
||||
coordinates: {
|
||||
lat: props.payload.latitude,
|
||||
long: props.payload.longitude,
|
||||
},
|
||||
},
|
||||
credentials
|
||||
)
|
||||
},
|
||||
carousel: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendCarousel(props, props.payload, credentials)
|
||||
},
|
||||
card: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendCarousel(props, { items: [props.payload] }, credentials)
|
||||
},
|
||||
dropdown: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendMessage(props, renderChoiceMessage(props.payload), credentials)
|
||||
},
|
||||
choice: async (props) => {
|
||||
const credentials = await getStoredCredentials(props.client, props.ctx)
|
||||
await sendMessage(props, renderChoiceMessage(props.payload), credentials)
|
||||
},
|
||||
bloc: () => {
|
||||
throw new RuntimeError('Not implemented')
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies bp.IntegrationProps['channels']
|
||||
|
||||
const POSTBACK_PREFIX = 'postback::'
|
||||
const SAY_PREFIX = 'say::'
|
||||
|
||||
/**
|
||||
* Gets the media URL for sending in messages.
|
||||
* If the source URL is from Zendesk, uploads it to Zendesk's Attachments API.
|
||||
* Otherwise, returns the original URL as-is.
|
||||
* The reason for this is that Zendesk will fail the sendMessage request if the URL is from
|
||||
* another Sunco Conversation.
|
||||
*/
|
||||
async function getMediaUrl(
|
||||
sourceUrl: string,
|
||||
conversation: Conversation,
|
||||
credentials: StoredCredentials
|
||||
): Promise<string> {
|
||||
try {
|
||||
const hostname = new URL(sourceUrl).hostname
|
||||
if (hostname.endsWith('zendesk.com')) {
|
||||
return getSuncoClient(credentials).downloadAndUploadAttachment(sourceUrl, getSuncoConversationId(conversation))
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL or error, return as-is
|
||||
}
|
||||
return sourceUrl
|
||||
}
|
||||
|
||||
function renderChoiceMessage(payload: Choice): MessageContent {
|
||||
return {
|
||||
type: 'text',
|
||||
text: payload.text,
|
||||
actions: payload.options.map((r) => ({ type: 'reply' as const, text: r.label, payload: r.value })),
|
||||
}
|
||||
}
|
||||
|
||||
type SendMessageProps = Pick<bp.AnyMessageProps, 'ctx' | 'conversation' | 'ack'>
|
||||
|
||||
async function sendMessage(
|
||||
{ conversation, ack }: SendMessageProps,
|
||||
payload: MessageContent,
|
||||
credentials: StoredCredentials
|
||||
) {
|
||||
const data: PostMessageRequest = {
|
||||
author: { type: 'business' },
|
||||
content: payload,
|
||||
}
|
||||
|
||||
const { messages } = await getSuncoClient(credentials).postMessage(getSuncoConversationId(conversation), data)
|
||||
|
||||
const message = messages?.[0]
|
||||
|
||||
if (!message) {
|
||||
throw new Error('Message not sent')
|
||||
}
|
||||
|
||||
await ack({ tags: { id: message.id } })
|
||||
|
||||
if (messages.length > 1) {
|
||||
console.warn('More than one message was sent')
|
||||
}
|
||||
}
|
||||
|
||||
const sendCarousel = async (props: SendMessageProps, payload: Carousel, credentials: StoredCredentials) => {
|
||||
const items: CarouselItem[] = []
|
||||
|
||||
for (const card of payload.items) {
|
||||
const actions: Action[] = []
|
||||
for (const button of card.actions) {
|
||||
if (button.action === 'url') {
|
||||
actions.push({
|
||||
type: 'link',
|
||||
text: button.label,
|
||||
uri: button.value,
|
||||
})
|
||||
} else if (button.action === 'postback') {
|
||||
actions.push({
|
||||
type: 'postback',
|
||||
text: button.label,
|
||||
payload: `${POSTBACK_PREFIX}${button.value}`,
|
||||
})
|
||||
} else if (button.action === 'say') {
|
||||
actions.push({
|
||||
type: 'postback',
|
||||
text: button.label,
|
||||
payload: `${SAY_PREFIX}${button.label}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length === 0) {
|
||||
actions.push({
|
||||
type: 'postback',
|
||||
text: card.title,
|
||||
payload: card.title,
|
||||
})
|
||||
}
|
||||
|
||||
items.push({ title: card.title, description: card.subtitle, mediaUrl: card.imageUrl, actions })
|
||||
}
|
||||
|
||||
await sendMessage(
|
||||
props,
|
||||
{
|
||||
type: 'carousel',
|
||||
items,
|
||||
},
|
||||
credentials
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { RuntimeError } from '@botpress/client'
|
||||
import axios from 'axios'
|
||||
import { BASE_HEADERS } from './api/const'
|
||||
import {
|
||||
SunshineConversationsApi,
|
||||
type AppsApi,
|
||||
type UsersApi,
|
||||
type ConversationsApi,
|
||||
type MessagesApi,
|
||||
type ActivitiesApi,
|
||||
type WebhooksApi,
|
||||
type IntegrationsApi,
|
||||
type Integration,
|
||||
type PostMessageRequest,
|
||||
type ActivityPost,
|
||||
} from './api/sunshine-api'
|
||||
import { StoredCredentials } from './types'
|
||||
|
||||
class SuncoClient {
|
||||
private _appId: string
|
||||
private _credentials: StoredCredentials
|
||||
private _client: {
|
||||
apps: AppsApi
|
||||
users: UsersApi
|
||||
conversations: ConversationsApi
|
||||
messages: MessagesApi
|
||||
activities: ActivitiesApi
|
||||
webhooks: WebhooksApi
|
||||
integrations: IntegrationsApi
|
||||
}
|
||||
|
||||
public constructor(credentials: StoredCredentials) {
|
||||
this._appId = credentials.appId
|
||||
this._credentials = credentials
|
||||
const apiClient = new SunshineConversationsApi.ApiClient()
|
||||
|
||||
if (!credentials.subdomain) {
|
||||
throw new RuntimeError('Subdomain is required for OAuth')
|
||||
}
|
||||
apiClient.basePath = `https://${credentials.subdomain}.zendesk.com/sc`
|
||||
const auth = apiClient.authentications['bearerAuth'] as { accessToken: string }
|
||||
auth.accessToken = credentials.token
|
||||
apiClient.defaultHeaders = { ...apiClient.defaultHeaders, ...BASE_HEADERS }
|
||||
|
||||
this._client = {
|
||||
apps: new SunshineConversationsApi.AppsApi(apiClient),
|
||||
users: new SunshineConversationsApi.UsersApi(apiClient),
|
||||
conversations: new SunshineConversationsApi.ConversationsApi(apiClient),
|
||||
messages: new SunshineConversationsApi.MessagesApi(apiClient),
|
||||
activities: new SunshineConversationsApi.ActivitiesApi(apiClient),
|
||||
webhooks: new SunshineConversationsApi.WebhooksApi(apiClient),
|
||||
integrations: new SunshineConversationsApi.IntegrationsApi(apiClient),
|
||||
}
|
||||
}
|
||||
|
||||
public async getApp() {
|
||||
return this._client.apps.getApp(this._appId)
|
||||
}
|
||||
|
||||
public async getUser(userId: string) {
|
||||
return this._client.users.getUser(this._appId, userId)
|
||||
}
|
||||
|
||||
public async getConversation(conversationId: string) {
|
||||
return this._client.conversations.getConversation(this._appId, conversationId)
|
||||
}
|
||||
|
||||
public async postMessage(conversationId: string, messagePost: PostMessageRequest) {
|
||||
return this._client.messages.postMessage(this._appId, conversationId, messagePost)
|
||||
}
|
||||
|
||||
public async postActivity(conversationId: string, activityPost: ActivityPost) {
|
||||
return this._client.activities.postActivity(this._appId, conversationId, activityPost)
|
||||
}
|
||||
|
||||
public async listWebhooks() {
|
||||
const result = await this._client.webhooks.listWebhooks(this._appId, 'me')
|
||||
return result.webhooks || []
|
||||
}
|
||||
|
||||
public async createWebhook(webhookUrl: string) {
|
||||
const result = await this._client.webhooks.createWebhook(this._appId, 'me', {
|
||||
target: webhookUrl,
|
||||
triggers: ['conversation:message', 'conversation:postback', 'conversation:create'],
|
||||
})
|
||||
return result.webhook
|
||||
}
|
||||
|
||||
public async updateWebhook(webhookId: string, webhookUrl: string) {
|
||||
const result = await this._client.webhooks.updateWebhook(this._appId, 'me', webhookId, {
|
||||
target: webhookUrl,
|
||||
triggers: ['conversation:message', 'conversation:postback', 'conversation:create'],
|
||||
})
|
||||
return result.webhook
|
||||
}
|
||||
|
||||
public async deleteWebhook(webhookId: string) {
|
||||
await this._client.webhooks.deleteWebhook(this._appId, 'me', webhookId)
|
||||
}
|
||||
|
||||
public async listIntegrations(): Promise<Integration[]> {
|
||||
const { baseUrl, auth } = this._getAxiosConfig()
|
||||
const response = await axios.get(`${baseUrl}/v2/apps/${this._appId}/integrations`, auth)
|
||||
return response.data.integrations || []
|
||||
}
|
||||
|
||||
public async updateConversationMetadata(conversationId: string, metadata: Record<string, string>) {
|
||||
await this._client.conversations.updateConversation(this._appId, conversationId, { metadata })
|
||||
}
|
||||
|
||||
public async downloadAndUploadAttachment(sourceUrl: string, conversationId: string): Promise<string> {
|
||||
const response = await axios.get(sourceUrl, { responseType: 'arraybuffer' })
|
||||
|
||||
const contentType = response.headers['content-type'] || 'application/octet-stream'
|
||||
const fileBuffer = Buffer.from(response.data)
|
||||
const filename = new URL(sourceUrl).pathname.split('/').pop() || 'file'
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('source', new Blob([fileBuffer], { type: contentType }), filename)
|
||||
|
||||
// Upload via axios instead of the SDK because the sunshine-conversations-client SDK
|
||||
// uses superagent internally, which doesn't properly handle Node.js File/Blob objects.
|
||||
const { baseUrl, auth } = this._getAxiosConfig()
|
||||
const uploadResponse = await axios.postForm(`${baseUrl}/v2/apps/${this._appId}/attachments`, formData, {
|
||||
params: { access: 'public', for: 'message', conversationId },
|
||||
...auth,
|
||||
})
|
||||
|
||||
const mediaUrl = uploadResponse.data?.attachment?.mediaUrl
|
||||
if (!mediaUrl) {
|
||||
throw new RuntimeError('Failed to upload attachment to Zendesk: no mediaUrl returned')
|
||||
}
|
||||
|
||||
return mediaUrl
|
||||
}
|
||||
|
||||
private _getAxiosConfig() {
|
||||
const { subdomain, token } = this._credentials
|
||||
const baseUrl = `https://${subdomain}.zendesk.com/sc`
|
||||
const auth = { headers: { Authorization: `Bearer ${token}`, ...BASE_HEADERS } }
|
||||
return { baseUrl, auth }
|
||||
}
|
||||
}
|
||||
|
||||
export const getSuncoClient = (credentials: StoredCredentials) => new SuncoClient(credentials)
|
||||
export type { SuncoClient }
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ConversationCreateEvent } from '../messaging-events'
|
||||
import { Logger, Client } from '.botpress'
|
||||
|
||||
export const executeConversationCreated = async (props: {
|
||||
event: ConversationCreateEvent
|
||||
client: Client
|
||||
logger: Logger
|
||||
}) => {
|
||||
const { event, client, logger } = props
|
||||
const payload = event.payload
|
||||
|
||||
const conversationId = payload.conversation?.id
|
||||
const userId = payload.user?.id
|
||||
|
||||
if (!conversationId?.length || !userId?.length) {
|
||||
logger.forBot().warn('conversation:create event missing conversation ID or user ID')
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: {
|
||||
id: conversationId,
|
||||
origin: payload.source?.type,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await client.createEvent({
|
||||
type: 'conversationCreated',
|
||||
conversationId: conversation.id,
|
||||
userId: user.id,
|
||||
payload: {
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ConversationMessageEvent } from 'src/messaging-events'
|
||||
import { Client, CreateMessageInputPayload, CreateMessageInputType, Logger } from 'src/types'
|
||||
|
||||
export async function handleConversationMessage(
|
||||
event: ConversationMessageEvent,
|
||||
client: Client,
|
||||
logger: Logger
|
||||
): Promise<void> {
|
||||
const payload = event.payload
|
||||
|
||||
if (payload.message.author.type === 'business') {
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation } = await client.getOrCreateConversation({
|
||||
channel: 'channel',
|
||||
tags: {
|
||||
id: payload.conversation?.id,
|
||||
origin: payload.message.source?.type,
|
||||
},
|
||||
discriminateByTags: ['id'],
|
||||
})
|
||||
|
||||
const { user } = await client.getOrCreateUser({
|
||||
tags: {
|
||||
id: payload.message.author.userId,
|
||||
},
|
||||
name: payload.message.author.displayName || payload.message.author.user?.profile?.givenName,
|
||||
pictureUrl: payload.message.author.avatarUrl || payload.message.author.user?.profile?.avatarUrl,
|
||||
})
|
||||
|
||||
const createMessage = async (type: CreateMessageInputType, messagePayload: CreateMessageInputPayload) => {
|
||||
await client.createMessage({
|
||||
tags: { id: payload.message.id },
|
||||
type,
|
||||
userId: user.id,
|
||||
conversationId: conversation.id,
|
||||
payload: messagePayload,
|
||||
})
|
||||
}
|
||||
|
||||
const { content } = payload.message
|
||||
|
||||
switch (content.type) {
|
||||
case 'text':
|
||||
if (!content.text?.length) {
|
||||
logger.forBot().warn('Text message received but no text provided')
|
||||
return
|
||||
}
|
||||
|
||||
await createMessage('text', { text: content.text })
|
||||
break
|
||||
case 'image':
|
||||
await createMessage('image', { imageUrl: content.mediaUrl })
|
||||
break
|
||||
case 'file':
|
||||
const mediaType = content.mediaType || ''
|
||||
const mediaUrl = content.mediaUrl
|
||||
|
||||
if (mediaType.startsWith('video/')) {
|
||||
await createMessage('video', { videoUrl: mediaUrl })
|
||||
} else if (mediaType.startsWith('audio/')) {
|
||||
await createMessage('audio', { audioUrl: mediaUrl })
|
||||
} else {
|
||||
await createMessage('file', { fileUrl: mediaUrl, title: content.altText })
|
||||
}
|
||||
break
|
||||
default:
|
||||
logger.forBot().warn(`Received a message with unsupported content type: ${content.type}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { executeConversationCreated } from './conversation-created'
|
||||
export { handleConversationMessage } from './conversation-message'
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import { StoredCredentials } from './types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const getStoredCredentials = async (
|
||||
client: bp.Client,
|
||||
ctx: bp.HandlerProps['ctx']
|
||||
): Promise<StoredCredentials> => {
|
||||
const {
|
||||
state: { payload: credentials },
|
||||
} = await client.getOrSetState({
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: {},
|
||||
})
|
||||
|
||||
const { token, appId, subdomain } = credentials
|
||||
|
||||
if (!token || !appId) {
|
||||
throw new sdk.RuntimeError('failed to get stored access token or app ID')
|
||||
}
|
||||
|
||||
return { token, appId, subdomain }
|
||||
}
|
||||
|
||||
export const getWebhookSecret = async (client: bp.Client, ctx: bp.HandlerProps['ctx']) => {
|
||||
const {
|
||||
state: {
|
||||
payload: { secret },
|
||||
},
|
||||
} = await client.getOrSetState({
|
||||
name: 'webhook',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: { id: '', secret: '' },
|
||||
})
|
||||
if (!secret) {
|
||||
throw new sdk.RuntimeError('Error: the webhook secret was not found in the bot state.')
|
||||
}
|
||||
return secret
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { generateRedirection, getWizardStepUrl, isOAuthWizardUrl } from '@botpress/common/src/oauth-wizard'
|
||||
import { RuntimeError } from '@botpress/sdk'
|
||||
import { INTEGRATION_NAME } from '../integration.definition'
|
||||
import { getCredentials } from './api/get-credentials'
|
||||
import { getSuncoClient } from './client'
|
||||
import { executeConversationCreated, handleConversationMessage } from './events'
|
||||
import { getStoredCredentials, getWebhookSecret } from './get-stored-credentials'
|
||||
import { getConversation, isSuncoWebhookPayload, isWebhookSignatureValid } from './messaging-events'
|
||||
import * as wizard from './wizard'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handler: bp.IntegrationProps['handler'] = async (props) => {
|
||||
const { req, client, logger, ctx } = props
|
||||
|
||||
if (req.path.startsWith('/oauth')) {
|
||||
return await _handleOAuthCallback(props)
|
||||
}
|
||||
|
||||
if (!isWebhookSignatureValid(req.headers, await getWebhookSecret(client, ctx))) {
|
||||
logger.forBot().warn('Received a request with an invalid webhook secret')
|
||||
return
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
console.warn('Handler received an empty body')
|
||||
return
|
||||
}
|
||||
|
||||
const data = JSON.parse(req.body)
|
||||
|
||||
if (!isSuncoWebhookPayload(data)) {
|
||||
logger.forBot().warn('Received an invalid payload from Sunco')
|
||||
return
|
||||
}
|
||||
|
||||
const credentials = await getStoredCredentials(client, ctx)
|
||||
const suncoClient = getSuncoClient(credentials)
|
||||
|
||||
for (const event of data.events) {
|
||||
const conversation = getConversation(event)
|
||||
const ownerWebhookId = conversation?.metadata?.botpressIntegrationOwner
|
||||
if (ownerWebhookId && ownerWebhookId !== ctx.webhookId) {
|
||||
logger.forBot().debug(`Ignoring Sunco conversation ${conversation?.id}: owned by ${ownerWebhookId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (conversation?.id) {
|
||||
await _updateConversationOwnerIfNeeded(suncoClient, conversation.id, conversation.metadata, ctx.webhookId)
|
||||
}
|
||||
|
||||
if (event.type === 'conversation:create') {
|
||||
await executeConversationCreated({ event, client, logger })
|
||||
} else if (event.type === 'conversation:message') {
|
||||
await handleConversationMessage(event, client, logger)
|
||||
} else {
|
||||
console.warn(`Received an event of type ${event.type}, which is not supported`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
}
|
||||
}
|
||||
|
||||
const _handleOAuthCallback = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
|
||||
if (isOAuthWizardUrl(req.path)) {
|
||||
return await wizard.handler({ client, ctx, logger, req })
|
||||
}
|
||||
|
||||
logger.forBot().debug('Handling OAuth callback')
|
||||
|
||||
const searchParams = new URLSearchParams(req.query)
|
||||
const authorizationCode = searchParams.get('code')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
if (error) {
|
||||
logger.forBot().error(`SunCo OAuth error: ${error} - ${errorDescription}`)
|
||||
throw new RuntimeError(`SunCo OAuth error: ${error} - ${errorDescription}`)
|
||||
}
|
||||
|
||||
if (!authorizationCode) {
|
||||
logger.forBot().error('Authorization code not present in OAuth callback')
|
||||
throw new RuntimeError('Authorization code not present in OAuth callback')
|
||||
}
|
||||
|
||||
const credentials = await getCredentials({
|
||||
authorizationCode,
|
||||
logger,
|
||||
})
|
||||
|
||||
logger.forBot().info('Successfully authenticated SunCo user')
|
||||
|
||||
await client.setState({ type: 'integration', name: 'credentials', id: ctx.integrationId, payload: credentials })
|
||||
|
||||
const suncoClient = getSuncoClient(credentials)
|
||||
const globalWebhookUrl = `${new URL(process.env.BP_WEBHOOK_URL!).origin}/integration/global/${INTEGRATION_NAME}`
|
||||
const existingWebhooks = await suncoClient.listWebhooks()
|
||||
const existingWebhook = existingWebhooks.find((wh) => wh.target === globalWebhookUrl)
|
||||
let webhook
|
||||
if (existingWebhook?.id) {
|
||||
webhook = await suncoClient.updateWebhook(existingWebhook.id, globalWebhookUrl)
|
||||
} else {
|
||||
webhook = await suncoClient.createWebhook(globalWebhookUrl)
|
||||
}
|
||||
if (webhook?.id && webhook?.secret) {
|
||||
await client.setState({
|
||||
type: 'integration',
|
||||
name: 'webhook',
|
||||
id: ctx.integrationId,
|
||||
payload: { id: webhook.id, secret: webhook.secret },
|
||||
})
|
||||
}
|
||||
|
||||
return generateRedirection(getWizardStepUrl('start', ctx))
|
||||
}
|
||||
|
||||
const _updateConversationOwnerIfNeeded = async (
|
||||
suncoClient: ReturnType<typeof getSuncoClient>,
|
||||
conversationId: string,
|
||||
metadata: Record<string, string> | undefined,
|
||||
webhookId: string
|
||||
) => {
|
||||
if (metadata?.botpressIntegrationOwner === webhookId) {
|
||||
return
|
||||
}
|
||||
await suncoClient.updateConversationMetadata(conversationId, { botpressIntegrationOwner: webhookId })
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { reporting } from '@botpress/sdk-addons'
|
||||
import { actions } from './actions'
|
||||
import { channels } from './channels'
|
||||
import { handler } from './handler'
|
||||
import { register, unregister } from './setup'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const integration = new bp.Integration({
|
||||
register,
|
||||
unregister,
|
||||
actions,
|
||||
channels,
|
||||
handler,
|
||||
})
|
||||
|
||||
export default reporting.wrapIntegration(integration)
|
||||
@@ -0,0 +1,214 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
|
||||
/**
|
||||
* Types for Sunshine Conversations webhook events
|
||||
* Based on the Sunshine Conversations API webhook structure
|
||||
* https://developer.zendesk.com/api-reference/conversations/#tag/Webhooks/operation/EventWebhooks
|
||||
*/
|
||||
|
||||
export type UnhandledEventType =
|
||||
// Client Events
|
||||
| 'client:add'
|
||||
| 'client:remove'
|
||||
| 'client:update'
|
||||
// Conversation Events
|
||||
| 'conversation:join'
|
||||
| 'conversation:leave'
|
||||
| 'conversation:remove'
|
||||
| 'conversation:message:delivery:channel'
|
||||
| 'conversation:message:delivery:failure'
|
||||
| 'conversation:message:delivery:user'
|
||||
| 'conversation:postback'
|
||||
| 'conversation:read'
|
||||
| 'conversation:referral'
|
||||
| 'conversation:typing'
|
||||
// Switchboard Events
|
||||
| 'switchboard:acceptControl'
|
||||
| 'switchboard:acceptControl:failure'
|
||||
| 'switchboard:offerControl'
|
||||
| 'switchboard:offerControl:failure'
|
||||
| 'switchboard:passControl'
|
||||
| 'switchboard:passControl:failure'
|
||||
| 'switchboard:releaseControl'
|
||||
// User Events
|
||||
| 'user:merge'
|
||||
| 'user:update'
|
||||
| 'user:remove'
|
||||
|
||||
export type UnhandledEvent = {
|
||||
type: UnhandledEventType
|
||||
}
|
||||
|
||||
export type ConversationCreateEvent = {
|
||||
type: 'conversation:create'
|
||||
payload: {
|
||||
conversation?: {
|
||||
id: string
|
||||
type?: string
|
||||
brandId?: string
|
||||
activeSwitchboardIntegration?: {
|
||||
id: string
|
||||
name: string
|
||||
integrationId: string
|
||||
integrationType: string
|
||||
}
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
user?: {
|
||||
id: string
|
||||
authenticated?: boolean
|
||||
}
|
||||
creationReason?: string
|
||||
source?: {
|
||||
type: string
|
||||
integrationId: string
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not typing the rest since we don't use them
|
||||
export type SuncoOtherMessageContent = {
|
||||
type: 'carousel' | 'form' | 'formResponse' | 'list' | 'location' | 'template'
|
||||
}
|
||||
|
||||
export type SuncoTextMessageContent = {
|
||||
type: 'text'
|
||||
text?: string
|
||||
htmlText?: string
|
||||
blockChatInput?: boolean
|
||||
actions?: unknown[]
|
||||
payload?: string
|
||||
}
|
||||
|
||||
export type SuncoImageMessageContent = {
|
||||
type: 'image'
|
||||
mediaUrl: string
|
||||
mediaType?: string
|
||||
mediaSize?: number
|
||||
altText?: string
|
||||
text?: string
|
||||
blockChatInput?: boolean
|
||||
htmlText?: string
|
||||
actions?: unknown[]
|
||||
attachmentId?: string
|
||||
}
|
||||
|
||||
export type SuncoFileMessageContent = {
|
||||
type: 'file'
|
||||
mediaUrl: string
|
||||
mediaSize?: number
|
||||
mediaType?: string
|
||||
altText?: string
|
||||
text?: string
|
||||
blockChatInput?: boolean
|
||||
htmlText?: string
|
||||
attachmentId?: string
|
||||
}
|
||||
|
||||
export type SuncoMessageContent =
|
||||
| SuncoTextMessageContent
|
||||
| SuncoImageMessageContent
|
||||
| SuncoFileMessageContent
|
||||
| SuncoOtherMessageContent
|
||||
|
||||
export type SuncoUserProfile = {
|
||||
givenName?: string
|
||||
surname?: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
locale?: string
|
||||
}
|
||||
|
||||
export type SuncoUser = {
|
||||
id: string
|
||||
externalId?: string
|
||||
signedUpAt?: string
|
||||
profile?: SuncoUserProfile
|
||||
}
|
||||
|
||||
export type SuncoUserAuthor = {
|
||||
type: 'user'
|
||||
userId: string
|
||||
user?: SuncoUser
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type SuncoBusinessAuthor = {
|
||||
type: 'business'
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export type SuncoMessageAuthor = SuncoUserAuthor | SuncoBusinessAuthor
|
||||
|
||||
export type ConversationMessageEvent = {
|
||||
id: string
|
||||
createdAt: string
|
||||
type: 'conversation:message'
|
||||
payload: {
|
||||
conversation?: {
|
||||
id: string
|
||||
type?: string
|
||||
activeSwitchboardIntegration?: {
|
||||
id: string
|
||||
name: string
|
||||
integrationId: string
|
||||
integrationType: string
|
||||
}
|
||||
pendingSwitchboardIntegration?: {
|
||||
id: string
|
||||
name: string
|
||||
integrationId: string
|
||||
integrationType: string
|
||||
}
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
message: {
|
||||
id: string
|
||||
received: string
|
||||
author: SuncoMessageAuthor
|
||||
content: SuncoMessageContent
|
||||
metadata?: Record<string, string>
|
||||
source?: {
|
||||
originalMessageTimestamp?: string
|
||||
type?: string
|
||||
integrationId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type SuncoWebhookPayload = {
|
||||
events: SuncoEvent[]
|
||||
}
|
||||
|
||||
export type SuncoEvent = ConversationMessageEvent | ConversationCreateEvent | UnhandledEvent
|
||||
|
||||
export function isSuncoWebhookPayload(data: unknown): data is SuncoWebhookPayload {
|
||||
return z
|
||||
.object({
|
||||
events: z.array(z.unknown()),
|
||||
})
|
||||
.safeParse(data).success
|
||||
}
|
||||
|
||||
export function isWebhookSignatureValid(
|
||||
headers: {
|
||||
[key: string]: string | undefined
|
||||
},
|
||||
webhookSecret: string
|
||||
) {
|
||||
const actualSecret = headers['x-api-key']
|
||||
if (!actualSecret || actualSecret !== webhookSecret) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function getConversation(event: SuncoEvent) {
|
||||
if ('payload' in event && event.payload.conversation) {
|
||||
return event.payload.conversation
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { register } from './register'
|
||||
export { unregister } from './unregister'
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const register: bp.IntegrationProps['register'] = async () => {}
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const unregister: bp.IntegrationProps['unregister'] = async () => {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type Handler = bp.IntegrationProps['handler']
|
||||
export type HandlerProps = bp.HandlerProps
|
||||
export type IntegrationCtx = bp.Context
|
||||
export type Logger = bp.Logger
|
||||
|
||||
export type Client = bp.Client
|
||||
export type Conversation = bp.ClientResponses['listConversations']['conversations'][number]
|
||||
export type Message = bp.ClientResponses['getMessage']['message']
|
||||
export type User = bp.ClientResponses['getUser']['user']
|
||||
export type Event = bp.ClientResponses['getEvent']['event']
|
||||
|
||||
export type EventDefinition = sdk.EventDefinition
|
||||
export type ActionDefinition = sdk.ActionDefinition
|
||||
export type ChannelDefinition = sdk.ChannelDefinition
|
||||
export type MessageDefinition = sdk.MessageDefinition
|
||||
|
||||
export type ActionProps = bp.AnyActionProps
|
||||
export type MessageHandlerProps = bp.AnyMessageProps
|
||||
export type AckFunction = bp.AnyAckFunction
|
||||
export type CreateMessageInput = Parameters<Client['createMessage']>[0]
|
||||
export type CreateMessageInputType = CreateMessageInput['type']
|
||||
export type CreateMessageInputPayload = CreateMessageInput['payload']
|
||||
|
||||
export type StoredCredentials = {
|
||||
appId: string
|
||||
token: string
|
||||
subdomain?: string
|
||||
}
|
||||
|
||||
// Channel message payload types
|
||||
export type Choice = bp.channels.channel.choice.Choice
|
||||
export type Carousel = bp.channels.channel.carousel.Carousel
|
||||
@@ -0,0 +1,82 @@
|
||||
export function getSuncoConversationId(conversation: { tags: { id?: string } }): string {
|
||||
const conversationId = conversation.tags.id
|
||||
|
||||
if (!conversationId) {
|
||||
throw new Error('Conversation does not have a sunco identifier')
|
||||
}
|
||||
|
||||
return conversationId
|
||||
}
|
||||
|
||||
export function isNetworkError(error: unknown): error is {
|
||||
status?: number
|
||||
body?: any
|
||||
request?: {
|
||||
data?: unknown
|
||||
body?: unknown
|
||||
}
|
||||
response?: {
|
||||
status?: number
|
||||
text?: string
|
||||
req: {
|
||||
method: string
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
data?: unknown
|
||||
body?: unknown
|
||||
}
|
||||
header: Record<string, string>
|
||||
}
|
||||
} {
|
||||
return typeof error === 'object' && error !== null && 'status' in error
|
||||
}
|
||||
|
||||
export function getNetworkErrorDetails(error: unknown):
|
||||
| {
|
||||
message: string
|
||||
status?: number
|
||||
data?: unknown
|
||||
requestBody?: unknown
|
||||
}
|
||||
| undefined {
|
||||
if (typeof error !== 'object' || error === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!isNetworkError(error)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse error message from various formats
|
||||
let message: string | undefined
|
||||
|
||||
// Check for Sunshine Conversations API error format (errors array in body)
|
||||
if (Array.isArray(error.body?.errors)) {
|
||||
const errorMessages = (error.body.errors as Array<{ title?: string; code?: string }>)
|
||||
.map((err) => {
|
||||
if (err.title) {
|
||||
return err.code ? `${err.title}: ${err.code}` : err.title
|
||||
}
|
||||
return JSON.stringify(err)
|
||||
})
|
||||
.filter((msg): msg is string => msg !== undefined)
|
||||
|
||||
if (errorMessages.length > 0) {
|
||||
message = errorMessages.join('; ')
|
||||
}
|
||||
} else if (error.body?.message?.length) {
|
||||
message = error.body?.message
|
||||
} else if (error.body) {
|
||||
message = JSON.stringify(error.body)
|
||||
}
|
||||
|
||||
const requestBody =
|
||||
error.request?.data ?? error.request?.body ?? error.response?.req?.data ?? error.response?.req?.body
|
||||
|
||||
return {
|
||||
message: message ?? 'Unknown error',
|
||||
status: error.status ?? error.response?.status,
|
||||
data: error.body,
|
||||
requestBody,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as oauthWizard from '@botpress/common/src/oauth-wizard'
|
||||
import { getSuncoClient } from './client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type WizardHandler = oauthWizard.WizardStepHandler<bp.HandlerProps>
|
||||
|
||||
const BOT_LIST_PATH = '/admin/ai/ai-agents/ai-agents/marketplace-bots'
|
||||
|
||||
export const handler = async (props: bp.HandlerProps) => {
|
||||
const wizard = new oauthWizard.OAuthWizardBuilder(props)
|
||||
.addStep({ id: 'start', handler: _start })
|
||||
.addStep({ id: 'get-subdomain', handler: _getSubdomain })
|
||||
.addStep({ id: 'add-to-channels', handler: _addToChannels })
|
||||
.addStep({ id: 'select-identifier', handler: _selectIdentifier })
|
||||
.addStep({ id: 'end', handler: _endHandler })
|
||||
.build()
|
||||
return await wizard.handleRequest()
|
||||
}
|
||||
|
||||
const _start: WizardHandler = async (props) => {
|
||||
const { client, responses, ctx, inputValue } = props
|
||||
|
||||
let subdomain = inputValue
|
||||
if (!subdomain) {
|
||||
const {
|
||||
state: { payload },
|
||||
} = await client.getOrSetState({
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: {},
|
||||
})
|
||||
if (!payload.subdomain) {
|
||||
return responses.redirectToStep('get-subdomain')
|
||||
}
|
||||
subdomain = payload.subdomain
|
||||
}
|
||||
|
||||
const url = `https://${subdomain}.zendesk.com${BOT_LIST_PATH}`
|
||||
const htmlUrl = `<a href="${url}" target="_blank" rel="noopener noreferrer">
|
||||
here
|
||||
</a>`
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Open Admin Center',
|
||||
htmlOrMarkdownPageContents: `Please click ${htmlUrl} to open your Admin Center on the \`AI => AI agents => AI agents => Marketplace bots\` page. (Click on \`Change subdomain\` if your subdomain is not \`${subdomain}\`.)`,
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Change subdomain', navigateToStep: 'get-subdomain', buttonType: 'secondary' },
|
||||
{ action: 'navigate', label: 'Next step', navigateToStep: 'add-to-channels', buttonType: 'primary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _getSubdomain: WizardHandler = async (props) => {
|
||||
const { responses } = props
|
||||
return responses.displayInput({
|
||||
pageTitle: 'Enter your SunCo Subdomain',
|
||||
htmlOrMarkdownPageContents: 'To continue, you need to enter your SunCo subdomain',
|
||||
input: { label: 'e.g. https://{subdomain}.zendesk.com', type: 'text' },
|
||||
nextStepId: 'start',
|
||||
})
|
||||
}
|
||||
|
||||
const _addToChannels: WizardHandler = async (props) => {
|
||||
const { responses } = props
|
||||
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Add The Bot To Channels',
|
||||
htmlOrMarkdownPageContents:
|
||||
'Click on the Botpress bot. Then in the `Basics` section, select every channel for which you want Botpress to be the default responder. Click on the `Save` button.',
|
||||
buttons: [
|
||||
{ action: 'navigate', label: 'Previous step', navigateToStep: 'start', buttonType: 'secondary' },
|
||||
{ action: 'navigate', label: 'Next step', navigateToStep: 'select-identifier', buttonType: 'primary' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const _selectIdentifier: WizardHandler = async (props) => {
|
||||
const { responses, client, ctx, selectedChoice, setIntegrationIdentifier } = props
|
||||
|
||||
if (selectedChoice) {
|
||||
setIntegrationIdentifier(selectedChoice)
|
||||
return responses.redirectToStep('end')
|
||||
}
|
||||
|
||||
const {
|
||||
state: { payload: credentials },
|
||||
} = await client.getOrSetState({
|
||||
name: 'credentials',
|
||||
type: 'integration',
|
||||
id: ctx.integrationId,
|
||||
payload: {},
|
||||
})
|
||||
|
||||
if (!credentials.appId || !credentials.token) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Select Channel Integration',
|
||||
htmlOrMarkdownPageContents: 'Could not load credentials. Please go back and complete the setup.',
|
||||
buttons: [{ action: 'navigate', label: 'Go back', navigateToStep: 'add-to-channels', buttonType: 'secondary' }],
|
||||
})
|
||||
}
|
||||
|
||||
const suncoClient = getSuncoClient({
|
||||
appId: credentials.appId,
|
||||
token: credentials.token,
|
||||
subdomain: credentials.subdomain,
|
||||
})
|
||||
const allIntegrations = await suncoClient.listIntegrations()
|
||||
const integrations = allIntegrations.filter((i) => i.defaultResponder?.integrationType === bp.secrets.CLIENT_ID)
|
||||
|
||||
if (!integrations.length) {
|
||||
return responses.displayButtons({
|
||||
pageTitle: 'Select Channel Integration',
|
||||
htmlOrMarkdownPageContents:
|
||||
'No channels found with Botpress as the default responder. Go back and add at least one channel.',
|
||||
buttons: [{ action: 'navigate', label: 'Go back', navigateToStep: 'add-to-channels', buttonType: 'secondary' }],
|
||||
})
|
||||
}
|
||||
|
||||
return responses.displayChoices({
|
||||
pageTitle: 'Select Channel Integration',
|
||||
htmlOrMarkdownPageContents: 'Select the channel integration you want to associate with this bot:',
|
||||
choices: integrations.map((i) => ({ label: i.displayName || i.id || 'Unknown', value: i.id! })),
|
||||
nextStepId: 'select-identifier',
|
||||
})
|
||||
}
|
||||
|
||||
const _endHandler: WizardHandler = async ({ responses }) => {
|
||||
return responses.endWizard({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import config from '../../vitest.config'
|
||||
export default config
|
||||
Reference in New Issue
Block a user