From 216541ea95ccf80b2aa2c7c95c7aea572f6b0d57 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:54:44 +0530 Subject: [PATCH 1/4] feat(slack): list only the bot's channels in channel dropdowns (#15907) --- packages/pieces/community/slack/package.json | 2 +- .../lib/actions/add-reaction-to-message.ts | 3 +- .../slack/src/lib/actions/delete-message.ts | 3 +- .../src/lib/actions/get-channel-history.ts | 3 +- .../slack/src/lib/actions/get-message.ts | 3 +- .../src/lib/actions/invite-user-to-channel.ts | 3 +- .../src/lib/actions/request-action-message.ts | 3 +- .../lib/actions/request-approval-message.ts | 3 +- .../lib/actions/retrieve-thread-messages.ts | 3 +- .../src/lib/actions/send-message-action.ts | 3 +- .../src/lib/actions/set-channel-topic.ts | 3 +- .../slack/src/lib/actions/update-message.ts | 3 +- .../slack/src/lib/actions/upload-file.ts | 3 +- .../community/slack/src/lib/common/props.ts | 60 ++++++++-- .../slack/src/lib/triggers/new-command.ts | 29 +---- .../slack/src/lib/triggers/new-mention.ts | 29 +---- .../lib/triggers/new-message-in-channel.ts | 3 +- .../src/lib/triggers/new-reaction-added.ts | 29 +---- .../src/lib/triggers/new-reaction-removed.ts | 29 +---- .../community/slack/test/get-channels.test.ts | 109 ++++++++++++++++++ 20 files changed, 203 insertions(+), 123 deletions(-) create mode 100644 packages/pieces/community/slack/test/get-channels.test.ts diff --git a/packages/pieces/community/slack/package.json b/packages/pieces/community/slack/package.json index 1576d00a7f0e..64c6b3bb81a3 100644 --- a/packages/pieces/community/slack/package.json +++ b/packages/pieces/community/slack/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-slack", - "version": "0.20.0", + "version": "0.21.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/slack/src/lib/actions/add-reaction-to-message.ts b/packages/pieces/community/slack/src/lib/actions/add-reaction-to-message.ts index 5eef2f050da0..cb0e7f6532f3 100644 --- a/packages/pieces/community/slack/src/lib/actions/add-reaction-to-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/add-reaction-to-message.ts @@ -1,6 +1,6 @@ import { slackAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; -import { singleSelectChannelInfo, slackChannel, messageTs } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels, messageTs } from '../common/props'; import { WebClient } from '@slack/web-api'; import { processMessageTimestamp } from '../common/utils'; @@ -22,6 +22,7 @@ export const addRectionToMessageAction = createAction({ props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), ts: messageTs, reaction: Property.ShortText({ diff --git a/packages/pieces/community/slack/src/lib/actions/delete-message.ts b/packages/pieces/community/slack/src/lib/actions/delete-message.ts index a82d1b043909..379e944021ba 100644 --- a/packages/pieces/community/slack/src/lib/actions/delete-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/delete-message.ts @@ -1,6 +1,6 @@ import { createAction } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { singleSelectChannelInfo, slackChannel, messageTs } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels, messageTs } from '../common/props'; import { processMessageTimestamp } from '../common/utils'; import { WebClient } from '@slack/web-api'; import { requireUserToken, SlackAuthValue } from '../common/auth-helpers'; @@ -19,6 +19,7 @@ export const deleteMessageAction = createAction({ auth: slackAuth, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), ts: messageTs, }, diff --git a/packages/pieces/community/slack/src/lib/actions/get-channel-history.ts b/packages/pieces/community/slack/src/lib/actions/get-channel-history.ts index 77461b4c681f..da4946dcf8c0 100644 --- a/packages/pieces/community/slack/src/lib/actions/get-channel-history.ts +++ b/packages/pieces/community/slack/src/lib/actions/get-channel-history.ts @@ -1,7 +1,7 @@ import { ConversationsHistoryResponse, WebClient } from '@slack/web-api'; import { createAction, Property } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { singleSelectChannelInfo, slackChannel } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels } from '../common/props'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; import { channelHistoryActionOutputSchema } from '../output-schemas'; @@ -16,6 +16,7 @@ export const getChannelHistory = createAction({ outputSchema: channelHistoryActionOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), oldest: Property.Number({ displayName: 'Oldest', diff --git a/packages/pieces/community/slack/src/lib/actions/get-message.ts b/packages/pieces/community/slack/src/lib/actions/get-message.ts index 91c1a508e7db..3ac64559f5ad 100644 --- a/packages/pieces/community/slack/src/lib/actions/get-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/get-message.ts @@ -1,6 +1,6 @@ import { createAction } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { singleSelectChannelInfo, slackChannel, messageTs } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels, messageTs } from '../common/props'; import { processMessageTimestamp } from '../common/utils'; import { WebClient } from '@slack/web-api'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; @@ -17,6 +17,7 @@ export const getMessageAction = createAction({ outputSchema: getMessageActionOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), ts: messageTs, }, diff --git a/packages/pieces/community/slack/src/lib/actions/invite-user-to-channel.ts b/packages/pieces/community/slack/src/lib/actions/invite-user-to-channel.ts index 5d4f553c33b2..ec75b68e7761 100644 --- a/packages/pieces/community/slack/src/lib/actions/invite-user-to-channel.ts +++ b/packages/pieces/community/slack/src/lib/actions/invite-user-to-channel.ts @@ -1,6 +1,6 @@ import { slackAuth } from '../auth'; import { createAction } from '@activepieces/pieces-framework'; -import { singleSelectChannelInfo, slackChannel, userId } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels, userId } from '../common/props'; import { WebClient } from '@slack/web-api'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; import { channelResponseOutputSchema } from '../output-schemas'; @@ -16,6 +16,7 @@ export const inviteUserToChannelAction = createAction({ outputSchema: channelResponseOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), userId: userId(true), }, diff --git a/packages/pieces/community/slack/src/lib/actions/request-action-message.ts b/packages/pieces/community/slack/src/lib/actions/request-action-message.ts index 96233b5cb17a..5ccca5f28894 100644 --- a/packages/pieces/community/slack/src/lib/actions/request-action-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/request-action-message.ts @@ -1,7 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; import { assertNotNullOrUndefined } from '@activepieces/pieces-framework'; -import { profilePicture, text, slackChannel, username, actions, singleSelectChannelInfo, threadTs, mentionOriginFlow, replyBroadcast } from '../common/props'; +import { profilePicture, text, slackChannel, onlyBotChannels, username, actions, singleSelectChannelInfo, threadTs, mentionOriginFlow, replyBroadcast } from '../common/props'; import { requestAction } from '../common/request-action'; import { requestActionActionOutputSchema } from '../output-schemas'; @@ -21,6 +21,7 @@ export const requestActionMessageAction = createAction({ outputSchema: requestActionActionOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), text, actions, diff --git a/packages/pieces/community/slack/src/lib/actions/request-approval-message.ts b/packages/pieces/community/slack/src/lib/actions/request-approval-message.ts index d34dadaf17b1..017f0f646886 100644 --- a/packages/pieces/community/slack/src/lib/actions/request-approval-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/request-approval-message.ts @@ -6,7 +6,7 @@ import { ExecutionType } from '@activepieces/pieces-framework'; import { profilePicture, singleSelectChannelInfo, - slackChannel, + slackChannel, onlyBotChannels, text, username, mentionOriginFlow, @@ -27,6 +27,7 @@ export const requestSendApprovalMessageAction = createAction({ outputSchema: approvalActionOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), text, username, diff --git a/packages/pieces/community/slack/src/lib/actions/retrieve-thread-messages.ts b/packages/pieces/community/slack/src/lib/actions/retrieve-thread-messages.ts index 34688f8c0e28..6930f90b836f 100644 --- a/packages/pieces/community/slack/src/lib/actions/retrieve-thread-messages.ts +++ b/packages/pieces/community/slack/src/lib/actions/retrieve-thread-messages.ts @@ -1,7 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; import { WebClient } from '@slack/web-api'; -import { singleSelectChannelInfo, slackChannel, threadCursor } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels, threadCursor } from '../common/props'; import { fetchAllThreadReplies, processMessageTimestamp } from '../common/utils'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; import { threadRepliesActionOutputSchema } from '../output-schemas'; @@ -17,6 +17,7 @@ export const retrieveThreadMessages = createAction({ outputSchema: threadRepliesActionOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), threadTs: Property.ShortText({ displayName: 'Thread Timestamp', diff --git a/packages/pieces/community/slack/src/lib/actions/send-message-action.ts b/packages/pieces/community/slack/src/lib/actions/send-message-action.ts index 349049e51c74..bc21356e0a24 100644 --- a/packages/pieces/community/slack/src/lib/actions/send-message-action.ts +++ b/packages/pieces/community/slack/src/lib/actions/send-message-action.ts @@ -1,5 +1,5 @@ import { createAction, Property } from '@activepieces/pieces-framework'; -import { profilePicture, slackChannel, username, blocks, threadTs, singleSelectChannelInfo, mentionOriginFlow, iconEmoji, replyBroadcast, unfurlLinks } from '../common/props'; +import { profilePicture, slackChannel, onlyBotChannels, username, blocks, threadTs, singleSelectChannelInfo, mentionOriginFlow, iconEmoji, replyBroadcast, unfurlLinks } from '../common/props'; import { buildFlowOriginContextBlock, processMessageTimestamp, slackSendMessage, textToSectionBlocks } from '../common/utils'; import { slackAuth } from '../auth'; import { Block,KnownBlock } from '@slack/web-api'; @@ -18,6 +18,7 @@ export const slackSendMessageAction = createAction({ outputSchema: chatPostMessageOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), text: Property.LongText({ displayName: 'Message', diff --git a/packages/pieces/community/slack/src/lib/actions/set-channel-topic.ts b/packages/pieces/community/slack/src/lib/actions/set-channel-topic.ts index 108fe6c17b9a..c75e55a6b693 100644 --- a/packages/pieces/community/slack/src/lib/actions/set-channel-topic.ts +++ b/packages/pieces/community/slack/src/lib/actions/set-channel-topic.ts @@ -1,6 +1,6 @@ import { slackAuth } from '../auth'; import { createAction, Property } from '@activepieces/pieces-framework'; -import { singleSelectChannelInfo, slackChannel } from '../common/props'; +import { singleSelectChannelInfo, slackChannel, onlyBotChannels } from '../common/props'; import { WebClient } from '@slack/web-api'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; import { channelResponseOutputSchema } from '../output-schemas'; @@ -16,6 +16,7 @@ export const setChannelTopicAction = createAction({ outputSchema: channelResponseOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), topic: Property.LongText({ displayName: 'Topic', diff --git a/packages/pieces/community/slack/src/lib/actions/update-message.ts b/packages/pieces/community/slack/src/lib/actions/update-message.ts index f4219c57c469..818ea6914e37 100644 --- a/packages/pieces/community/slack/src/lib/actions/update-message.ts +++ b/packages/pieces/community/slack/src/lib/actions/update-message.ts @@ -1,6 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { blocks, singleSelectChannelInfo, slackChannel, mentionOriginFlow, messageTs } from '../common/props'; +import { blocks, singleSelectChannelInfo, slackChannel, onlyBotChannels, mentionOriginFlow, messageTs } from '../common/props'; import { buildFlowOriginContextBlock, processMessageTimestamp, textToSectionBlocks } from '../common/utils'; import { Block,KnownBlock, WebClient } from '@slack/web-api'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; @@ -21,6 +21,7 @@ export const updateMessage = createAction({ outputSchema: chatUpdateOutputSchema, props: { info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), ts: messageTs, text: Property.LongText({ diff --git a/packages/pieces/community/slack/src/lib/actions/upload-file.ts b/packages/pieces/community/slack/src/lib/actions/upload-file.ts index 7573919a11c1..bc45f72b8385 100644 --- a/packages/pieces/community/slack/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/slack/src/lib/actions/upload-file.ts @@ -2,7 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; import { WebClient } from '@slack/web-api'; import { - slackChannel, + slackChannel, onlyBotChannels, } from '../common/props'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; import { uploadFileActionOutputSchema } from '../output-schemas'; @@ -33,6 +33,7 @@ export const uploadFile = createAction({ placeholder: 'report.pdf', required: false, }), + onlyBotChannels, channel: slackChannel(false), }, async run(context) { diff --git a/packages/pieces/community/slack/src/lib/common/props.ts b/packages/pieces/community/slack/src/lib/common/props.ts index 11459fc27b74..a0b4c9a2441a 100644 --- a/packages/pieces/community/slack/src/lib/common/props.ts +++ b/packages/pieces/community/slack/src/lib/common/props.ts @@ -1,5 +1,5 @@ import { MarkdownVariant, Property } from '@activepieces/pieces-framework'; -import { UsersListResponse, WebClient } from '@slack/web-api'; +import { ConversationsListResponse, UsersConversationsResponse, UsersListResponse, WebClient } from '@slack/web-api'; import { slackAuth } from '../auth'; import { getBotToken, SlackAuthValue } from '../common/auth-helpers'; export const multiSelectChannelInfo = Property.MarkDown({ @@ -27,13 +27,13 @@ export const interactivitySetupInfo = Property.MarkDown({ }); export const slackChannel = (required: R) => - Property.Dropdown({ + Property.Dropdown({ auth: slackAuth, displayName: 'Channel', description: 'Private channels appear only after the bot is added to them.', required, - refreshers: [], - async options({ auth }) { + refreshers: ['onlyBotChannels'], + async options({ auth, onlyBotChannels }) { if (!auth) { return { disabled: true, @@ -43,7 +43,7 @@ export const slackChannel = (required: R) => } const accessToken = getBotToken(auth as SlackAuthValue); - const channels = await getChannels(accessToken); + const channels = await getChannels({ accessToken, onlyBotChannels: onlyBotChannels === true }); return { disabled: false, @@ -53,6 +53,39 @@ export const slackChannel = (required: R) => }, }); +export const onlyBotChannels = Property.Checkbox({ + displayName: 'Only Channels the Bot Is In', + description: 'Show only channels the bot is a member of.', + required: false, + defaultValue: true, +}); + +export const slackChannels = Property.MultiSelectDropdown({ + auth: slackAuth, + displayName: 'Channels', + description: 'Empty means every channel the bot is in.', + required: false, + refreshers: ['onlyBotChannels'], + async options({ auth, onlyBotChannels }) { + if (!auth) { + return { + disabled: true, + placeholder: 'connect slack account', + options: [], + }; + } + const accessToken = getBotToken(auth as SlackAuthValue); + + const channels = await getChannels({ accessToken, onlyBotChannels: onlyBotChannels === true }); + + return { + disabled: false, + placeholder: 'Select channels', + options: channels, + }; + }, +}); + export const username = Property.ShortText({ displayName: 'Username', description: "Overrides the bot's display name for this message.", @@ -259,19 +292,28 @@ export async function getUsers(accessToken: string) { return users; } -export async function getChannels(accessToken: string) { +export async function getChannels({ + accessToken, + onlyBotChannels, +}: { + accessToken: string; + onlyBotChannels?: boolean; +}) { const client = new WebClient(accessToken); const channels: { label: string; value: string }[] = []; const CHANNELS_LIMIT = 2000; - let cursor; + let cursor: string | undefined; do { - const response = await client.conversations.list({ + const params = { types: 'public_channel,private_channel', exclude_archived: true, limit: 1000, cursor, - }); + }; + const response: ConversationsListResponse | UsersConversationsResponse = onlyBotChannels + ? await client.users.conversations(params) + : await client.conversations.list(params); if (response.channels) { channels.push( diff --git a/packages/pieces/community/slack/src/lib/triggers/new-command.ts b/packages/pieces/community/slack/src/lib/triggers/new-command.ts index eed6668f63bc..9ea1a328d89d 100644 --- a/packages/pieces/community/slack/src/lib/triggers/new-command.ts +++ b/packages/pieces/community/slack/src/lib/triggers/new-command.ts @@ -3,10 +3,10 @@ import { TriggerStrategy, createTrigger, } from '@activepieces/pieces-framework'; -import { appWebhookSetupInfo, getChannels, multiSelectChannelInfo, userId } from '../common/props'; +import { appWebhookSetupInfo, onlyBotChannels, slackChannels, multiSelectChannelInfo, userId } from '../common/props'; import { slackAuth } from '../auth'; import { parseCommand } from '../common/utils'; -import { getBotToken, getTeamId, SlackAuthValue } from '../common/auth-helpers'; +import { getTeamId, SlackAuthValue } from '../common/auth-helpers'; import { newCommandTriggerOutputSchema } from '../output-schemas'; export const newCommand = createTrigger({ @@ -30,29 +30,8 @@ export const newCommand = createTrigger({ required: true, defaultValue: ['help'], }), - channels: Property.MultiSelectDropdown({ - auth: slackAuth, - displayName: 'Channels', - description: 'Empty means every channel the bot is in.', - required: false, - refreshers: [], - async options({ auth }) { - if (!auth) { - return { - disabled: true, - placeholder: 'connect slack account', - options: [], - }; - } - const accessToken = getBotToken(auth as SlackAuthValue); - const channels = await getChannels(accessToken); - return { - disabled: false, - placeholder: 'Select channel', - options: channels, - }; - }, - }), + onlyBotChannels, + channels: slackChannels, ignoreBots: Property.Checkbox({ displayName: 'Ignore Bot Messages', description: 'Skip messages posted by bots and apps.', diff --git a/packages/pieces/community/slack/src/lib/triggers/new-mention.ts b/packages/pieces/community/slack/src/lib/triggers/new-mention.ts index 80b0982bc337..5d16496d4b5a 100644 --- a/packages/pieces/community/slack/src/lib/triggers/new-mention.ts +++ b/packages/pieces/community/slack/src/lib/triggers/new-mention.ts @@ -3,9 +3,9 @@ import { TriggerStrategy, createTrigger, } from '@activepieces/pieces-framework'; -import { appWebhookSetupInfo, getChannels, multiSelectChannelInfo, userIds, usergroupIds } from '../common/props'; +import { appWebhookSetupInfo, onlyBotChannels, slackChannels, multiSelectChannelInfo, userIds, usergroupIds } from '../common/props'; import { slackAuth } from '../auth'; -import { getBotToken, getTeamId, SlackAuthValue } from '../common/auth-helpers'; +import { getTeamId, SlackAuthValue } from '../common/auth-helpers'; import { newMentionTriggerOutputSchema } from '../output-schemas'; export const newMention = createTrigger({ @@ -23,29 +23,8 @@ export const newMention = createTrigger({ info: multiSelectChannelInfo, users: userIds, usergroups: usergroupIds, - channels: Property.MultiSelectDropdown({ - auth: slackAuth, - displayName: 'Channels', - description: 'Empty means every channel the bot is in.', - required: false, - refreshers: [], - async options({ auth }) { - if (!auth) { - return { - disabled: true, - placeholder: 'connect slack account', - options: [], - }; - } - const accessToken = getBotToken(auth as SlackAuthValue); - const channels = await getChannels(accessToken); - return { - disabled: false, - placeholder: 'Select channel', - options: channels, - }; - }, - }), + onlyBotChannels, + channels: slackChannels, ignoreBots: Property.Checkbox({ displayName: 'Ignore Bot Messages', description: 'Skip messages posted by bots and apps.', diff --git a/packages/pieces/community/slack/src/lib/triggers/new-message-in-channel.ts b/packages/pieces/community/slack/src/lib/triggers/new-message-in-channel.ts index aa1fe1a14699..6fbc7163a6a7 100644 --- a/packages/pieces/community/slack/src/lib/triggers/new-message-in-channel.ts +++ b/packages/pieces/community/slack/src/lib/triggers/new-message-in-channel.ts @@ -1,5 +1,5 @@ import { Property, TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; -import { appWebhookSetupInfo, singleSelectChannelInfo, slackChannel } from '../common/props'; +import { appWebhookSetupInfo, singleSelectChannelInfo, slackChannel, onlyBotChannels } from '../common/props'; import { slackAuth } from '../auth'; import { getTeamId, SlackAuthValue } from '../common/auth-helpers'; import { newMessageInChannelTriggerOutputSchema } from '../output-schemas'; @@ -19,6 +19,7 @@ export const newMessageInChannelTrigger = createTrigger({ props: { webhookInfo: appWebhookSetupInfo, info: singleSelectChannelInfo, + onlyBotChannels, channel: slackChannel(true), ignoreBots: Property.Checkbox({ displayName: 'Ignore Bot Messages', diff --git a/packages/pieces/community/slack/src/lib/triggers/new-reaction-added.ts b/packages/pieces/community/slack/src/lib/triggers/new-reaction-added.ts index 47d36e5e516b..a4569610a137 100644 --- a/packages/pieces/community/slack/src/lib/triggers/new-reaction-added.ts +++ b/packages/pieces/community/slack/src/lib/triggers/new-reaction-added.ts @@ -4,8 +4,8 @@ import { createTrigger, } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { appWebhookSetupInfo, getChannels, multiSelectChannelInfo, userId } from '../common/props'; -import { getBotToken, getTeamId, SlackAuthValue } from '../common/auth-helpers'; +import { appWebhookSetupInfo, onlyBotChannels, slackChannels, multiSelectChannelInfo, userId } from '../common/props'; +import { getTeamId, SlackAuthValue } from '../common/auth-helpers'; import { newReactionAddedTriggerOutputSchema } from '../output-schemas'; @@ -28,29 +28,8 @@ export const newReactionAdded = createTrigger({ required: false, }), user: userId(false), - channels: Property.MultiSelectDropdown({ - auth: slackAuth, - displayName: 'Channels', - description: 'Empty means every channel the bot is in.', - required: false, - refreshers: [], - async options({ auth }) { - if (!auth) { - return { - disabled: true, - placeholder: 'connect slack account', - options: [], - }; - } - const accessToken = getBotToken(auth as SlackAuthValue); - const channels = await getChannels(accessToken); - return { - disabled: false, - placeholder: 'Select channels', - options: channels, - }; - }, - }), + onlyBotChannels, + channels: slackChannels, }, type: TriggerStrategy.APP_WEBHOOK, sampleData: undefined, diff --git a/packages/pieces/community/slack/src/lib/triggers/new-reaction-removed.ts b/packages/pieces/community/slack/src/lib/triggers/new-reaction-removed.ts index df011627ce65..88328bb78147 100644 --- a/packages/pieces/community/slack/src/lib/triggers/new-reaction-removed.ts +++ b/packages/pieces/community/slack/src/lib/triggers/new-reaction-removed.ts @@ -4,8 +4,8 @@ import { createTrigger, } from '@activepieces/pieces-framework'; import { slackAuth } from '../auth'; -import { appWebhookSetupInfo, getChannels, multiSelectChannelInfo, userId } from '../common/props'; -import { getBotToken, getTeamId, SlackAuthValue } from '../common/auth-helpers'; +import { appWebhookSetupInfo, onlyBotChannels, slackChannels, multiSelectChannelInfo, userId } from '../common/props'; +import { getTeamId, SlackAuthValue } from '../common/auth-helpers'; import { newReactionRemovedTriggerOutputSchema } from '../output-schemas'; export const newReactionRemoved = createTrigger({ @@ -27,29 +27,8 @@ export const newReactionRemoved = createTrigger({ required: false, }), user: userId(false), - channels: Property.MultiSelectDropdown({ - auth: slackAuth, - displayName: 'Channels', - description: 'Empty means every channel the bot is in.', - required: false, - refreshers: [], - async options({ auth }) { - if (!auth) { - return { - disabled: true, - placeholder: 'connect slack account', - options: [], - }; - } - const accessToken = getBotToken(auth as SlackAuthValue); - const channels = await getChannels(accessToken); - return { - disabled: false, - placeholder: 'Select channels', - options: channels, - }; - }, - }), + onlyBotChannels, + channels: slackChannels, }, type: TriggerStrategy.APP_WEBHOOK, sampleData: undefined, diff --git a/packages/pieces/community/slack/test/get-channels.test.ts b/packages/pieces/community/slack/test/get-channels.test.ts new file mode 100644 index 000000000000..d32fdb38038b --- /dev/null +++ b/packages/pieces/community/slack/test/get-channels.test.ts @@ -0,0 +1,109 @@ +import { AppConnectionType } from '@activepieces/pieces-framework'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const calls: { method: string; params: Record }[] = []; +let pages: { channels: { id: string; name: string }[]; next?: string }[] = []; + +vi.mock('@slack/web-api', () => ({ + WebClient: class { + conversations = { + list: async (params: Record) => nextPage({ method: 'conversations.list', params }), + }; + users = { + conversations: async (params: Record) => nextPage({ method: 'users.conversations', params }), + }; + }, +})); + +import { getChannels, onlyBotChannels, slackChannel, slackChannels } from '../src/lib/common/props'; + +const auth = { + type: AppConnectionType.CUSTOM_AUTH, + props: { botToken: 'xoxb-test' }, +}; + +function nextPage(call: { method: string; params: Record }) { + calls.push(call); + const page = pages[calls.length - 1] ?? { channels: [] }; + return { channels: page.channels, response_metadata: { next_cursor: page.next ?? '' } }; +} + +function channelsOf({ count, prefix }: { count: number; prefix: string }) { + return Array.from({ length: count }, (_, i) => ({ id: `${prefix}${i}`, name: `${prefix}-${i}` })); +} + +describe('channel listing', () => { + beforeEach(() => { + calls.length = 0; + pages = [{ channels: [{ id: 'C1', name: 'general' }] }]; + }); + + it('checkbox defaults to on', () => { + expect(onlyBotChannels.defaultValue).toBe(true); + }); + + it('lists only the bot channels via users.conversations when the toggle is on', async () => { + const channels = await getChannels({ accessToken: 'xoxb-test', onlyBotChannels: true }); + + expect(calls.map((c) => c.method)).toEqual(['users.conversations']); + expect(channels).toEqual([{ label: 'general', value: 'C1' }]); + }); + + it('lists all channels via conversations.list when the toggle is off or unset', async () => { + await getChannels({ accessToken: 'xoxb-test', onlyBotChannels: false }); + await getChannels({ accessToken: 'xoxb-test' }); + + expect(calls.map((c) => c.method)).toEqual(['conversations.list', 'conversations.list']); + }); + + it('follows next_cursor and stops at the 2000 channel cap', async () => { + pages = [ + { channels: channelsOf({ count: 1000, prefix: 'A' }), next: 'cursor-1' }, + { channels: channelsOf({ count: 1000, prefix: 'B' }), next: 'cursor-2' }, + { channels: channelsOf({ count: 1000, prefix: 'C' }), next: 'cursor-3' }, + ]; + + const channels = await getChannels({ accessToken: 'xoxb-test', onlyBotChannels: true }); + + expect(channels).toHaveLength(2000); + expect(calls).toHaveLength(2); + expect(calls[1].params.cursor).toBe('cursor-1'); + }); + + it('stops when there is no next cursor', async () => { + pages = [{ channels: channelsOf({ count: 3, prefix: 'A' }) }]; + + const channels = await getChannels({ accessToken: 'xoxb-test' }); + + expect(channels).toHaveLength(3); + expect(calls).toHaveLength(1); + }); + + describe.each([ + ['slackChannel', slackChannel(true)], + ['slackChannels', slackChannels], + ])('%s dropdown', (_name, prop) => { + it('refreshes on the toggle', () => { + expect(prop.refreshers).toEqual(['onlyBotChannels']); + }); + + it('uses users.conversations only when the toggle is exactly true', async () => { + await prop.options({ auth, onlyBotChannels: true }, {}); + await prop.options({ auth, onlyBotChannels: false }, {}); + await prop.options({ auth }, {}); + + expect(calls.map((c) => c.method)).toEqual([ + 'users.conversations', + 'conversations.list', + 'conversations.list', + ]); + }); + + it('is disabled without a connection', async () => { + const result = await prop.options({ onlyBotChannels: true }, {}); + + expect(result.disabled).toBe(true); + expect(calls).toHaveLength(0); + }); + }); +}); From f8cd637d018b7ef0e97a1967331f65ae76a440ba Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:01:37 +0100 Subject: [PATCH 2/4] feat(ai): serve the published model tiers to the chat and agent pickers (#15838) --- .../knowledge/ai-intelligence/ai-providers.md | 3 +- .../testing-model-tiers-locally.md | 15 ++ .../engineering/ci-pr-review-hygiene.md | 2 +- brain/knowledge/memory.md | 1 + .../api/src/app/ai/ai-provider-controller.ts | 28 +++ .../ce/ai-provider/model-tiers.test.ts | 129 ++++++++++++++ .../app/routes/chat-with-ai/ai-chat-box.tsx | 14 +- .../components/chat-model-selector.tsx | 55 +++--- .../web/src/features/agents/ai-model/hooks.ts | 72 +++++--- .../web/src/features/chat/lib/chat-utils.ts | 15 +- .../web/src/features/chat/lib/use-chat.ts | 13 +- .../platform-admin/api/ai-provider-api.ts | 18 ++ .../platform-admin/hooks/ai-provider-hooks.ts | 39 ++++- .../components/chat-model-selector.test.tsx | 76 ++++++++ .../features/agents/ai-model/hooks.test.tsx | 163 ++++++++++++++++++ .../chat/lib/new-chat-model-name.test.ts | 28 +++ .../platform-admin/ai-provider-hooks.test.tsx | 143 +++++++++++++++ 17 files changed, 757 insertions(+), 57 deletions(-) create mode 100644 brain/knowledge/ai-intelligence/testing-model-tiers-locally.md create mode 100644 packages/server/api/test/integration/ce/ai-provider/model-tiers.test.ts create mode 100644 packages/web/test/app/routes/chat-with-ai/components/chat-model-selector.test.tsx create mode 100644 packages/web/test/features/agents/ai-model/hooks.test.tsx create mode 100644 packages/web/test/features/chat/lib/new-chat-model-name.test.ts create mode 100644 packages/web/test/features/platform-admin/ai-provider-hooks.test.tsx diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 0f4148f782bb..96f0e697cd91 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -87,6 +87,7 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **The api test suite runs on the bundled tiers, never the live file.** `packages/server/api/vitest.setup.ts` points `AP_MODEL_TIERS_URL` at `model-tiers.invalid`, so boot warm-up fails fast and every test resolves against `ACTIVEPIECES_CHAT_TIERS`. CI runners have egress, and the first time a resolver read the file in CI the live `defaultTierId` was `fast`, which turned a test asserting the release default (Expert) red with no code change behind it. A test that wants the published shape mocks `modelTierCatalog` (`test/unit/app/ee/agent/model-tier-fixture.ts`); an integration test that boots the app cannot be handed a file. - **A published tier without `nativeModelId` keeps every BYOK key on the release's model for that tier id.** `nativeModelIdFor` (`ee/agent/agent-model-resolution.ts`) takes the tier's own `nativeModelId`, else the `nativeModelId` of the bundled `ACTIVEPIECES_CHAT_TIERS` entry with the same id, else null, which sends a curated provider to its first curated model and a catalogue-less provider to the first text model its key lists. The fallback is keyed by tier id only, so two lists sharing an id resolve to the same native model on every own-key provider, whatever their managed models are. The BYOK tier map (PR 6 of the configurable tiers work) is the real fix. - **`premium` pairs `modelId: anthropic/claude-opus-4.8` with `nativeModelId: claude-opus-4-7` on purpose.** The managed tier runs Opus 4.8 through OpenRouter; a customer's own Anthropic key runs Opus 4.7. The reason is cost on the customer's key: Heavy on a BYOK key must not silently move the customer onto the most expensive model without them choosing it. The console's `defaults.ts` carries the same pair. Do not "fix" either copy. +- **Whatever the console publishes is in every Cloud customer's picker within about 20 minutes on a fresh page load, and about 35 minutes on a page left open: no draft, no staging, no filter.** `GET /v1/ai-providers/tiers` (`ListModelTiers` in `ai-provider-controller.ts`) returns `modelTierCatalog.current(surface)` for both lists, keyed `flow` and `chat`, to USER and ENGINE principals with no platform or project scope, because the tiers are global; the response schema puts only `id`, `label` and `modelId` on the wire. The web reads it through one hook, `aiProviderQueries.useModelTiers(surface)` (`features/platform-admin/hooks/ai-provider-hooks.ts`): the chat selector passes `chat`, the agent picker passes `flow`, and nothing but a test checks which one a caller passed, since the surface argument the server requires does not reach this layer. Both pickers show the release's bundled tiers while the call is in flight or has failed, so they never render empty. The web query is stale after 15 minutes and refetches on that interval while the tab is visible (`staleTime` alone only gates the mount, focus and reconnect triggers, so a tab left open would never refetch), which is where the two bounds come from: CDN `max-age` 5 min plus the server's 15-min TTL for a fresh load, plus one 15-min client tick for an open page, and the server serves its old copy once while it refreshes. The AI piece's own model dropdown still reads the bundled constant (the props.ts bullet above) until it stores tier ids. - **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both. - **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run. @@ -173,7 +174,7 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **Chat model tiers are Activepieces-only.** `ACTIVEPIECES_CHAT_TIERS` (`fast`/`smart`/`premium`, shown as Fast/Expert/Heavy) hold OpenRouter-shaped Anthropic ids, so they only mean anything for the ACTIVEPIECES and OPENROUTER chat providers. Any provider that declares `ALLOWED_CHAT_MODELS_BY_PROVIDER` (openai, anthropic, google) picks a real model id from that list instead. Naively stripping the tier's vendor prefix for every provider is what once sent `claude-haiku-4-5` to OpenAI and broke every message. - **A short model list in the key's picker is the vendor's catalog, not a truncation.** `listModels` returns whatever the provider's own `/models` endpoint gives and filters nothing except the key's `modelScope` allow-list. Anthropic ships roughly a dozen models, OpenAI ~80 (mostly embeddings/tts/whisper), while OpenRouter is an aggregator and returns 400+ from every vendor it proxies — so the counts differ by an order of magnitude by design. Anthropic pages at 20 by default, which is why its request pins `?limit=1000`. If the list shows exactly three Claude models, that is the *chat* dropdown reading the curated `ANTHROPIC_CHAT_MODELS`, a different surface from the admin picker. - **Read the chat model list through `aiProviderUtils.getCuratedChatModels({ provider })`.** It is the one accessor the server resolver (`agentHelpers.resolveModelIdForProvider`) and the chat dropdown share, so the two cannot drift; it returns `{ id, label }` or `undefined` — never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded `CHAT_MODEL_LABELS` (falling back to the id) rather than `AIProviderModel.name`, because the live `listModels` response cannot supply one for every provider: anthropic returns `display_name` and google `displayName`, but OpenAI's `/v1/models` returns ids only. -- **`conversation.modelName` carries either a tier id or a real model id** — it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note `premium` maps to opus 4.8, which the native anthropic list does not carry, so a legacy `premium` on anthropic lands on Sonnet. +- **`conversation.modelName` carries either a tier id or a real model id** — it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note `premium` maps to opus 4.8, which the native anthropic list does not carry, so a legacy `premium` on anthropic lands on Sonnet. A new web chat sends no model at all until the user picks one (`use-chat.ts` starts `modelName` at null): the server resolves the published chat default for that turn and the worker stamps the tier it ran onto the conversation, so the chat default lives in one place, the published file, and the picker shows it as selected by reading `chat.defaultTierId` from the tiers endpoint. Before that change the web sent `smart` explicitly on every new chat, which is why a published `defaultTierId` never reached web chats. **That holds only where the picker shows tiers.** An own-key provider with a curated model list shows concrete models, but a null model still resolves the published chat default tier and then that tier's `nativeModelId`, which is in the Anthropic curated list: a published default of `fast` ran Haiku on every Anthropic-key chat while the picker showed Sonnet, and the worker's tier stamp kept it there. So a new chat on a curated key sends the model the picker shows (`chatUtils.newChatModelName`); the tier picker sends null, and so does any chat whose picker is hidden (an agent or the agent builder: a saved agent's job carries the agent's own model anyway). The chat box does not mount until the chat provider has loaded, or a message sent in that window would go out with no model. Caught in review on #15838. - Chat-provider resolution is **first `enabledForChat` row wins, else the visible managed ACTIVEPIECES row** — `pickChatRow`, shared by `findAvailableChatProviderRow` and both list endpoints. So a platform with `[openai, activepieces]` both chat-enabled still resolves to **openai** (an explicit pick beats the fallback), while a platform with nothing flagged resolves to the managed row instead of to nothing. The flag a list endpoint reports is therefore the *resolved* one, not the stored column — `ai_provider.enabledForChat` can say `false` on the row chat actually runs on, and `ensureManagedProviderRow` no longer writes it. The client mirror is `aiProviderQueries.useChatProvider()` (`providers.find((p) => p.enabledForChat)`) — always read the resolved chat provider through it rather than re-deriving the rule inline. A project entry is chat-enabled when it owns the picked row, whichever of its keys carries the flag: ranking (`selected` > `except` > `all`, newest first) and chat selection answer different questions, so reading `rows[0].enabledForChat` makes the client report "no provider configured" whenever the chat-enabled key is not the ranking winner. `findBy()` has no `ORDER BY`, so "first" is not guaranteed stable when several providers are chat-enabled. - Listing providers is **not a pure read**: both `listConfigs` and `listForProject` go through `listVisibleRows`, which inserts the ACTIVEPIECES provider row when `aiCreditsEnabled && !activepiecesExists`. A `GET /v1/ai-providers` can therefore create a row. It also applies the hidden-provider filter (`plan.embeddingEnabled` hides the managed provider), which is why the client can trust its output without re-checking flags. - **A managed chat turn is charged in two events, not one.** The model cost arrives per model call from diff --git a/brain/knowledge/ai-intelligence/testing-model-tiers-locally.md b/brain/knowledge/ai-intelligence/testing-model-tiers-locally.md new file mode 100644 index 000000000000..05ce0b69e125 --- /dev/null +++ b/brain/knowledge/ai-intelligence/testing-model-tiers-locally.md @@ -0,0 +1,15 @@ +--- +icon: 🧪 +--- +# Testing Model Tiers Locally + +How to see the published tier file drive chat, agents and the pickers on a dev box, without publishing anything. The server fetches through `safeHttp`, which blocks loopback, so the allow-list line is not optional. + +1. Write a tier file with both lists. Put something in it that the release does not ship, such as a chat-only tier `turbo` and a relabelled `smart` ("Expert (from file)"), so you can tell the file from the bundled constants at a glance. Every tier needs `id`, `label`, `modelId` (an OpenRouter id) and `thinkingBudget`; the file needs `version`, `publishedAt`, `publishedBy`, `tiers`, `defaultTierId`, and `chatTiers` with `chatDefaultTierId` together or not at all. +2. Serve it: `python3 -m http.server 8787` in that folder. +3. In `.env.dev`: `AP_MODEL_TIERS_URL=http://127.0.0.1:8787/tiers.json` and `AP_SSRF_ALLOW_LIST=127.0.0.1`. Start with `npm run dev`. The boot log must not say "Failed to load the AI model tiers file". +4. Chat page: the picker shows the chat list, with the chat default selected on a fresh chat. Agent model picker: the flow list only. A tier on one list and not the other proves the surfaces are told apart. +5. Send one chat message without picking. The `Chat config resolved` log line carries `tier.id`, `surface` and the model id the turn ran on. +6. To see a re-point, change a `modelId` in the file, restart the backend (the catalog cache is in-process, 15 minutes) and reload the page (the web refetches every 15 minutes). Same label, new model in the log. + +The Activepieces provider must be enabled for chat under Platform, AI, which needs `AP_EDITION=cloud` and `AP_OPENROUTER_PROVISION_KEY` on the dev box. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 29c60561a18a..e0ff28107a1d 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -84,7 +84,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Docs videos belong on `cdn.activepieces.com/videos/docs/`, never in the repo — and `docs/resources/` is the legacy path that suggests otherwise.** Every `