diff --git a/src/v0/sources/slack/mapping.json b/src/v0/sources/slack/mapping.json new file mode 100644 index 0000000000..f7825bd88d --- /dev/null +++ b/src/v0/sources/slack/mapping.json @@ -0,0 +1,50 @@ +[ + { + "sourceKeys": "event.type", + "destKeys": "event" + }, + { + "sourceKeys": "event.user.tz", + "destKeys": "timezone" + }, + { + "sourceKeys": "event.user.profile.email", + "destKeys": "context.traits.email" + }, + { + "sourceKeys": "event.user.profile.phone", + "destKeys": "context.traits.phone" + }, + { + "sourceKeys": "event.user.profile.real_name_normalized", + "destKeys": "context.traits.name" + }, + { + "sourceKeys": "event.user.profile.real_name", + "destKeys": "context.traits.name" + }, + { + "sourceKeys": "event.user.profile.display_name_normalized", + "destKeys": "context.traits.name" + }, + { + "sourceKeys": "event.user.profile.display_name", + "destKeys": "context.traits.name" + }, + { + "sourceKeys": "event.user.profile.first_name", + "destKeys": "context.traits.firstName" + }, + { + "sourceKeys": "event.user.profile.last_name", + "destKeys": "context.traits.lastName" + }, + { + "sourceKeys": "event.user.profile.image_original", + "destKeys": "context.traits.avatar" + }, + { + "sourceKeys": "event.user.profile.title", + "destKeys": "context.traits.title" + } +] diff --git a/src/v0/sources/slack/transform.js b/src/v0/sources/slack/transform.js new file mode 100644 index 0000000000..98324a7b65 --- /dev/null +++ b/src/v0/sources/slack/transform.js @@ -0,0 +1,110 @@ +const sha256 = require('sha256'); +const { TransformationError } = require('@rudderstack/integrations-lib'); +const Message = require('../message'); +const { mapping, tsToISODate, normalizeEventName } = require('./util'); +const { generateUUID, removeUndefinedAndNullValues } = require('../../util'); +const { JSON_MIME_TYPE } = require('../../util/constant'); +const { EventType } = require('../../../constants'); + +/** + * Transform event data to RudderStack supported standard event schema + * @param {Object} slackPayload - The complete data received on the webhook from Slack + * @param {Object} slackPayload.event - The data object specific to the Slack event received. Has different schema for different event types. + * @returns {Object} Event data transformed to RudderStack supported standard event schema + */ +function processNormalEvent(slackPayload) { + const message = new Message(`SLACK`); + if (!slackPayload?.event) { + throw new TransformationError('Missing the required event data'); + } + switch (slackPayload.event.type) { + case 'team_join': + message.setEventType(EventType.IDENTIFY); + break; + case 'user_change': + message.setEventType(EventType.IDENTIFY); + break; + default: + message.setEventType(EventType.TRACK); + break; + } + message.setEventName(normalizeEventName(slackPayload.event.type)); + if (!slackPayload.event.user) { + throw new TransformationError('UserId not found'); + } + const stringifiedUserId = + typeof slackPayload.event.user === 'object' + ? slackPayload.event.user.id + : slackPayload.event.user; + message.setProperty( + 'anonymousId', + stringifiedUserId ? sha256(stringifiedUserId).toString().substring(0, 36) : generateUUID(), + ); + // Set the user id received from Slack into externalId + message.context.externalId = [ + { + type: 'slackUserId', + id: stringifiedUserId, + }, + ]; + // Set the standard common event fields. More info at https://www.rudderstack.com/docs/event-spec/standard-events/common-fields/ + // originalTimestamp - The actual time (in UTC) when the event occurred + message.setProperty( + 'originalTimestamp', + tsToISODate(slackPayload.event.ts || slackPayload.event.event_ts || slackPayload.event_time), + ); + // sentAt - Time, client-side, when the event was sent from the client to RudderStack + message.setProperty('sentAt', tsToISODate(slackPayload.event_time)); + // Map the remaining standard event properties according to mappings for the payload properties + message.setPropertiesV2(slackPayload, mapping); + // Copy the complete Slack event payload to message.properties + if (!message.properties) message.properties = {}; + Object.assign(message.properties, slackPayload.event); + return message; +} + +/** + * Handles a special event for webhook url verification. + * Responds back with the challenge key received in the request. + * Reference - https://api.slack.com/apis/connections/events-api#subscribing + * @param {Object} event - Event data received from Slack + * @param {string} event.challenge - The challenge key received in the request + * @returns response that needs to be sent back to the source, alongwith the same challenge key received int the request + */ +function processUrlVerificationEvent(event) { + const response = { challenge: event?.challenge }; + return { + outputToSource: { + body: Buffer.from(JSON.stringify(response)).toString('base64'), + contentType: JSON_MIME_TYPE, + }, + statusCode: 200, + }; +} + +/** + * Checks if the event is a special url verification event or not. + * Slack sends this event at the time of webhook setup to verify webhook url ownership for the security purpose. + * Reference - https://api.slack.com/apis/connections/events-api#subscribing + * @param {Object} event - Event data received from Slack + * @param {string} event.challenge - The challenge key received in the request + * @param {string} event.type - The type of Slack event. `url_verification` when it is a special webhook url verification event. + * @returns {boolean} true if it is a valid challenge event for url verification event + */ +function isWebhookUrlVerificationEvent(event) { + return event?.type === 'url_verification' && !!event?.challenge; +} + +/** + * Processes the event with needed transformation and sends back the response + * Reference - https://api.slack.com/apis/connections/events-api + * @param {Object} event + */ +function process(event) { + const response = isWebhookUrlVerificationEvent(event) + ? processUrlVerificationEvent(event) + : processNormalEvent(event); + return removeUndefinedAndNullValues(response); +} + +exports.process = process; diff --git a/src/v0/sources/slack/util.js b/src/v0/sources/slack/util.js new file mode 100644 index 0000000000..b9c39db223 --- /dev/null +++ b/src/v0/sources/slack/util.js @@ -0,0 +1,62 @@ +/* eslint-disable no-restricted-syntax */ +const path = require('path'); +const fs = require('fs'); + +const mapping = JSON.parse(fs.readFileSync(path.resolve(__dirname, './mapping.json'), 'utf-8')); + +/** + * Converts a Slack timestamp to RudderStack's standard timestamp format - ISO 8601 date string. + * The Slack timestamp is a string that represents unix timestamp (seconds since the Unix Epoch) + * with fractional seconds for millisecond precision. + * If the timestamp is not provided, the function returns the current date and time in ISO 8601 format. + * + * @param {string} [slackTs] - The Slack timestamp to be converted. + * @returns {string} The ISO 8601 formatted date string corresponding to the given Slack timestamp + * or the current date and time if no timestamp is provided. + * + * @example + * // Convert a Slack timestamp to an ISO 8601 date string + * const slackTimestamp = "1609459200.123000"; + * const isoDate = tsToISODate(slackTimestamp); + * console.log(isoDate); // Output: "2021-01-01T00:00:00.123Z" (depending on your timezone) + */ +function tsToISODate(slackTs) { + // Default to current date if slackTs is not provided + if (!slackTs) return new Date().toISOString(); + + // Convert slackTs string into unix timestamp in milliseconds + const msTimestamp = parseFloat(slackTs) * 1000; + // Convert to a date object + if (Number.isNaN(msTimestamp)) { + // If timestamp was not a valid float, the parser will return NaN, stop processing the timestamp further and return null + return null; + } + const date = new Date(msTimestamp); + + // Return the date in ISO 8601 format + return date.toISOString(); +} + +/** + * Converts an event name from snake_case to a RudderStack format - space-separated string with each word capitalized. + * @param {string} evtName - The event name in snake_case format to be normalized. + * @returns {string} The normalized event name with spaces between words and each word capitalized. + * + * @example + * // Convert a slack event name to RudderStack format + * const eventName = "member_joined_channel"; + * const normalizedEventName = normalizeEventName(eventName); + * console.log(normalizedEventName); // Output: "Member Joined Channel" + */ +function normalizeEventName(evtName) { + try { + return evtName + .split('_') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); + } catch (e) { + return 'undefined'; + } +} + +module.exports = { mapping, tsToISODate, normalizeEventName }; diff --git a/src/v0/sources/slack/util.test.js b/src/v0/sources/slack/util.test.js new file mode 100644 index 0000000000..b83f22f058 --- /dev/null +++ b/src/v0/sources/slack/util.test.js @@ -0,0 +1,51 @@ +const { tsToISODate, normalizeEventName } = require('./util.js'); + +describe('Unit test cases for tsToISODate', () => { + it('should return a valid iso date string for a valid slack timestamp input', () => { + const result = tsToISODate('1609459200.123000'); + expect(result).toBe('2021-01-01T00:00:00.123Z'); + }); + + it('should return iso date string of today when slack timestamp argument is not provided', () => { + const result = tsToISODate(); + expect(result).not.toBeNull(); + expect(typeof result).toBe('string'); + expect(result).not.toHaveLength(0); + // Check if the result is a valid date + const dateObject = new Date(result); + const resultTime = dateObject.getTime(); + expect(resultTime).not.toBeNaN(); + // Check if the result is close to the current time with precision tolerance of upto a minute + const nowTime = new Date().getTime(); + const TOLERANCE = 60000; // In ms + const timeDiff = Math.abs(nowTime - resultTime); + expect(timeDiff).toBeLessThanOrEqual(TOLERANCE); + }); + + it('should return null if the slack timestamp argument is invalid', () => { + const result = tsToISODate('invalid.slack.timestamp'); + expect(result).toBeNull(); + }); +}); + +describe('Unit test cases for normalizeEventName', () => { + it('should normalize a valid snake case string "member_joined_channel" to RudderStack format "Member Joined Channel"', () => { + const result = normalizeEventName('member_joined_channel'); + expect(result).toBe('Member Joined Channel'); + }); + + it('should return undefined string when event name is undefined', () => { + const result = normalizeEventName(undefined); + expect(result).toBe('undefined'); + }); + + it('should return undefined string when event name is null', () => { + const result = normalizeEventName(null); + expect(result).toBe('undefined'); + }); + + it('should return undefined string when event name argument cannot be parsed to string', () => { + const result = normalizeEventName({}); + expect(result).toBe('undefined'); + }); +}); diff --git a/test/integrations/sources/slack/data.ts b/test/integrations/sources/slack/data.ts new file mode 100644 index 0000000000..def8a63408 --- /dev/null +++ b/test/integrations/sources/slack/data.ts @@ -0,0 +1,282 @@ +export const data = [ + { + name: 'slack', + description: 'Webhook url verificatin event', + module: 'source', + version: 'v0', + input: { + request: { + body: [ + { + token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl', + challenge: '3eZbrw1aB10FEMAGAZd4FyFQ', + type: 'url_verification', + }, + ], + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + pathSuffix: '', + }, + output: { + response: { + status: 200, + body: [ + { + outputToSource: { + body: 'eyJjaGFsbGVuZ2UiOiIzZVpicncxYUIxMEZFTUFHQVpkNEZ5RlEifQ==', + contentType: 'application/json', + }, + statusCode: 200, + }, + ], + }, + }, + }, + { + name: 'slack', + description: 'Team joined event', + module: 'source', + version: 'v0', + input: { + request: { + body: [ + { + event: { + type: 'team_join', + user: { + id: 'W012CDE', + name: 'johnd', + real_name: 'John Doe', + }, + }, + type: 'event_callback', + event_id: 'Ev06TJ0NG5', + event_time: 1709441309, + token: 'REm276ggfh72Lq', + team_id: 'T0GFJL5J7', + context_team_id: 'T0GFJL5J7', + context_enterprise_id: null, + api_app_id: 'B02SJMHRR', + authorizations: [ + { + enterprise_id: null, + team_id: 'T0GFJL5J7', + user_id: 'U04G7H550', + is_bot: true, + is_enterprise_install: false, + }, + ], + is_ext_shared_channel: false, + event_context: 'eJldCI65436EUEpMSFhgfhg76joiQzAxRTRQTEIxMzUifQ', + }, + ], + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + pathSuffix: '', + }, + output: { + response: { + status: 200, + body: [ + { + output: { + batch: [ + { + context: { + library: { + name: 'unknown', + version: 'unknown', + }, + integration: { + name: 'SLACK', + }, + externalId: [ + { + type: 'slackUserId', + id: 'W012CDE', + }, + ], + }, + integrations: { + SLACK: false, + }, + type: 'identify', + event: 'Team Join', + anonymousId: '2bc5ae2825a712d3d154cbdacb86ac16c278', + originalTimestamp: '2024-03-03T04:48:29.000Z', + sentAt: '2024-03-03T04:48:29.000Z', + properties: { + type: 'team_join', + user: { + id: 'W012CDE', + name: 'johnd', + real_name: 'John Doe', + }, + }, + }, + ], + }, + }, + ], + }, + }, + }, + { + name: 'slack', + description: 'Message event', + module: 'source', + version: 'v0', + input: { + request: { + body: [ + { + event: { + user: 'U04G7H550', + type: 'message', + ts: '1709441309.308399', + client_msg_id: '834r664e-ec75-445d-t5c6-b873a07y9c81', + text: 'What is the pricing of product X', + team: 'T0GFJL5J7', + thread_ts: '1709407304.839329', + parent_user_id: 'U06P6LQTPV', + blocks: [ + { + type: 'rich_text', + block_id: 'xGKJl', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'What is the pricing of product X', + }, + { + type: 'channel', + channel_id: 'C03CDQTPI65', + }, + { + type: 'text', + text: ' to do this', + }, + ], + }, + ], + }, + ], + channel: 'C03CDQTPI65', + event_ts: '1709441309.308399', + channel_type: 'channel', + }, + type: 'event_callback', + event_id: 'EvY5JTJ0NG5', + event_time: 1709441309, + token: 'REm2987dqtpi72Lq', + team_id: 'T0GFJL5J7', + context_team_id: 'T01gqtPIL5J7', + context_enterprise_id: null, + api_app_id: 'A04QTPIHRR', + authorizations: [ + { + enterprise_id: null, + team_id: 'T0GFJL5J7', + user_id: 'W012CDE', + is_bot: true, + is_enterprise_install: false, + }, + ], + is_ext_shared_channel: false, + event_context: '4-wd6joiQfdgTRQTpIzdfifQ', + }, + ], + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, + pathSuffix: '', + }, + output: { + response: { + status: 200, + body: [ + { + output: { + batch: [ + { + context: { + library: { + name: 'unknown', + version: 'unknown', + }, + integration: { + name: 'SLACK', + }, + externalId: [ + { + type: 'slackUserId', + id: 'U04G7H550', + }, + ], + }, + integrations: { + SLACK: false, + }, + type: 'track', + event: 'Message', + anonymousId: '7509c04f547b05afb6838aa742f4910263d6', + originalTimestamp: '2024-03-03T04:48:29.308Z', + sentAt: '2024-03-03T04:48:29.000Z', + properties: { + user: 'U04G7H550', + type: 'message', + ts: '1709441309.308399', + client_msg_id: '834r664e-ec75-445d-t5c6-b873a07y9c81', + text: 'What is the pricing of product X', + team: 'T0GFJL5J7', + thread_ts: '1709407304.839329', + parent_user_id: 'U06P6LQTPV', + blocks: [ + { + type: 'rich_text', + block_id: 'xGKJl', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text: 'What is the pricing of product X', + }, + { + type: 'channel', + channel_id: 'C03CDQTPI65', + }, + { + type: 'text', + text: ' to do this', + }, + ], + }, + ], + }, + ], + channel: 'C03CDQTPI65', + event_ts: '1709441309.308399', + channel_type: 'channel', + }, + }, + ], + }, + }, + ], + }, + }, + }, +];