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 11 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 || !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;
default:
message.setEventType(EventType.TRACK);
break;

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

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L24 - L29 were not covered by tests
}
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;

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L38 was not covered by tests
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;
58 changes: 58 additions & 0 deletions src/v0/sources/slack/util.js
krishna2020 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/* 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;

Check warning on line 32 in src/v0/sources/slack/util.js

View check run for this annotation

Codecov / codecov/patch

src/v0/sources/slack/util.js#L32

Added line #L32 was not covered by tests
}
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) {
return evtName
.split('_')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(' ');
}

module.exports = { mapping, tsToISODate, normalizeEventName };
130 changes: 130 additions & 0 deletions test/integrations/sources/slack/data.ts
krishna2020 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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',
gitcommitshow marked this conversation as resolved.
Show resolved Hide resolved
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',
},
},
},
],
},
},
],
},
},
},
];
Loading