Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add slack source #3148

Merged
merged 16 commits into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/v0/sources/slack/mapping.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
110 changes: 110 additions & 0 deletions src/v0/sources/slack/transform.js
Original file line number Diff line number Diff line change
@@ -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');

Check warning on line 18 in src/v0/sources/slack/transform.js

View check run for this annotation

Codecov / codecov/patch

src/v0/sources/slack/transform.js#L18

Added line #L18 was not covered by tests
}
switch (slackPayload.event.type) {
case 'team_join':
message.setEventType(EventType.IDENTIFY);
break;
case 'user_change':
message.setEventType(EventType.IDENTIFY);
break;

Check warning on line 26 in src/v0/sources/slack/transform.js

View check run for this annotation

Codecov / codecov/patch

src/v0/sources/slack/transform.js#L24-L26

Added lines #L24 - L26 were not covered by tests
default:
message.setEventType(EventType.TRACK);
break;
}
message.setEventName(normalizeEventName(slackPayload.event.type));
if (!slackPayload.event.user) {
throw new TransformationError('UserId not found');

Check warning on line 33 in src/v0/sources/slack/transform.js

View check run for this annotation

Codecov / codecov/patch

src/v0/sources/slack/transform.js#L33

Added line #L33 was not covered by tests
}
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;
62 changes: 62 additions & 0 deletions src/v0/sources/slack/util.js
Original file line number Diff line number Diff line change
@@ -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 };
51 changes: 51 additions & 0 deletions src/v0/sources/slack/util.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading