diff --git a/.github/actions/create-or-update-secret/action.yml b/.github/actions/create-or-update-secret/action.yml new file mode 100644 index 00000000000..7c9ac6f38b7 --- /dev/null +++ b/.github/actions/create-or-update-secret/action.yml @@ -0,0 +1,31 @@ +name: 'Create or Update Secret' +description: 'Creates or updates a secret in the repository' + +inputs: + secret_name: + description: 'Secret name' + required: true + secret_value: + description: 'Secret value' + required: true + sync_secret_app_id: + description: 'Sync secret app id' + required: true + sync_secret_app_private_key: + description: 'Sync secret app private key' + required: true +runs: + using: 'composite' + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ inputs.sync_secret_app_id }} + private-key: ${{ inputs.sync_secret_app_private_key }} + - name: Set secret + shell: bash + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + gh secret set ${{ inputs.secret_name }} --body "${{ inputs.secret_value }}" diff --git a/.github/actions/refresh-instagram-access-tokens/action.yml b/.github/actions/refresh-instagram-access-tokens/action.yml deleted file mode 100644 index f769aad8669..00000000000 --- a/.github/actions/refresh-instagram-access-tokens/action.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: 'Refresh Instagram Access Tokens' -description: 'Refreshes Instagram access token, updates the integrations secrets and update the token in 1Password' - -inputs: - environment: - type: choice - description: 'Environment to deploy the integration to' - required: true - options: - - staging - - production - item_name: - description: '1Password item name containing the Instagram access token' - required: true - field_name: - description: '1Password field name containing the Instagram access token' - required: true - token_cloud_ops_account: - description: 'Cloud Ops account token' - required: true - cloud_ops_workspace_id: - description: 'Cloud Ops workspace id' - required: true -runs: - using: 'composite' - steps: - - name: Install 1Password CLI - uses: 1password/install-cli-action@v1 - - name: Refresh Instagram Access Tokens - shell: bash - env: - BP_API_URL: ${{ inputs.environment == 'staging' && 'https://api.botpress.dev' || 'https://api.botpress.cloud' }} - BP_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }} - BP_TOKEN: ${{ inputs.token_cloud_ops_account }} - OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} - run: | - script_path="./.github/scripts/refresh-instagram-access-tokens.sh" - chmod +x $script_path - $script_path "${{ inputs.item_name }}" "${{ inputs.field_name }}" diff --git a/.github/actions/refresh-instagram-tokens/action.yml b/.github/actions/refresh-instagram-tokens/action.yml new file mode 100644 index 00000000000..05f44d2d008 --- /dev/null +++ b/.github/actions/refresh-instagram-tokens/action.yml @@ -0,0 +1,53 @@ +name: 'Refresh Instagram Access Tokens' +description: 'Refreshes Instagram access token, updates the integrations secrets and update the token GitHub secrets' + +inputs: + token_cloud_ops_account: + description: 'Cloud Ops account token' + required: true + cloud_ops_workspace_id: + description: 'Cloud Ops workspace id' + required: true + api_url: + description: 'API URL' + required: true + current_token: + description: 'Current token' + required: true + secret_name: + description: 'Secret name' + required: true + sync_secret_app_id: + description: 'Create or update secret app id' + required: true + sync_secret_app_private_key: + description: 'Create or update secret app private key' + required: true +runs: + using: 'composite' + steps: + - name: Refresh Access Tokens + id: refresh_tokens + shell: bash + env: + BP_API_URL: ${{ inputs.api_url }} + BP_WORKSPACE_ID: ${{ inputs.cloud_ops_workspace_id }} + BP_TOKEN: ${{ inputs.token_cloud_ops_account }} + run: | + refresh_result=$(pnpm -F 'instagram' exec -- pnpm run --silent refreshTokens --json --refreshToken "${{ inputs.current_token }}") + new_token=$(echo "$refresh_result" | jq -r '.refreshedToken') + if [ -z "$new_token" ]; then + echo "❌ Error: Failed to refresh token" >&2 + exit 1 + fi + + echo "$refresh_result" | jq -r '.message' + echo "✅ Tokens refreshed successfully" + echo "::add-mask::$new_token" + echo "new_token=$new_token" >> "$GITHUB_OUTPUT" + - uses: ./.github/actions/create-or-update-secret + with: + secret_name: ${{ inputs.secret_name }} + secret_value: ${{ steps.refresh_tokens.outputs.new_token }} + sync_secret_app_id: ${{ inputs.sync_secret_app_id }} + sync_secret_app_private_key: ${{ inputs.sync_secret_app_private_key }} diff --git a/.github/scripts/refresh-instagram-access-tokens.sh b/.github/scripts/refresh-instagram-access-tokens.sh deleted file mode 100755 index 18bfb512470..00000000000 --- a/.github/scripts/refresh-instagram-access-tokens.sh +++ /dev/null @@ -1,34 +0,0 @@ -if [ -z "$1" ]; then - echo "❌ Error: item name is not provided" >&2 - exit 1 -fi -item=$1 - -if [ -z "$2" ]; then - echo "❌ Error: field name is not provided" >&2 - exit 1 -fi -field=$2 - -current_token=$(op item get "$item" --format json | jq -r ".fields[] | select(.label == \"$field\").value") -if [ -z "$current_token" ]; then - echo "❌ Error: Failed to retrieve current token from 1Password" >&2 - exit 1 -fi - -refresh_result=$(pnpm -F 'instagram' exec -- pnpm run --silent refreshTokens --json --instagramRefreshToken "$current_token") -new_token=$(echo "$refresh_result" | jq -r '.refreshedToken') -if [ -z "$new_token" ]; then - echo "❌ Error: Failed to refresh token" >&2 - exit 1 -fi - -echo "$refresh_result" | jq -r '.message' - -op item edit "$item" "$field=$new_token" > /dev/null -if [ $? -ne 0 ]; then - echo "❌ Error: Failed to update token in 1Password" >&2 - exit 1 -fi - -echo "✅ Tokens refreshed successfully" \ No newline at end of file diff --git a/.github/workflows/refresh-instagram-access-tokens-production.yml b/.github/workflows/refresh-instagram-access-tokens-production.yml index 2378d1bff6d..84927fbe6ae 100644 --- a/.github/workflows/refresh-instagram-access-tokens-production.yml +++ b/.github/workflows/refresh-instagram-access-tokens-production.yml @@ -4,6 +4,7 @@ on: schedule: # Run at 10:00 AM EST (15:00 UTC) on the first Tuesday of every month - cron: '0 15 1-7 * 2' + workflow_dispatch: jobs: refresh-tokens: @@ -14,12 +15,25 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - extra_filters: '-F instagram' + extra_filters: '-F @botpresshub/instagram' - name: Refresh Instagram Access Tokens - uses: ./.github/actions/refresh-instagram-access-tokens + uses: ./.github/actions/refresh-instagram-tokens with: - environment: production - item_name: 'Secrets - Instagram' - field_name: 'Sandbox Access Token - Instagram User - Botpress Sandbox' token_cloud_ops_account: ${{ secrets.PRODUCTION_TOKEN_CLOUD_OPS_ACCOUNT }} cloud_ops_workspace_id: ${{ secrets.PRODUCTION_CLOUD_OPS_WORKSPACE_ID }} + api_url: https://api.botpress.cloud + current_token: ${{ secrets.PRODUCTION_INSTAGRAM_ACCESS_TOKEN }} + secret_name: PRODUCTION_INSTAGRAM_ACCESS_TOKEN + sync_secret_app_id: ${{ vars.SYNC_SECRET_APP_ID }} + sync_secret_app_private_key: ${{ secrets.SYNC_SECRET_APP_PRIVATE_KEY }} + ping-success: + runs-on: depot-ubuntu-22.04-8 + needs: [refresh-tokens] + steps: + - run: curl -m 10 --retry 5 ${{ secrets.INSTAGRAM_REFRESH_TOKEN_PING_URL }} + ping-failure: + runs-on: depot-ubuntu-22.04-8 + if: ${{ failure() }} + needs: [refresh-tokens] + steps: + - run: curl -m 10 --retry 5 ${{ secrets.INSTAGRAM_REFRESH_TOKEN_PING_URL }}/fail diff --git a/.github/workflows/refresh-instagram-access-tokens-staging.yml b/.github/workflows/refresh-instagram-access-tokens-staging.yml index 8cbc6c91841..efc90e7e488 100644 --- a/.github/workflows/refresh-instagram-access-tokens-staging.yml +++ b/.github/workflows/refresh-instagram-access-tokens-staging.yml @@ -4,6 +4,7 @@ on: schedule: # Run at 10:00 AM EST (15:00 UTC) on the first Tuesday of every month - cron: '0 15 1-7 * 2' + workflow_dispatch: jobs: refresh-tokens: @@ -14,12 +15,14 @@ jobs: - name: Setup uses: ./.github/actions/setup with: - extra_filters: '-F instagram' + extra_filters: '-F @botpresshub/instagram' - name: Refresh Instagram Access Tokens - uses: ./.github/actions/refresh-instagram-access-tokens + uses: ./.github/actions/refresh-instagram-tokens with: - environment: staging - item_name: 'Secrets - Instagram' - field_name: 'Sandbox Access Token - Instagram User - Botpress Sandbox (Staging)' token_cloud_ops_account: ${{ secrets.STAGING_TOKEN_CLOUD_OPS_ACCOUNT }} cloud_ops_workspace_id: ${{ secrets.STAGING_CLOUD_OPS_WORKSPACE_ID }} + api_url: https://api.botpress.dev + current_token: ${{ secrets.STAGING_INSTAGRAM_ACCESS_TOKEN }} + secret_name: STAGING_INSTAGRAM_ACCESS_TOKEN + sync_secret_app_id: ${{ vars.SYNC_SECRET_APP_ID }} + sync_secret_app_private_key: ${{ secrets.SYNC_SECRET_APP_PRIVATE_KEY }} diff --git a/integrations/instagram/refreshTokens.ts b/integrations/instagram/refreshTokens.ts index 3ee403be725..e0b73f9d394 100644 --- a/integrations/instagram/refreshTokens.ts +++ b/integrations/instagram/refreshTokens.ts @@ -8,14 +8,12 @@ const argsSchema = z.object({ apiUrl: z.string(), workspaceId: z.string(), token: z.string(), - instagramRefreshToken: z.string(), + refreshToken: z.string(), json: z.boolean(), useProvidedToken: z.boolean(), }) -type RefreshSecretArgs = { - instagramRefreshToken: string +type RefreshSecretArgs = Pick, 'refreshToken' | 'json'> & { client: Client - json: boolean useProvidedToken: boolean log: (message: string) => void } @@ -25,7 +23,7 @@ const DEFAULT_API_URL = 'https://api.botpress.cloud' const INSTAGRAM_GRAPH_API_URL = 'https://graph.instagram.com' async function refreshSandboxAccessToken(args: RefreshSecretArgs) { - const { instagramRefreshToken, client, useProvidedToken, log } = args + const { refreshToken, client, useProvidedToken, log } = args const { integrations: integrationsList } = await client.listIntegrations({ name: 'instagram', @@ -57,14 +55,14 @@ async function refreshSandboxAccessToken(args: RefreshSecretArgs) { if (useProvidedToken) { log('Using provided token, skipping refresh on Instagram API') data = { - access_token: instagramRefreshToken, + access_token: refreshToken, } } else { log('Refreshing access token on Instagram API') const response = await axios.get(`${INSTAGRAM_GRAPH_API_URL}/refresh_access_token`, { params: { grant_type: 'ig_refresh_token', - access_token: instagramRefreshToken, + access_token: refreshToken, }, }) data = response.data @@ -73,7 +71,7 @@ async function refreshSandboxAccessToken(args: RefreshSecretArgs) { for (const integration of integrations) { await client.updateIntegration({ id: integration.id, - public: integration.public, + visibility: integration.visibility, secrets: { [INTEGRATION_SECRET_SANDBOX_ACCESS_TOKEN]: data.access_token, }, @@ -94,7 +92,7 @@ async function main() { apiUrl: { type: 'string', default: process.env.BP_API_URL || DEFAULT_API_URL }, workspaceId: { type: 'string', default: process.env.BP_WORKSPACE_ID }, token: { type: 'string', default: process.env.BP_TOKEN }, - instagramRefreshToken: { type: 'string' }, + refreshToken: { type: 'string' }, json: { type: 'boolean', default: false }, useProvidedToken: { type: 'boolean', default: false }, }, @@ -102,7 +100,7 @@ async function main() { try { const args = argsSchema.parse(values) - const { apiUrl, token, workspaceId, instagramRefreshToken, json, useProvidedToken } = args + const { apiUrl, token, workspaceId, refreshToken, json, useProvidedToken } = args const messages: string[] = [] const log = (message: string) => { if (!json) { @@ -117,14 +115,14 @@ async function main() { const client = new Client({ apiUrl, token, workspaceId }) const refreshResult = await refreshSandboxAccessToken({ - instagramRefreshToken, + refreshToken, client, json, useProvidedToken, log, }) const output = json - ? JSON.stringify({ message: messages.join('\\n'), messages, ...refreshResult }, null, 2) + ? JSON.stringify({ message: messages.join('\n'), messages, ...refreshResult }, null, 2) : `New token: ${refreshResult.refreshedToken}` console.info(output) process.exit(0) diff --git a/integrations/whatsapp/integration.definition.ts b/integrations/whatsapp/integration.definition.ts index 8ab9d8067aa..5b856b74fd5 100644 --- a/integrations/whatsapp/integration.definition.ts +++ b/integrations/whatsapp/integration.definition.ts @@ -43,37 +43,39 @@ const startConversationProps = { 'Proactively starts a conversation with a WhatsApp user by sending them a message using a WhatsApp Message Template', input: { schema: z.object({ - conversation: z.object({ - userPhone: z - .string() - .min(1) - .title('User Phone Number') - .describe('Phone number of the WhatsApp user to start a conversation with'), - templateName: z - .string() - .min(1) - .title('Message Template name') - .describe('Name of the WhatsApp Message Template to start the conversation with'), - templateLanguage: z - .string() - .optional() - .title('Message Template language') - .describe( - 'Language of the WhatsApp Message Template to start the conversation with. Defaults to "en" (English)' - ), - templateVariablesJson: z - .string() - .optional() - .title('Message Template variables') - .describe( - 'JSON array representation of variable values to pass to the WhatsApp Message Template (if required by the template)' - ), - botPhoneNumberId: z - .string() - .optional() - .title('Bot Phone Number ID') - .describe('Phone number ID to use as sender (uses the default phone number ID if not provided)'), - }), + conversation: z + .object({ + userPhone: z + .string() + .min(1) + .title('User Phone Number') + .describe('Phone number of the WhatsApp user to start a conversation with'), + templateName: z + .string() + .min(1) + .title('Message Template name') + .describe('Name of the WhatsApp Message Template to start the conversation with'), + templateLanguage: z + .string() + .optional() + .title('Message Template language') + .describe( + 'Language of the WhatsApp Message Template to start the conversation with. Defaults to "en" (English)' + ), + templateVariablesJson: z + .string() + .optional() + .title('Message Template variables') + .describe( + 'JSON array representation of variable values to pass to the WhatsApp Message Template (if required by the template)' + ), + botPhoneNumberId: z + .string() + .optional() + .title('Bot Phone Number ID') + .describe('Phone number ID to use as sender (uses the default phone number ID if not provided)'), + }) + .describe('Details of the conversation'), }), }, } @@ -85,7 +87,7 @@ const defaultBotPhoneNumberId = { export default new IntegrationDefinition({ name: INTEGRATION_NAME, - version: '4.2.6', + version: '4.3.0', title: 'WhatsApp', description: 'Send and receive messages through WhatsApp.', icon: 'icon.svg', @@ -226,6 +228,16 @@ export default new IntegrationDefinition({ }), }, }, + sendTemplateMessage: { + title: 'Send Template Message', + description: 'Sends a WhatsApp Message Template to a user in an existing conversation', + input: startConversationProps.input, + output: { + schema: z.object({ + conversationId: z.string().title('Conversation ID').describe('ID of the conversation created'), + }), + }, + }, }, events: { reactionAdded: { diff --git a/integrations/whatsapp/src/actions/index.ts b/integrations/whatsapp/src/actions/index.ts index 834dbe9e6df..5a9d7fb2f6c 100644 --- a/integrations/whatsapp/src/actions/index.ts +++ b/integrations/whatsapp/src/actions/index.ts @@ -1,9 +1,10 @@ -import { startConversation } from './start-conversation' +import { startConversation, sendTemplateMessage } from './start-conversation' import { startTypingIndicator, stopTypingIndicator } from './typing-indicator' import * as bp from '.botpress' export default { startConversation, + sendTemplateMessage, startTypingIndicator, stopTypingIndicator, } as const satisfies bp.IntegrationProps['actions'] diff --git a/integrations/whatsapp/src/actions/start-conversation.ts b/integrations/whatsapp/src/actions/start-conversation.ts index 5df6268e773..af2500e143a 100644 --- a/integrations/whatsapp/src/actions/start-conversation.ts +++ b/integrations/whatsapp/src/actions/start-conversation.ts @@ -6,6 +6,13 @@ import * as bp from '.botpress' const TemplateVariablesSchema = z.array(z.string().or(z.number())) +export const sendTemplateMessage: bp.IntegrationProps['actions']['sendTemplateMessage'] = async (props) => { + return startConversation({ + ...props, + type: 'startConversation', + }) +} + export const startConversation: bp.IntegrationProps['actions']['startConversation'] = async ({ ctx, input, diff --git a/packages/cli/package.json b/packages/cli/package.json index 896d93ba2a1..04ee9ce882b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "4.17.12", + "version": "4.17.13", "description": "Botpress CLI", "scripts": { "build": "pnpm run bundle && pnpm run template:gen", diff --git a/packages/cli/src/command-implementations/dev-command.ts b/packages/cli/src/command-implementations/dev-command.ts index 2db1da2b6b1..09e4b4fd39d 100644 --- a/packages/cli/src/command-implementations/dev-command.ts +++ b/packages/cli/src/command-implementations/dev-command.ts @@ -3,6 +3,7 @@ import type * as sdk from '@botpress/sdk' import { TunnelRequest, TunnelResponse } from '@bpinternal/tunnel' import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' import chalk from 'chalk' +import { isEqual } from 'lodash' import * as pathlib from 'path' import * as uuid from 'uuid' import * as apiUtils from '../api' @@ -22,6 +23,7 @@ const FILEWATCHER_DEBOUNCE_MS = 2000 export type DevCommandDefinition = typeof commandDefinitions.dev export class DevCommand extends ProjectCommand { private _initialDef: ProjectDefinition | undefined = undefined + private _cacheDevRequestBody: apiUtils.UpdateBotRequestBody | apiUtils.UpdateIntegrationRequestBody | undefined public async run(): Promise { this.logger.warn('This command is experimental and subject to breaking changes without notice.') @@ -322,9 +324,6 @@ export class DevCommand extends ProjectCommand { await this.projectCache.set('devId', bot.id) } - const updateLine = this.logger.line() - updateLine.started('Deploying dev bot...') - const updateBotBody = apiUtils.prepareUpdateBotBody( { ...(await apiUtils.prepareCreateBotBody(botDef)), @@ -335,6 +334,13 @@ export class DevCommand extends ProjectCommand { bot ) + if (!(await this._didDefinitionChange(updateBotBody))) { + this.logger.log('Skipping deployment step. No changes found in bot.definition.ts') + return + } + const updateLine = this.logger.line() + updateLine.started('Deploying dev bot...') + const { bot: updatedBot } = await api.client.updateBot(updateBotBody).catch((thrown) => { throw errors.BotpressCLIError.wrap(thrown, 'Could not deploy dev bot') }) @@ -356,6 +362,12 @@ export class DevCommand extends ProjectCommand { this.displayWebhookUrls(updatedBot) } + private async _didDefinitionChange(body: apiUtils.UpdateBotRequestBody | apiUtils.UpdateIntegrationRequestBody) { + const didChange = !isEqual(body, this._cacheDevRequestBody) + this._cacheDevRequestBody = { ...body } + return didChange + } + private _forwardTunnelRequest = async (baseUrl: string, request: TunnelRequest): Promise => { const axiosConfig = { method: request.method,