diff --git a/frontend/__snapshots__/components-integrations-slack--slack-integration-added--dark.png b/frontend/__snapshots__/components-integrations-slack--slack-integration-added--dark.png index dd30ef4e40c81..b0e1bd7e3e1d0 100644 Binary files a/frontend/__snapshots__/components-integrations-slack--slack-integration-added--dark.png and b/frontend/__snapshots__/components-integrations-slack--slack-integration-added--dark.png differ diff --git a/frontend/__snapshots__/components-integrations-slack--slack-integration-added--light.png b/frontend/__snapshots__/components-integrations-slack--slack-integration-added--light.png index 2c60a71e8d41a..c641cbc2729a5 100644 Binary files a/frontend/__snapshots__/components-integrations-slack--slack-integration-added--light.png and b/frontend/__snapshots__/components-integrations-slack--slack-integration-added--light.png differ diff --git a/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts b/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts index ab5108074621b..a3b328435be0f 100644 --- a/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts +++ b/frontend/src/lib/components/Subscriptions/subscriptionLogic.ts @@ -7,7 +7,6 @@ import { dayjs } from 'lib/dayjs' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { isEmail, isURL } from 'lib/utils' import { getInsightId } from 'scenes/insights/utils' -import { integrationsLogic } from 'scenes/settings/project/integrationsLogic' import { SubscriptionType } from '~/types' @@ -33,7 +32,6 @@ export const subscriptionLogic = kea([ key(({ id, insightShortId, dashboardId }) => `${insightShortId || dashboardId}-${id ?? 'new'}`), connect(({ insightShortId, dashboardId }: SubscriptionsLogicProps) => ({ actions: [subscriptionsLogic({ insightShortId, dashboardId }), ['loadSubscriptions']], - values: [integrationsLogic, ['isMemberOfSlackChannel']], })), loaders(({ props }) => ({ @@ -48,7 +46,7 @@ export const subscriptionLogic = kea([ }, })), - forms(({ props, actions, values }) => ({ + forms(({ props, actions }) => ({ subscription: { defaults: {} as unknown as SubscriptionType, errors: ({ frequency, interval, target_value, target_type, title, start_date }) => ({ @@ -76,12 +74,6 @@ export const subscriptionLogic = kea([ ? 'Must be a valid URL' : undefined : undefined, - memberOfSlackChannel: - target_type == 'slack' - ? target_value && !values.isMemberOfSlackChannel(target_value) - ? 'Please add the PostHog Slack App to the selected channel' - : undefined - : undefined, }), submit: async (subscription, breakpoint) => { const insightId = props.insightShortId ? await getInsightId(props.insightShortId) : undefined diff --git a/frontend/src/lib/components/Subscriptions/utils.tsx b/frontend/src/lib/components/Subscriptions/utils.tsx index 370c6855d6d1f..f76ce50535693 100644 --- a/frontend/src/lib/components/Subscriptions/utils.tsx +++ b/frontend/src/lib/components/Subscriptions/utils.tsx @@ -1,11 +1,10 @@ import { IconLetter } from '@posthog/icons' import { LemonSelectOptions } from '@posthog/lemon-ui' -import { IconSlack, IconSlackExternal } from 'lib/lemon-ui/icons' -import { LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' +import { IconSlack } from 'lib/lemon-ui/icons' import { range } from 'lib/utils' import { urls } from 'scenes/urls' -import { InsightShortId, SlackChannelType } from '~/types' +import { InsightShortId } from '~/types' export interface SubscriptionBaseProps { dashboardId?: number @@ -80,28 +79,3 @@ export const timeOptions: LemonSelectOptions = range(0, 24).map((x) => ( value: String(x), label: `${String(x).padStart(2, '0')}:00`, })) - -export const getSlackChannelOptions = ( - value: string, - slackChannels?: SlackChannelType[] | null -): LemonInputSelectOption[] => { - return slackChannels - ? slackChannels.map((x) => ({ - key: `${x.id}|#${x.name}`, - labelComponent: ( - - {x.is_private ? `🔒${x.name}` : `#${x.name}`} - {x.is_ext_shared ? : null} - - ), - label: `${x.id} #${x.name}`, - })) - : value - ? [ - { - key: value, - label: value?.split('|')?.pop() || value, - }, - ] - : [] -} diff --git a/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx b/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx index c47d043570384..3a8077ad6c4ee 100644 --- a/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx +++ b/frontend/src/lib/components/Subscriptions/views/EditSubscription.tsx @@ -4,19 +4,19 @@ import { Form } from 'kea-forms' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' import { usersLemonSelectOptions } from 'lib/components/UserSelectItem' import { dayjs } from 'lib/dayjs' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers' import { IconChevronLeft } from 'lib/lemon-ui/icons' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonField } from 'lib/lemon-ui/LemonField' -import { LemonInputSelect, LemonInputSelectOption } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' +import { LemonInputSelect } from 'lib/lemon-ui/LemonInputSelect/LemonInputSelect' import { LemonLabel } from 'lib/lemon-ui/LemonLabel/LemonLabel' import { LemonModal } from 'lib/lemon-ui/LemonModal' import { LemonSelect } from 'lib/lemon-ui/LemonSelect' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { useEffect, useMemo } from 'react' import { membersLogic } from 'scenes/organization/membersLogic' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' -import { integrationsLogic } from 'scenes/settings/project/integrationsLogic' import { urls } from 'scenes/urls' import { subscriptionLogic } from '../subscriptionLogic' @@ -25,7 +25,6 @@ import { bysetposOptions, frequencyOptionsPlural, frequencyOptionsSingular, - getSlackChannelOptions, intervalOptions, monthlyWeekdayOptions, SubscriptionBaseProps, @@ -59,15 +58,14 @@ export function EditSubscription({ }) const { meFirstMembers, membersLoading } = useValues(membersLogic) - const { subscription, subscriptionLoading, isSubscriptionSubmitting, subscriptionChanged, isMemberOfSlackChannel } = - useValues(logic) + const { subscription, subscriptionLoading, isSubscriptionSubmitting, subscriptionChanged } = useValues(logic) const { preflight, siteUrlMisconfigured } = useValues(preflightLogic) const { deleteSubscription } = useActions(subscriptionslogic) - const { slackChannels, slackChannelsLoading, slackIntegration, addToSlackButtonUrl } = useValues(integrationsLogic) - const { loadSlackChannels } = useActions(integrationsLogic) + const { slackIntegrations, addToSlackButtonUrl } = useValues(integrationsLogic) + // TODO: Fix this so that we use the appropriate config... + const firstSlackIntegration = slackIntegrations?.[0] const emailDisabled = !preflight?.email_service_available - const slackDisabled = !slackIntegration const _onDelete = (): void => { if (id !== 'new') { @@ -76,23 +74,6 @@ export function EditSubscription({ } } - useEffect(() => { - if (subscription?.target_type === 'slack' && slackIntegration) { - loadSlackChannels() - } - }, [subscription?.target_type, slackIntegration]) - - // If slackChannels aren't loaded, make sure we display only the channel name and not the actual underlying value - const slackChannelOptions: LemonInputSelectOption[] = useMemo( - () => getSlackChannelOptions(subscription?.target_value, slackChannels), - [slackChannels, subscription?.target_value] - ) - - const showSlackMembershipWarning = - subscription.target_value && - subscription.target_type === 'slack' && - !isMemberOfSlackChannel(subscription.target_value) - const formatter = new Intl.DateTimeFormat('en-US', { timeZoneName: 'shortGeneric' }) const parts = formatter.formatToParts(new Date()) const currentTimezone = parts?.find((part) => part.type === 'timeZoneName')?.value @@ -222,7 +203,7 @@ export function EditSubscription({ {subscription.target_type === 'slack' ? ( <> - {slackDisabled ? ( + {!firstSlackIntegration ? ( <> {addToSlackButtonUrl() ? ( @@ -278,45 +259,13 @@ export function EditSubscription({ } > {({ value, onChange }) => ( - onChange(val[0] ?? null)} - value={value ? [value] : []} - disabled={slackDisabled} - mode="single" - data-attr="select-slack-channel" - placeholder="Select a channel..." - options={slackChannelOptions} - loading={slackChannelsLoading} + )} - - {showSlackMembershipWarning ? ( - - -
- - The PostHog Slack App is not in this channel. Please add it - to the channel otherwise Subscriptions will fail to be - delivered.{' '} - - See the Docs for more information - - - - Check again - -
-
-
- ) : null} )} diff --git a/frontend/src/lib/integrations/SlackIntegrationHelpers.tsx b/frontend/src/lib/integrations/SlackIntegrationHelpers.tsx new file mode 100644 index 0000000000000..cd435a811a9ce --- /dev/null +++ b/frontend/src/lib/integrations/SlackIntegrationHelpers.tsx @@ -0,0 +1,130 @@ +import { LemonBanner, LemonButton, LemonInputSelect, LemonInputSelectOption, Link } from '@posthog/lemon-ui' +import { useActions, useValues } from 'kea' +import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' +import { IconSlack, IconSlackExternal } from 'lib/lemon-ui/icons' +import { useMemo } from 'react' + +import { IntegrationType, SlackChannelType } from '~/types' + +import { slackIntegrationLogic } from './slackIntegrationLogic' + +const getSlackChannelOptions = (slackChannels?: SlackChannelType[] | null): LemonInputSelectOption[] | null => { + return slackChannels + ? slackChannels.map((x) => ({ + key: `${x.id}|#${x.name}`, + labelComponent: ( + + {x.is_private ? `🔒${x.name}` : `#${x.name}`} + {x.is_ext_shared ? : null} + + ), + label: `${x.id} #${x.name}`, + })) + : null +} + +export type SlackChannelPickerProps = { + integration: IntegrationType + value?: string + onChange?: (value: string | null) => void + disabled?: boolean +} + +export function SlackChannelPicker({ onChange, value, integration, disabled }: SlackChannelPickerProps): JSX.Element { + const { slackChannels, slackChannelsLoading, isMemberOfSlackChannel } = useValues( + slackIntegrationLogic({ id: integration.id }) + ) + const { loadSlackChannels } = useActions(slackIntegrationLogic({ id: integration.id })) + + // If slackChannels aren't loaded, make sure we display only the channel name and not the actual underlying value + const slackChannelOptions = useMemo(() => getSlackChannelOptions(slackChannels), [slackChannels]) + const showSlackMembershipWarning = value && isMemberOfSlackChannel(value) === false + + // Sometimes the parent will only store the channel ID and not the name, so we need to handle that + + const modifiedValue = useMemo(() => { + if (value?.split('|').length === 1) { + const channel = slackChannels?.find((x) => x.id === value) + + if (channel) { + return `${channel.id}|#${channel.name}` + } + } + + return value + }, [value, slackChannels]) + + return ( + <> + onChange?.(val[0] ?? null)} + value={modifiedValue ? [modifiedValue] : []} + onFocus={() => !slackChannels && !slackChannelsLoading && loadSlackChannels()} + disabled={disabled} + mode="single" + data-attr="select-slack-channel" + placeholder="Select a channel..." + options={ + slackChannelOptions ?? + (modifiedValue + ? [ + { + key: modifiedValue, + label: modifiedValue?.split('|')[1] ?? modifiedValue, + }, + ] + : []) + } + loading={slackChannelsLoading} + /> + + {showSlackMembershipWarning ? ( + +
+ + The PostHog Slack App is not in this channel. Please add it to the channel otherwise + Subscriptions will fail to be delivered.{' '} + + See the Docs for more information + + + + Check again + +
+
+ ) : null} + + ) +} + +export function SlackIntegrationView({ + integration, + suffix, +}: { + integration: IntegrationType + suffix?: JSX.Element +}): JSX.Element { + return ( +
+
+ +
+
+ Connected to {integration.config.team.name} workspace +
+ {integration.created_by ? ( + + ) : null} +
+
+ + {suffix} +
+ ) +} diff --git a/frontend/src/scenes/settings/project/integrationsLogic.ts b/frontend/src/lib/integrations/integrationsLogic.ts similarity index 80% rename from frontend/src/scenes/settings/project/integrationsLogic.ts rename to frontend/src/lib/integrations/integrationsLogic.ts index 55c90ac929b79..464c3901dd8ef 100644 --- a/frontend/src/scenes/settings/project/integrationsLogic.ts +++ b/frontend/src/lib/integrations/integrationsLogic.ts @@ -6,18 +6,18 @@ import api from 'lib/api' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { urls } from 'scenes/urls' -import { IntegrationType, SlackChannelType } from '~/types' +import { IntegrationType } from '~/types' import type { integrationsLogicType } from './integrationsLogicType' // NOTE: Slack enforces HTTPS urls so to aid local dev we change to https so the redirect works. // Just means we have to change it back to http once redirected. -export const getSlackRedirectUri = (next: string = ''): string => +const getSlackRedirectUri = (next: string = ''): string => `${window.location.origin.replace('http://', 'https://')}/integrations/slack/redirect${ next ? '?next=' + encodeURIComponent(next) : '' }` -export const getSlackEventsUri = (): string => +const getSlackEventsUri = (): string => `${window.location.origin.replace('http://', 'https://')}/api/integrations/slack/events` // Modified version of https://app.slack.com/app-settings/TSS5W8YQZ/A03KWE2FJJ2/app-manifest to match current instance @@ -57,7 +57,7 @@ export const getSlackAppManifest = (): any => ({ }) export const integrationsLogic = kea([ - path(['scenes', 'project', 'Settings', 'integrationsLogic']), + path(['lib', 'integrations', 'integrationsLogic']), connect({ values: [preflightLogic, ['siteUrlMisconfigured', 'preflight']], }), @@ -67,7 +67,7 @@ export const integrationsLogic = kea([ deleteIntegration: (id: number) => ({ id }), }), - loaders(({ values }) => ({ + loaders(() => ({ integrations: [ null as IntegrationType[] | null, { @@ -77,20 +77,6 @@ export const integrationsLogic = kea([ }, }, ], - - slackChannels: [ - null as SlackChannelType[] | null, - { - loadSlackChannels: async () => { - if (!values.slackIntegration) { - return null - } - - const res = await api.integrations.slackChannels(values.slackIntegration.id) - return res.channels - }, - }, - ], })), listeners(({ actions }) => ({ handleRedirect: async ({ kind, searchParams }) => { @@ -142,27 +128,13 @@ export const integrationsLogic = kea([ }, })), selectors({ - slackIntegration: [ + slackIntegrations: [ (s) => [s.integrations], (integrations) => { - return integrations?.find((x) => x.kind == 'slack') + return integrations?.filter((x) => x.kind == 'slack') }, ], - isMemberOfSlackChannel: [ - (s) => [s.slackChannels], - (slackChannels) => { - return (channel: string) => { - if (!slackChannels) { - return null - } - - const [channelId] = channel.split('|') - - return slackChannels.find((x) => x.id === channelId)?.is_member - } - }, - ], addToSlackButtonUrl: [ (s) => [s.preflight], (preflight) => { diff --git a/frontend/src/lib/integrations/slackIntegrationLogic.ts b/frontend/src/lib/integrations/slackIntegrationLogic.ts new file mode 100644 index 0000000000000..729dc33378f41 --- /dev/null +++ b/frontend/src/lib/integrations/slackIntegrationLogic.ts @@ -0,0 +1,48 @@ +import { actions, connect, kea, key, path, props, selectors } from 'kea' +import { loaders } from 'kea-loaders' +import api from 'lib/api' +import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' + +import { SlackChannelType } from '~/types' + +import type { slackIntegrationLogicType } from './slackIntegrationLogicType' + +export const slackIntegrationLogic = kea([ + props({} as { id: number }), + key((props) => props.id), + path((key) => ['lib', 'integrations', 'slackIntegrationLogic', key]), + connect({ + values: [preflightLogic, ['siteUrlMisconfigured', 'preflight']], + }), + actions({ + loadSlackChannels: true, + }), + + loaders(({ props }) => ({ + slackChannels: [ + null as SlackChannelType[] | null, + { + loadSlackChannels: async () => { + const res = await api.integrations.slackChannels(props.id) + return res.channels + }, + }, + ], + })), + selectors({ + isMemberOfSlackChannel: [ + (s) => [s.slackChannels], + (slackChannels) => { + return (channel: string) => { + if (!slackChannels) { + return null + } + + const [channelId] = channel.split('|') + + return slackChannels.find((x) => x.id === channelId)?.is_member ?? false + } + }, + ], + }), +]) diff --git a/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx index 0bef3e097d6c7..c6c67170f851e 100644 --- a/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx +++ b/frontend/src/lib/lemon-ui/LemonInputSelect/LemonInputSelect.tsx @@ -33,6 +33,7 @@ export type LemonInputSelectProps = Pick< allowCustomValues?: boolean onChange?: (newValue: string[]) => void onBlur?: () => void + onFocus?: () => void onInputChange?: (newValue: string) => void 'data-attr'?: string popoverClassName?: string @@ -45,6 +46,7 @@ export function LemonInputSelect({ loading, onChange, onInputChange, + onFocus, onBlur, mode, disabled, @@ -189,6 +191,7 @@ export function LemonInputSelect({ } const _onFocus = (): void => { + onFocus?.() setShowPopover(true) popoverFocusRef.current = true } diff --git a/frontend/src/scenes/IntegrationsRedirect/IntegrationsRedirect.tsx b/frontend/src/scenes/IntegrationsRedirect/IntegrationsRedirect.tsx index 3a1cb65525eb6..b59afbff98a64 100644 --- a/frontend/src/scenes/IntegrationsRedirect/IntegrationsRedirect.tsx +++ b/frontend/src/scenes/IntegrationsRedirect/IntegrationsRedirect.tsx @@ -1,6 +1,6 @@ +import { integrationsLogic } from 'lib/integrations/integrationsLogic' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { SceneExport } from 'scenes/sceneTypes' -import { integrationsLogic } from 'scenes/settings/project/integrationsLogic' export const scene: SceneExport = { component: IntegrationsRedirect, diff --git a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx index 6961e09250b4f..92a068b7fb6be 100644 --- a/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx +++ b/frontend/src/scenes/pipeline/hogfunctions/HogFunctionInputs.tsx @@ -23,6 +23,8 @@ import { useEffect, useMemo, useState } from 'react' import { groupsModel } from '~/models/groupsModel' import { HogFunctionInputSchemaType } from '~/types' +import { HogFunctionInputIntegration } from './integrations/HogFunctionInputIntegration' +import { HogFunctionInputIntegrationField } from './integrations/HogFunctionInputIntegrationField' import { pipelineHogFunctionConfigurationLogic } from './pipelineHogFunctionConfigurationLogic' export type HogFunctionInputProps = { @@ -36,7 +38,7 @@ export type HogFunctionInputWithSchemaProps = { schema: HogFunctionInputSchemaType } -const typeList = ['string', 'boolean', 'dictionary', 'choice', 'json'] as const +const typeList = ['string', 'boolean', 'dictionary', 'choice', 'json', 'integration'] as const function useAutocompleteOptions(): languages.CompletionItem[] { const { groupTypes } = useValues(groupsModel) @@ -257,12 +259,14 @@ export function HogFunctionInputRenderer({ value, onChange, schema, disabled }: case 'boolean': return onChange?.(checked)} disabled={disabled} /> + case 'integration': + return + case 'integration_field': + return default: return ( Unknown field type "{schema.type}". -
- You may need to upgrade PostHog!
) } @@ -354,6 +358,17 @@ function HogFunctionInputSchemaControls({ value, onChange, onDone }: HogFunction )} + {value.type === 'integration' && ( + + _onChange({ integration })} + options={[{ label: 'Slack', value: 'slack' }]} + placeholder="Choose kind" + /> + + )} + void +} + +export type HogFunctionInputIntegrationProps = HogFunctionInputIntegrationConfigureProps & { + schema: HogFunctionInputSchemaType +} + +export function HogFunctionInputIntegration({ schema, ...props }: HogFunctionInputIntegrationProps): JSX.Element { + if (schema.integration === 'slack') { + return + } + return ( +
+

Unsupported integration type: {schema.integration}

+
+ ) +} + +export function HogFunctionIntegrationSlackConnection({ + onChange, + value, +}: HogFunctionInputIntegrationConfigureProps): JSX.Element { + const { integrationsLoading, slackIntegrations, addToSlackButtonUrl } = useValues(integrationsLogic) + + const integration = slackIntegrations?.find((integration) => integration.id === value) + + if (integrationsLoading) { + return + } + + const button = ( + ({ + icon: , + onClick: () => onChange?.(integration.id), + label: integration.config.team.name, + })) || []), + { + to: addToSlackButtonUrl(window.location.pathname + '?target_type=slack') || '', + label: 'Add to different Slack workspace', + }, + ]} + > + {integration ? ( + Change + ) : ( + Choose Slack connection + )} + + ) + + return <>{integration ? : button} +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx b/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx new file mode 100644 index 0000000000000..ca348c2f7fb2d --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/integrations/HogFunctionInputIntegrationField.tsx @@ -0,0 +1,62 @@ +import { LemonSkeleton } from '@posthog/lemon-ui' +import { useValues } from 'kea' +import { integrationsLogic } from 'lib/integrations/integrationsLogic' +import { SlackChannelPicker } from 'lib/integrations/SlackIntegrationHelpers' + +import { HogFunctionInputSchemaType } from '~/types' + +import { pipelineHogFunctionConfigurationLogic } from '../pipelineHogFunctionConfigurationLogic' + +export type HogFunctionInputIntegrationFieldProps = { + schema: HogFunctionInputSchemaType + value?: any + onChange?: (value: any) => void +} + +export function HogFunctionInputIntegrationField({ + schema, + value, + onChange, +}: HogFunctionInputIntegrationFieldProps): JSX.Element { + const { configuration } = useValues(pipelineHogFunctionConfigurationLogic) + const { integrationsLoading, integrations } = useValues(integrationsLogic) + + if (integrationsLoading) { + return + } + + const relatedSchemaIntegration = configuration.inputs_schema?.find((input) => input.key === schema.integration_key) + + if (!relatedSchemaIntegration) { + return ( +
+ Bad configuration: integration key {schema.integration_key} not found in schema +
+ ) + } + + const integrationId = configuration.inputs?.[relatedSchemaIntegration.key]?.value + const integration = integrations?.find((integration) => integration.id === integrationId) + + if (!integration) { + return ( +
+ Configure {relatedSchemaIntegration.label} to continue +
+ ) + } + if (schema.integration_field === 'slack_channel') { + return ( + onChange?.(x?.split('|')[0])} + integration={integration} + /> + ) + } + return ( +
+

Unsupported integration type: {schema.integration}

+
+ ) +} diff --git a/frontend/src/scenes/pipeline/hogfunctions/integrations/types.ts b/frontend/src/scenes/pipeline/hogfunctions/integrations/types.ts new file mode 100644 index 0000000000000..a44f2b301eca9 --- /dev/null +++ b/frontend/src/scenes/pipeline/hogfunctions/integrations/types.ts @@ -0,0 +1,10 @@ +import { HogFunctionInputSchemaType } from '~/types' + +export type HogFunctionInputIntegrationConfigureProps = { + value?: any + onChange?: (value: string | null) => void +} + +export type HogFunctionInputIntegrationProps = HogFunctionInputIntegrationConfigureProps & { + schema: HogFunctionInputSchemaType +} diff --git a/frontend/src/scenes/settings/project/SlackIntegration.tsx b/frontend/src/scenes/settings/project/SlackIntegration.tsx index 58c479c683267..31297bbd689ba 100644 --- a/frontend/src/scenes/settings/project/SlackIntegration.tsx +++ b/frontend/src/scenes/settings/project/SlackIntegration.tsx @@ -2,32 +2,30 @@ import { IconTrash } from '@posthog/icons' import { LemonButton, Link } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' -import { IconSlack } from 'lib/lemon-ui/icons' +import { getSlackAppManifest, integrationsLogic } from 'lib/integrations/integrationsLogic' +import { SlackIntegrationView } from 'lib/integrations/SlackIntegrationHelpers' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' import { useState } from 'react' import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' -import { getSlackAppManifest, integrationsLogic } from './integrationsLogic' - export function SlackIntegration(): JSX.Element { - const { slackIntegration, addToSlackButtonUrl } = useValues(integrationsLogic) + const { slackIntegrations, addToSlackButtonUrl } = useValues(integrationsLogic) const { deleteIntegration } = useActions(integrationsLogic) const [showSlackInstructions, setShowSlackInstructions] = useState(false) const { user } = useValues(userLogic) - const onDeleteClick = (): void => { + const onDeleteClick = (id: number): void => { LemonDialog.open({ title: `Do you want to disconnect from Slack?`, description: - 'This cannot be undone. PostHog resources configured to use Slack will remain but will stop working.', + 'This cannot be undone. PostHog resources configured to use this Slack workspace will remain but will stop working.', primaryButton: { children: 'Yes, disconnect', status: 'danger', onClick: () => { - if (slackIntegration?.id) { - deleteIntegration(slackIntegration.id) + if (id) { + deleteIntegration(id) } }, }, @@ -49,79 +47,75 @@ export function SlackIntegration(): JSX.Element { .

-
- {slackIntegration ? ( -
-
- -
-
- Connected to {slackIntegration.config.team.name} workspace -
- {slackIntegration.created_by ? ( - - ) : null} -
-
- - }> - Disconnect - -
- ) : addToSlackButtonUrl() ? ( - - Add to Slack - - ) : user?.is_staff ? ( - !showSlackInstructions ? ( - <> - setShowSlackInstructions(true)}> - Show Instructions +
+ {slackIntegrations?.map((integration) => ( + onDeleteClick(integration.id)} + icon={} + > + Disconnect - - ) : ( - <> -
To get started
-

-

    -
  1. Copy the below Slack App Template
  2. -
  3. - Go to{' '} - - Slack Apps - -
  4. -
  5. Create an App using the provided template
  6. -
  7. - Go to Instance Settings and update the{' '} - "SLACK_" properties using the values from the{' '} - App Credentials section of your Slack Apps -
  8. -
+ } + /> + ))} - - {JSON.stringify(getSlackAppManifest(), null, 2)} - -

- - ) - ) : ( -

- This PostHog instance is not configured for Slack. Please contact the instance owner to - configure it. -

- )} +
+ {addToSlackButtonUrl() ? ( + + Connect to Slack workspace + + ) : user?.is_staff ? ( + !showSlackInstructions ? ( + <> + setShowSlackInstructions(true)}> + Show Instructions + + + ) : ( + <> +
To get started
+

+

    +
  1. Copy the below Slack App Template
  2. +
  3. + Go to{' '} + + Slack Apps + +
  4. +
  5. Create an App using the provided template
  6. +
  7. + Go to Instance Settings and update + the "SLACK_" properties using the values from the{' '} + App Credentials section of your Slack Apps +
  8. +
+ + + {JSON.stringify(getSlackAppManifest(), null, 2)} + +

+ + ) + ) : ( +

+ This PostHog instance is not configured for Slack. Please contact the instance owner to + configure it. +

+ )} +
) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0d0888297d166..47bf9fa7227dc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4183,7 +4183,7 @@ export type OnboardingProduct = { } export type HogFunctionInputSchemaType = { - type: 'string' | 'boolean' | 'dictionary' | 'choice' | 'json' + type: 'string' | 'boolean' | 'dictionary' | 'choice' | 'json' | 'integration' | 'integration_field' key: string label: string choices?: { value: string; label: string }[] @@ -4191,6 +4191,9 @@ export type HogFunctionInputSchemaType = { default?: any secret?: boolean description?: string + integration?: string + integration_key?: string + integration_field?: 'slack_channel' } export type HogFunctionType = { diff --git a/plugin-server/src/cdp/cdp-consumers.ts b/plugin-server/src/cdp/cdp-consumers.ts index 2a8891807d794..efcda79763198 100644 --- a/plugin-server/src/cdp/cdp-consumers.ts +++ b/plugin-server/src/cdp/cdp-consumers.ts @@ -427,9 +427,15 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { } // We use the provided config if given, otherwise the function's config - const functionConfiguration: HogFunctionType = configuration ?? hogFunction + const compoundConfiguration: HogFunctionType = { + ...hogFunction, + ...(configuration ?? {}), + } + + // TODO: Type the configuration better so we don't make mistakes here + await this.hogFunctionManager.enrichWithIntegrations([compoundConfiguration]) - let response = this.hogExecutor.execute(functionConfiguration, invocation) + let response = this.hogExecutor.execute(compoundConfiguration, invocation) while (response.asyncFunctionRequest) { const asyncFunctionRequest = response.asyncFunctionRequest @@ -456,7 +462,7 @@ export class CdpFunctionCallbackConsumer extends CdpConsumerBase { // Clear it so we can't ever end up in a loop delete response.asyncFunctionRequest - response = this.hogExecutor.execute(functionConfiguration, response, asyncFunctionRequest.vmState) + response = this.hogExecutor.execute(compoundConfiguration, response, asyncFunctionRequest.vmState) } res.json({ diff --git a/plugin-server/src/cdp/hog-executor.ts b/plugin-server/src/cdp/hog-executor.ts index f86ed7aba528b..f9628f9960a61 100644 --- a/plugin-server/src/cdp/hog-executor.ts +++ b/plugin-server/src/cdp/hog-executor.ts @@ -307,7 +307,7 @@ export class HogExecutor { buildHogFunctionGlobals(hogFunction: HogFunctionType, invocation: HogFunctionInvocation): Record { const builtInputs: Record = {} - Object.entries(hogFunction.inputs).forEach(([key, item]) => { + Object.entries(hogFunction.inputs ?? {}).forEach(([key, item]) => { builtInputs[key] = item.value if (item.bytecode) { diff --git a/plugin-server/src/cdp/hog-function-manager.ts b/plugin-server/src/cdp/hog-function-manager.ts index 4adbec4ab81cc..b8522d1f388ed 100644 --- a/plugin-server/src/cdp/hog-function-manager.ts +++ b/plugin-server/src/cdp/hog-function-manager.ts @@ -4,11 +4,13 @@ import { PluginsServerConfig, Team } from '../types' import { PostgresRouter, PostgresUse } from '../utils/db/postgres' import { PubSub } from '../utils/pubsub' import { status } from '../utils/status' -import { HogFunctionType } from './types' +import { HogFunctionType, IntegrationType } from './types' export type HogFunctionMap = Record export type HogFunctionCache = Record +const HOG_FUNCTION_FIELDS = ['id', 'team_id', 'name', 'enabled', 'inputs', 'inputs_schema', 'filters', 'bytecode'] + export class HogFunctionManager { private started: boolean private ready: boolean @@ -67,13 +69,49 @@ export class HogFunctionManager { } public async reloadAllHogFunctions(): Promise { - this.cache = await fetchAllHogFunctionsGroupedByTeam(this.postgres) + const items = ( + await this.postgres.query( + PostgresUse.COMMON_READ, + ` + SELECT ${HOG_FUNCTION_FIELDS.join(', ')} + FROM posthog_hogfunction + WHERE deleted = FALSE AND enabled = TRUE + `, + [], + 'fetchAllHogFunctions' + ) + ).rows + + await this.enrichWithIntegrations(items) + + const cache: HogFunctionCache = {} + for (const item of items) { + if (!cache[item.team_id]) { + cache[item.team_id] = {} + } + + cache[item.team_id][item.id] = item + } + + this.cache = cache status.info('🍿', 'Fetched all hog functions from DB anew') } public async reloadHogFunctions(teamId: Team['id'], ids: HogFunctionType['id'][]): Promise { status.info('🍿', `Reloading hog functions ${ids} from DB`) - const items = await fetchEnabledHogFunctions(this.postgres, ids) + + const items: HogFunctionType[] = ( + await this.postgres.query( + PostgresUse.COMMON_READ, + `SELECT ${HOG_FUNCTION_FIELDS.join(', ')} + FROM posthog_hogfunction + WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE`, + [ids], + 'fetchEnabledHogFunctions' + ) + ).rows + + await this.enrichWithIntegrations(items) if (!this.cache[teamId]) { this.cache[teamId] = {} @@ -89,66 +127,77 @@ export class HogFunctionManager { } } - public fetchHogFunction(id: HogFunctionType['id']): Promise { - return fetchHogFunction(this.postgres, id) + public async fetchHogFunction(id: HogFunctionType['id']): Promise { + const items: HogFunctionType[] = ( + await this.postgres.query( + PostgresUse.COMMON_READ, + `SELECT ${HOG_FUNCTION_FIELDS.join(', ')} + FROM posthog_hogfunction + WHERE id = $1 AND deleted = FALSE`, + [id], + 'fetchHogFunction' + ) + ).rows + await this.enrichWithIntegrations(items) + return items[0] ?? null } -} - -const HOG_FUNCTION_FIELDS = ['id', 'team_id', 'name', 'enabled', 'inputs', 'filters', 'bytecode'] -async function fetchAllHogFunctionsGroupedByTeam(client: PostgresRouter): Promise { - const items = ( - await client.query( - PostgresUse.COMMON_READ, - ` - SELECT ${HOG_FUNCTION_FIELDS.join(', ')} - FROM posthog_hogfunction - WHERE deleted = FALSE AND enabled = TRUE - `, - [], - 'fetchAllHogFunctions' - ) - ).rows + public async enrichWithIntegrations(items: HogFunctionType[]): Promise { + const integrationIds: number[] = [] + + items.forEach((item) => { + item.inputs_schema?.forEach((schema) => { + if (schema.type === 'integration') { + const input = item.inputs?.[schema.key] + if (input && typeof input.value === 'number') { + integrationIds.push(input.value) + } + } + }) + }) - const cache: HogFunctionCache = {} - for (const item of items) { - if (!cache[item.team_id]) { - cache[item.team_id] = {} + if (!items.length) { + return } - cache[item.team_id][item.id] = item - } - - return cache -} - -async function fetchEnabledHogFunctions( - client: PostgresRouter, - ids: HogFunctionType['id'][] -): Promise { - const items: HogFunctionType[] = ( - await client.query( - PostgresUse.COMMON_READ, - `SELECT ${HOG_FUNCTION_FIELDS.join(', ')} - FROM posthog_hogfunction - WHERE id = ANY($1) AND deleted = FALSE AND enabled = TRUE`, - [ids], - 'fetchEnabledHogFunctions' + const integrations: IntegrationType[] = ( + await this.postgres.query( + PostgresUse.COMMON_READ, + `SELECT id, team_id, kind, config, sensitive_config + FROM posthog_integration + WHERE id = ANY($1)`, + [integrationIds], + 'fetchIntegrations' + ) + ).rows + + const integrationConfigsByTeamAndId: Record> = integrations.reduce( + (acc, integration) => { + return { + ...acc, + [`${integration.team_id}:${integration.id}`]: { + ...integration.config, + ...integration.sensitive_config, + }, + } + }, + {} ) - ).rows - return items -} -async function fetchHogFunction(client: PostgresRouter, id: HogFunctionType['id']): Promise { - const items: HogFunctionType[] = ( - await client.query( - PostgresUse.COMMON_READ, - `SELECT ${HOG_FUNCTION_FIELDS.join(', ')} - FROM posthog_hogfunction - WHERE id = $1 AND deleted = FALSE`, - [id], - 'fetchHogFunction' - ) - ).rows - return items[0] ?? null + items.forEach((item) => { + item.inputs_schema?.forEach((schema) => { + if (schema.type === 'integration') { + const input = item.inputs?.[schema.key] + if (!input) { + return + } + const integrationId = input.value + const integrationConfig = integrationConfigsByTeamAndId[`${item.team_id}:${integrationId}`] + if (integrationConfig) { + input.value = integrationConfig + } + } + }) + }) + } } diff --git a/plugin-server/src/cdp/types.ts b/plugin-server/src/cdp/types.ts index 83c30a4344d14..f961229d966fe 100644 --- a/plugin-server/src/cdp/types.ts +++ b/plugin-server/src/cdp/types.ts @@ -182,7 +182,7 @@ export type HogFunctionMessageToQueue = { // Mostly copied from frontend types export type HogFunctionInputSchemaType = { - type: 'string' | 'number' | 'boolean' | 'dictionary' | 'choice' | 'json' + type: 'string' | 'boolean' | 'dictionary' | 'choice' | 'json' | 'integration' | 'integration_field' key: string label?: string choices?: { value: string; label: string }[] @@ -190,6 +190,9 @@ export type HogFunctionInputSchemaType = { default?: any secret?: boolean description?: string + integration?: string + integration_key?: string + integration_field?: 'slack_channel' } export type HogFunctionType = { @@ -199,13 +202,25 @@ export type HogFunctionType = { enabled: boolean hog: string bytecode: HogBytecode - inputs_schema: HogFunctionInputSchemaType[] - inputs: Record< - string, - { - value: any - bytecode?: HogBytecode | object - } - > + inputs_schema?: HogFunctionInputSchemaType[] + inputs?: Record filters?: HogFunctionFilters | null } + +export type HogFunctionInputType = { + value: any + bytecode?: HogBytecode | object +} + +export type IntegrationType = { + id: number + team_id: number + kind: 'slack' + config: Record + sensitive_config: Record + + // Fields we don't load but need for seeding data + errors?: string + created_at?: string + created_by_id?: number +} diff --git a/plugin-server/tests/cdp/fixtures.ts b/plugin-server/tests/cdp/fixtures.ts index 9147d043a7468..b70af8efe3c39 100644 --- a/plugin-server/tests/cdp/fixtures.ts +++ b/plugin-server/tests/cdp/fixtures.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto' import { Message } from 'node-rdkafka' -import { HogFunctionInvocationGlobals, HogFunctionType } from '../../src/cdp/types' +import { HogFunctionInvocationGlobals, HogFunctionType, IntegrationType } from '../../src/cdp/types' import { ClickHouseTimestamp, RawClickHouseEvent, Team } from '../../src/types' import { PostgresRouter } from '../../src/utils/db/postgres' import { insertRow } from '../helpers/sql' @@ -23,6 +23,18 @@ export const createHogFunction = (hogFunction: Partial) => { return item } +export const createIntegration = (integration: Partial) => { + const item: IntegrationType = { + team_id: 1, + errors: '', + created_at: new Date().toISOString(), + created_by_id: 1001, + ...integration, + } + + return item +} + export const createIncomingEvent = (teamId: number, data: Partial): RawClickHouseEvent => { return { team_id: teamId, @@ -67,6 +79,22 @@ export const insertHogFunction = async ( return res } +export const insertIntegration = async ( + postgres: PostgresRouter, + team_id: Team['id'], + integration: Partial = {} +): Promise => { + const res = await insertRow( + postgres, + 'posthog_integration', + createIntegration({ + ...integration, + team_id: team_id, + }) + ) + return res +} + export const createHogExecutionGlobals = ( data: Partial = {} ): HogFunctionInvocationGlobals => { diff --git a/plugin-server/tests/cdp/hog-function-manager.test.ts b/plugin-server/tests/cdp/hog-function-manager.test.ts index a3245b4ef97c1..05bb8debea75c 100644 --- a/plugin-server/tests/cdp/hog-function-manager.test.ts +++ b/plugin-server/tests/cdp/hog-function-manager.test.ts @@ -1,10 +1,10 @@ import { HogFunctionManager } from '../../src/cdp/hog-function-manager' -import { HogFunctionType } from '../../src/cdp/types' +import { HogFunctionType, IntegrationType } from '../../src/cdp/types' import { Hub } from '../../src/types' import { createHub } from '../../src/utils/db/hub' import { PostgresUse } from '../../src/utils/db/postgres' import { createTeam, resetTestDatabase } from '../helpers/sql' -import { insertHogFunction } from './fixtures' +import { insertHogFunction, insertIntegration } from './fixtures' describe('HogFunctionManager', () => { let hub: Hub @@ -12,6 +12,7 @@ describe('HogFunctionManager', () => { let manager: HogFunctionManager let hogFunctions: HogFunctionType[] + let integrations: IntegrationType[] let teamId1: number let teamId2: number @@ -27,15 +28,53 @@ describe('HogFunctionManager', () => { teamId2 = await createTeam(hub.db.postgres, team!.organization_id) hogFunctions = [] + integrations = [] + + integrations.push( + await insertIntegration(hub.postgres, teamId1, { + kind: 'slack', + config: { team: 'foobar' }, + sensitive_config: { access_token: 'token' }, + }) + ) + hogFunctions.push( await insertHogFunction(hub.postgres, teamId1, { name: 'Test Hog Function team 1', + inputs_schema: [ + { + type: 'integration', + key: 'slack', + }, + ], + inputs: { + slack: { + value: integrations[0].id, + }, + normal: { + value: integrations[0].id, + }, + }, }) ) hogFunctions.push( await insertHogFunction(hub.postgres, teamId2, { name: 'Test Hog Function team 2', + inputs_schema: [ + { + type: 'integration', + key: 'slack', + }, + ], + inputs: { + slack: { + value: integrations[0].id, + }, + normal: { + value: integrations[0].id, + }, + }, }) ) @@ -55,9 +94,25 @@ describe('HogFunctionManager', () => { team_id: teamId1, name: 'Test Hog Function team 1', enabled: true, - inputs: null, bytecode: null, filters: null, + inputs_schema: [ + { + key: 'slack', + type: 'integration', + }, + ], + inputs: { + slack: { + value: { + access_token: 'token', + team: 'foobar', + }, + }, + normal: { + value: integrations[0].id, + }, + }, }, }) @@ -98,4 +153,31 @@ describe('HogFunctionManager', () => { expect(functionsMap).not.toHaveProperty(hogFunctions[0].id) }) + + it('enriches integration inputs if found and belonging to the team', () => { + const function1Inputs = manager.getTeamHogFunctions(teamId1)[hogFunctions[0].id].inputs + const function2Inputs = manager.getTeamHogFunctions(teamId2)[hogFunctions[1].id].inputs + + // Only the right team gets the integration inputs enriched + expect(function1Inputs).toEqual({ + slack: { + value: { + access_token: 'token', + team: 'foobar', + }, + }, + normal: { + value: integrations[0].id, + }, + }) + + expect(function2Inputs).toEqual({ + slack: { + value: integrations[0].id, + }, + normal: { + value: integrations[0].id, + }, + }) + }) }) diff --git a/posthog/cdp/templates/slack/template_slack.py b/posthog/cdp/templates/slack/template_slack.py index 69851527577ff..d00cf01c8a748 100644 --- a/posthog/cdp/templates/slack/template_slack.py +++ b/posthog/cdp/templates/slack/template_slack.py @@ -1,65 +1,89 @@ from posthog.cdp.templates.hog_function_template import HogFunctionTemplate -# NOTE: Slack template is essentially just a webhook template with limited options - template: HogFunctionTemplate = HogFunctionTemplate( status="beta", id="template-slack", - name="Slack webhook", - description="Sends a webhook templated by the incoming event data", + name="Post a Slack message", + description="Sends a message to a slack channel", icon_url="/api/projects/@current/hog_functions/icon/?id=slack.com", hog=""" -fetch(inputs.url, { - 'body': inputs.body, +let res := fetch('https://slack.com/api/chat.postMessage', { + 'body': { + 'channel': inputs.channel, + 'icon_emoji': inputs.icon_emoji, + 'username': inputs.username, + 'blocks': inputs.blocks, + 'text': inputs.text + }, 'method': 'POST', 'headers': { + 'Authorization': f'Bearer {inputs.slack_workspace.access_token}', 'Content-Type': 'application/json' } }); + +if (res.status != 200 or not res.body.ok) { + print('Non-ok response:', res) +} """.strip(), inputs_schema=[ { - "key": "url", - "type": "string", - "label": "Slack webhook URL", - "description": "Create a slack webhook URL in your (see https://api.slack.com/messaging/webhooks)", - "placeholder": "https://hooks.slack.com/services/XXX/YYY", + "key": "slack_workspace", + "type": "integration", + "integration": "slack", + "label": "Slack workspace", + "secret": False, + "required": True, + }, + { + "key": "channel", + "type": "integration_field", + "integration_key": "slack_workspace", + "integration_field": "slack_channel", + "label": "Channel to post to", + "description": "Select the channel to post to (e.g. #general). The PostHog app must be installed in the workspace.", "secret": False, "required": True, }, + {"key": "icon_emoji", "type": "string", "label": "Emoji icon", "default": ":hedgehog:", "required": False}, + {"key": "username", "type": "string", "label": "Bot name", "defaukt": "PostHog", "required": False}, { - "key": "body", + "key": "blocks", "type": "json", - "label": "Message", - "description": "Message to send to Slack (see https://api.slack.com/block-kit/building)", - "default": { - "blocks": [ - { - "text": { - "text": "*{person.name}* triggered event: '{event.name}'", - "type": "mrkdwn", - }, - "type": "section", - }, - { - "type": "actions", - "elements": [ - { - "url": "{person.url}", - "text": {"text": "View Person in PostHog", "type": "plain_text"}, - "type": "button", - }, - { - "url": "{source.url}", - "text": {"text": "Message source", "type": "plain_text"}, - "type": "button", - }, - ], + "label": "Blocks", + "description": "(see https://api.slack.com/block-kit/building)", + "default": [ + { + "text": { + "text": "*{person.name}* triggered event: '{event.name}'", + "type": "mrkdwn", }, - ] - }, + "type": "section", + }, + { + "type": "actions", + "elements": [ + { + "url": "{person.url}", + "text": {"text": "View Person in PostHog", "type": "plain_text"}, + "type": "button", + }, + { + "url": "{source.url}", + "text": {"text": "Message source", "type": "plain_text"}, + "type": "button", + }, + ], + }, + ], "secret": False, "required": False, }, + { + "key": "text", + "type": "string", + "label": "Plain text message", + "description": "Optional fallback message if blocks are not provided or supported", + }, ], ) diff --git a/posthog/cdp/templates/slack/test_template_slack.py b/posthog/cdp/templates/slack/test_template_slack.py index 15d3f17804b18..9b8bab8cd1952 100644 --- a/posthog/cdp/templates/slack/test_template_slack.py +++ b/posthog/cdp/templates/slack/test_template_slack.py @@ -5,25 +5,51 @@ class TestTemplateSlack(BaseHogFunctionTemplateTest): template = template_slack + def _inputs(self, **kwargs): + inputs = { + "slack_workspace": { + "access_token": "xoxb-1234", + }, + "icon_emoji": ":hedgehog:", + "username": "PostHog", + "channel": "channel", + "blocks": [], + } + inputs.update(kwargs) + return inputs + def test_function_works(self): - res = self.run_function( - inputs={ - "url": "https://webhooks.slack.com/1234", - "body": { - "blocks": [], - }, - } - ) + self.mock_fetch_response = lambda *args: {"status": 200, "body": {"ok": True}} # type: ignore + res = self.run_function(self._inputs()) assert res.result is None assert self.get_mock_fetch_calls()[0] == ( - "https://webhooks.slack.com/1234", + "https://slack.com/api/chat.postMessage", { + "body": { + "channel": "channel", + "icon_emoji": ":hedgehog:", + "username": "PostHog", + "blocks": [], + "text": None, + }, + "method": "POST", "headers": { + "Authorization": "Bearer xoxb-1234", "Content-Type": "application/json", }, - "body": {"blocks": []}, - "method": "POST", }, ) + + assert self.get_mock_print_calls() == [] + + def test_function_prints_warning_on_bad_status(self): + self.mock_fetch_response = lambda *args: {"status": 400, "body": {"ok": True}} # type: ignore + self.run_function(self._inputs()) + assert self.get_mock_print_calls() == [("Non-ok response:", {"status": 400, "body": {"ok": True}})] + + def test_function_prints_warning_on_bad_body(self): + self.mock_fetch_response = lambda *args: {"status": 200, "body": {"ok": False}} # type: ignore + self.run_function(self._inputs()) + assert self.get_mock_print_calls() == [("Non-ok response:", {"status": 200, "body": {"ok": False}})] diff --git a/posthog/cdp/validation.py b/posthog/cdp/validation.py index 4a38523947de7..48944fa2ba188 100644 --- a/posthog/cdp/validation.py +++ b/posthog/cdp/validation.py @@ -24,7 +24,9 @@ def generate_template_bytecode(obj: Any) -> Any: class InputsSchemaItemSerializer(serializers.Serializer): - type = serializers.ChoiceField(choices=["string", "boolean", "dictionary", "choice", "json"]) + type = serializers.ChoiceField( + choices=["string", "boolean", "dictionary", "choice", "json", "integration", "integration_field"] + ) key = serializers.CharField() label = serializers.CharField(required=False) # type: ignore choices = serializers.ListField(child=serializers.DictField(), required=False) @@ -32,6 +34,9 @@ class InputsSchemaItemSerializer(serializers.Serializer): default = serializers.JSONField(required=False) secret = serializers.BooleanField(default=False) description = serializers.CharField(required=False) + integration = serializers.CharField(required=False) + integration_key = serializers.CharField(required=False) + integration_field = serializers.ChoiceField(choices=["slack_channel"], required=False) # TODO Validate choices if type=choice @@ -71,6 +76,9 @@ def validate(self, attrs): elif item_type == "dictionary": if not isinstance(value, dict): raise serializers.ValidationError({"inputs": {name: f"Value must be a dictionary."}}) + elif item_type == "integration": + if not isinstance(value, int): + raise serializers.ValidationError({"inputs": {name: f"Value must be an Integration ID."}}) try: if value: