Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion echo.Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ dynamodb_resource = {
}
}

PUSHPIN_ROUTES = "* %s:443,ssl,insecure,host=%s" % (API.bp_webhook_domain, API.bp_webhook_domain)
PUSHPIN_ROUTES = "*,proto=ws %s:443,ssl,insecure,host=%s,over_http\n* %s:443,ssl,insecure,host=%s" % (API.bp_webhook_domain, API.bp_webhook_domain, API.bp_webhook_domain, API.bp_webhook_domain)
PUSHPIN_CONFIG = "%s" % read_file(PUSHPIN_CONFIG_PATH, '')
pushpin_ressource = {
"image": "botpress/pushpin",
Expand Down
2 changes: 1 addition & 1 deletion integrations/anthropic/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default new IntegrationDefinition({
name: 'anthropic',
title: 'Anthropic',
description: 'Access a curated list of Claude models to set as your chosen LLM.',
version: '17.0.0',
version: '18.0.0',
readme: 'hub.md',
icon: 'icon.svg',
entities: {
Expand Down
6 changes: 4 additions & 2 deletions integrations/anthropic/src/actions/generate-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,13 @@ export async function generateContent(
(modelId === 'claude-sonnet-4-5-20250929' ||
modelId === 'claude-haiku-4-5-20251001' ||
modelId === 'claude-sonnet-4-6' ||
modelId === 'claude-opus-4-6') &&
modelId === 'claude-opus-4-6' ||
modelId === 'claude-opus-4-7') &&
request.temperature !== undefined &&
request.top_p !== undefined
) {
// This model fails when setting both parameters with the error "`temperature` and `top_p` cannot both be specified for this model. Please use only one.", so we remove the top_p parameter if temperature is also set.
// TODO: Remove this check once all 3.x models are removed,
// see https://platform.claude.com/docs/en/about-claude/models/migration-guide#breaking-changes
request.top_p = undefined
}

Expand Down
14 changes: 14 additions & 0 deletions integrations/anthropic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ const LanguageModels: Record<ModelId, llm.ModelDetails> = {
// NOTE: We don't support returning "thinking" blocks from Claude in the integration action output as the concept of "thinking" blocks is a Claude-specific feature that other providers don't have. For now we won't support this as an official feature in the integration so it needs to be taken into account when using reasoning mode and passing a multi-turn conversation history in the generateContent action input.
// For more information, see: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
// NOTE: We intentionally didn't include the Opus model as it's the most expensive model in the market, it's not very popular, and no users have ever requested it so far.
'claude-opus-4-7': {
name: 'Claude Opus 4.7',
description:
"Claude Opus 4.7 is Anthropic's most capable model to date. Building on Opus 4.6, it advances frontier coding, agentic reasoning, and enterprise workflows.",
tags: ['recommended', 'reasoning', 'agents', 'vision', 'general-purpose', 'coding'],
input: {
costPer1MTokens: 5,
maxTokens: 1_000_000,
},
output: {
costPer1MTokens: 15,
maxTokens: 128_000,
},
},
'claude-opus-4-6': {
name: 'Claude Opus 4.6',
description:
Expand Down
1 change: 1 addition & 0 deletions integrations/anthropic/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const DefaultModel: ModelId = 'claude-sonnet-4-5-20250929'

export const ModelId = z
.enum([
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-haiku-4-5-20251001',
Expand Down
3 changes: 2 additions & 1 deletion integrations/chat/package.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
{
"name": "@botpresshub/chat",
"private": true,
"scripts": {
"check:type": "tsc --noEmit",
"check:bplint": "bp lint",
"generate": "ts-node -T ./openapi.ts ./src/gen",
"build": "bp add -y && bp build",
"test": "vitest --run"
},
"private": true,
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.564.0",
"@botpress/sdk": "workspace:*",
"@bpinternal/pingrip": "0.1.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/core": "1.30.0",
"@opentelemetry/exporter-trace-otlp-http": "0.54.2",
Expand Down
21 changes: 21 additions & 0 deletions integrations/chat/src/handler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Request } from '@botpress/sdk'
import * as api from './api'
import { extraRoutes } from './extra-routes'
import * as errors from './gen/errors'
import { handleRequest, Router } from './gen/handler'
import { httpRequestsTotal, httpRequestDuration } from './metrics'
import { Handler } from './types'
import * as websocket from './websocket'

const isPushpinRequest = (req: Request) => 'grip-sig' in req.headers

Expand All @@ -30,6 +32,25 @@ export const makeHandler =
}
}

if (websocket.isWebSocketRequest(args.req) && args.req.body) {
try {
return await websocket.handleWebSocketRequest(props, args.req)
} catch (thrown: unknown) {
if (errors.isApiError(thrown)) {
return {
status: thrown.code,
body: JSON.stringify(thrown.toJSON()),
}
}
return {
status: 500,
body: JSON.stringify({
message: thrown instanceof Error ? thrown.message : 'Unknown error',
}),
}
}
}

const match = router.match(args.req.path)
const normalizedPath = match?.path ?? 'not_found'
const method = args.req.method.toLowerCase()
Expand Down
7 changes: 7 additions & 0 deletions integrations/chat/src/signal-emitter/pushpin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class PushpinEmitter implements SignalEmitter {
action: 'send',
content: this._sse(signal),
},
'ws-message': {
action: 'send',
content: JSON.stringify(signal),
},
},
},
],
Expand All @@ -73,6 +77,9 @@ export class PushpinEmitter implements SignalEmitter {
'http-stream': {
action: 'close',
},
'ws-message': {
action: 'close',
},
},
},
],
Expand Down
78 changes: 78 additions & 0 deletions integrations/chat/src/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Request } from '@botpress/sdk'
import { messages, outputs } from '@bpinternal/pingrip'
import qs from 'qs'
import * as api from './api'
import * as errors from './gen/errors'

const WS_CLOSE_GOING_AWAY = 1001

export const isWebSocketRequest = (req: Request) => {
if (req.method.toLowerCase() !== 'post') {
return false
}
const parts = req.path.split('/').splice(1)
if (parts.length !== 3) {
return false
}
return parts[0] === 'conversations' && parts[2] === 'listen'
}

type RequestIdentifiers = {
conversationId: string
userId: string
}

const extractRequestIdentifiers = async (props: api.OperationTools, req: Request): Promise<RequestIdentifiers> => {
const queries = qs.parse(req.query)
if (!queries['x-user-key'] || typeof queries['x-user-key'] !== 'string') {
throw new errors.UnauthorizedError('x-user-key should be specified as a query param.')
}
const userKey = queries['x-user-key']

const _convId = req.path.split('/').splice(1)[1]
if (_convId === undefined) {
throw new errors.InternalError('An unexpected error occurred.')
}
const _userId = props.auth.parseKey(userKey).id
const [userId, conversationId] = await Promise.all([
props.userIdStore.byFid.get(_userId),
props.convIdStore.byFid.get(_convId),
])

return {
userId,
conversationId,
}
}

export const handleWebSocketRequest = async (props: api.OperationTools, req: Request) => {
if (!req.body) {
throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.')
}
const { userId, conversationId } = await extractRequestIdentifiers(props, req)
const channels = [conversationId, userId]

const { participant } = await props.apiUtils.findParticipant({ id: conversationId, userId })
if (!participant) {
throw new errors.ForbiddenError('You are not a participant in this conversation')
}

for (const message of messages.parse(Buffer.from(req.body))) {
if (message.type === 'open') {
const response = new outputs.ResponseBuilder().open().keepAlive('ping', 30).subscribe(channels).toResponse()
return {
...response,
body: response.body.toString(),
}
}
if (message.type === 'close' || message.type === 'disconnect') {
const code = 'code' in message ? message.code : WS_CLOSE_GOING_AWAY
const response = new outputs.ResponseBuilder().close(code).unsubscribe(channels).toResponse()
return {
...response,
body: response.body.toString(),
}
}
}
throw new errors.InvalidPayloadError('The payload should be a open or close websocket message.')
}
2 changes: 1 addition & 1 deletion integrations/slack/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default new IntegrationDefinition({
name: 'slack',
title: 'Slack',
description: 'Automate interactions with your team.',
version: '5.0.0',
version: '5.0.2',
icon: 'icon.svg',
readme: 'hub.md',
configuration: {
Expand Down
99 changes: 59 additions & 40 deletions integrations/slack/src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ChatPostMessageArguments } from '@slack/web-api'
import { textSchema } from '../definitions/channels/text-input-schema'
import { transformMarkdownForSlack } from './misc/markdown-to-slack'
import { replaceMentions } from './misc/replace-mentions'
import { isValidUrl } from './misc/utils'
import { downloadBotpressFile, isValidUrl } from './misc/utils'
import { SlackClient } from './slack-api'
import { renderCard } from './slack-api/card-renderer'
import * as bp from '.botpress'
Expand Down Expand Up @@ -41,51 +41,15 @@ const defaultMessages = {
},
audio: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending audio message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'audio',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `<${payload.audioUrl}|audio>` },
},
],
}
)
await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.audioUrl, title: payload.title })
},
video: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending video message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'video',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `<${payload.videoUrl}|video>` },
},
],
}
)
await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.videoUrl, title: payload.title })
},
file: async ({ ctx, conversation, ack, client, payload, logger }) => {
logger.forBot().debug('Sending file message to Slack chat:', payload)
await _sendSlackMessage(
{ ack, ctx, client, logger },
{
..._getSlackTarget(conversation),
text: 'file',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `<${payload.fileUrl}|file>` },
},
],
}
)
await _uploadSlackFile({ ack, ctx, client, logger, conversation }, { url: payload.fileUrl, title: payload.title })
},
location: async ({ ctx, conversation, ack, client, payload, logger }) => {
const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${payload.latitude},${payload.longitude}`
Expand Down Expand Up @@ -231,6 +195,61 @@ const _getOptionalProps = (ctx: bp.Context, logger: bp.Logger) => {
return props
}

const _uploadSlackFile = async (
{
client,
ctx,
ack,
logger,
conversation,
}: {
client: bp.Client
ctx: bp.Context
ack: bp.AnyAckFunction
logger: bp.Logger
conversation: bp.ClientResponses['getConversation']['conversation']
},
{ url, title }: { url: string; title?: string }
) => {
const { channel, thread_ts } = _getSlackTarget(conversation)
const { buffer, filename } = await downloadBotpressFile(url, client, logger)

const slackClient = await SlackClient.createFromStates({ client, ctx, logger })

const oldestTs = (Date.now() / 1000 - 1).toFixed(6)

await slackClient.uploadFile({
channelId: channel,
threadTs: thread_ts,
fileBuffer: buffer,
filename,
title,
})

let messageTs: string | undefined
let messageUserId: string | undefined
try {
const message = await slackClient.getLatestChannelMessage({
channelId: channel,
threadTs: thread_ts,
oldestTs,
})

if (message && message.user === slackClient.getBotUserId()) {
messageTs = message.ts
messageUserId = message.user
} else {
logger
.forBot()
.warn('Could not correlate uploaded Slack file with a bot message; thread/reaction tracking will be limited')
}
} catch (err) {
logger.forBot().warn(`Failed to retrieve uploaded file message metadata: ${err}`)
}

await ack({ tags: { ts: messageTs, channelId: channel, userId: messageUserId } })
}

const _sendSlackMessage = async (
{ client, ctx, ack, logger }: { client: bp.Client; ctx: bp.Context; ack: bp.AnyAckFunction; logger: bp.Logger },
payload: ChatPostMessageArguments
Expand Down
Loading
Loading