Skip to content

Commit

Permalink
feat: add slack source (#3148)
Browse files Browse the repository at this point in the history
  • Loading branch information
gitcommitshow authored May 11, 2024
2 parents 3741093 + d16752b commit 3cbb011
Show file tree
Hide file tree
Showing 5 changed files with 555 additions and 0 deletions.
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');
}
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;
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

0 comments on commit 3cbb011

Please sign in to comment.