chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.botpress
+13
View File
@@ -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']
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+10
View File
@@ -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
+1
View File
@@ -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.
+8
View File
@@ -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,
},
}))
+9
View File
@@ -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 }}"
+29
View File
@@ -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,
}
}
+11
View File
@@ -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 {}
}
+8
View File
@@ -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 }
}
+689
View File
@@ -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>
}
+194
View File
@@ -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
)
}
+146
View File
@@ -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}`)
}
}
+2
View File
@@ -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
}
+127
View File
@@ -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 })
}
+16
View File
@@ -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)
+214
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
export { register } from './register'
export { unregister } from './unregister'
+3
View File
@@ -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 () => {}
+35
View File
@@ -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
+82
View File
@@ -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,
}
}
+129
View File
@@ -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 })
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
import config from '../../vitest.config'
export default config