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() ? (
-
-
-
- ) : user?.is_staff ? (
- !showSlackInstructions ? (
- <>
-
setShowSlackInstructions(true)}>
- Show Instructions
+
+ {slackIntegrations?.map((integration) => (
+
onDeleteClick(integration.id)}
+ icon={}
+ >
+ Disconnect
- >
- ) : (
- <>
- To get started
-
-
- - Copy the below Slack App Template
- -
- Go to{' '}
-
- Slack Apps
-
-
- - Create an App using the provided template
- -
- Go to Instance Settings and update the{' '}
-
"SLACK_"
properties using the values from the{' '}
- App Credentials section of your Slack Apps
-
-
+ }
+ />
+ ))}
-
- {JSON.stringify(getSlackAppManifest(), null, 2)}
-
-
- >
- )
- ) : (
-
- This PostHog instance is not configured for Slack. Please contact the instance owner to
- configure it.
-
- )}
+
+ {addToSlackButtonUrl() ? (
+
+
+
+ ) : user?.is_staff ? (
+ !showSlackInstructions ? (
+ <>
+
setShowSlackInstructions(true)}>
+ Show Instructions
+
+ >
+ ) : (
+ <>
+
To get started
+
+
+ - Copy the below Slack App Template
+ -
+ Go to{' '}
+
+ Slack Apps
+
+
+ - Create an App using the provided template
+ -
+ Go to Instance Settings and update
+ the
"SLACK_"
properties using the values from the{' '}
+ App Credentials section of your Slack Apps
+
+
+
+
+ {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: