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

Added in functionality for admin.analytics.getFile method #1515

Merged
merged 6 commits into from Jul 21, 2022
65 changes: 60 additions & 5 deletions packages/web-api/src/WebClient.ts
Expand Up @@ -10,6 +10,13 @@ import pRetry, { AbortError } from 'p-retry';
import axios, { AxiosInstance, AxiosResponse } from 'axios';
import FormData from 'form-data';
import isElectron from 'is-electron';
import zlib from 'zlib';
import { TextDecoder } from 'util';
import {
AdminAnalyticsMemberDetails,
AdminAnalyticsPublicChannelDetails,
AdminAnalyticsPublicChannelMetadataDetails,
} from './response';

import { Methods, CursorPaginationEnabled, cursorPaginationEnabledMethods } from './methods';
import { getUserAgent } from './instrument';
Expand Down Expand Up @@ -232,7 +239,7 @@ export class WebClient extends Methods {
team_id: this.teamId,
...options,
}, headers);
const result = this.buildResult(response);
const result = await this.buildResult(response);

// log warnings in response metadata
if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) {
Expand All @@ -259,7 +266,12 @@ export class WebClient extends Methods {
});
}

if (!result.ok) {
// If result's content is gzip, "ok" property is not returned with successful response
// TODO: look into simplifying this code block to only check for the second condition
// if an { ok: false } body applies for all API errors
if (!result.ok && (response.headers['content-type'] !== 'application/gzip')) {
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
throw platformErrorFromResult(result as (WebAPICallResult & { error: string; }));
} else if ('ok' in result && result.ok === false) {
throw platformErrorFromResult(result as (WebAPICallResult & { error: string; }));
}

Expand Down Expand Up @@ -385,10 +397,17 @@ export class WebClient extends Methods {
const task = () => this.requestQueue.add(async () => {
this.logger.debug('will perform http request');
try {
const response = await this.axios.post(url, body, {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const config: any = {
headers,
...this.tlsConfig,
});
};
// admin.analytics.getFile returns a binary response
// To be able to parse it, it should be read as an ArrayBuffer
if (url.endsWith('admin.analytics.getFile')) {
config.responseType = 'arraybuffer';
}
const response = await this.axios.post(url, body, config);
this.logger.debug('http response received');

if (response.status === 429) {
Expand Down Expand Up @@ -533,8 +552,44 @@ export class WebClient extends Methods {
* @param response - an http response
*/
// eslint-disable-next-line class-methods-use-this
private buildResult(response: AxiosResponse): WebAPICallResult {
private async buildResult(response: AxiosResponse): Promise<WebAPICallResult> {
let { data } = response;
const isGzipResponse = response.headers['content-type'] === 'application/gzip';

// Check for GZIP response - if so, it is a successful response from admin.analytics.getFile
if (isGzipResponse) {
// admin.analytics.getFile will return a Buffer that can be unzipped
try {
const unzippedData = await new Promise((resolve, reject) => {
zlib.unzip(data, (err, buf) => {
if (err) {
return reject(err);
}
return resolve(buf.toString().split('\n'));
});
}).then((res) => res)
.catch((err) => {
throw err;
});
const fileData: Array<AdminAnalyticsMemberDetails |
AdminAnalyticsPublicChannelDetails |
AdminAnalyticsPublicChannelMetadataDetails> = [];
if (Array.isArray(unzippedData)) {
unzippedData.forEach((dataset) => {
if (dataset && dataset.length > 0) {
fileData.push(JSON.parse(dataset));
}
});
}
data = { file_data: fileData };
} catch (err) {
data = { ok: false, error: err };
}
} else if (!isGzipResponse && response.request.path === '/api/admin.analytics.getFile') {
// if it isn't a Gzip response but is from the admin.analytics.getFile request,
// decode the ArrayBuffer to JSON read the error
data = JSON.parse(new TextDecoder().decode(data));
}

if (typeof data === 'string') {
// response.data can be a string, not an object for some reason
Expand Down
10 changes: 9 additions & 1 deletion packages/web-api/src/methods.ts
Expand Up @@ -3,6 +3,7 @@ import { Dialog, View, KnownBlock, Block, MessageAttachment, LinkUnfurls, CallUs
import { EventEmitter } from 'eventemitter3';
import { WebAPICallOptions, WebAPICallResult, WebClient, WebClientEvent } from './WebClient';
import {
AdminAnalyticsGetFileResponse,
AdminAppsApproveResponse,
AdminAppsApprovedListResponse,
AdminAppsClearResolutionResponse,
Expand Down Expand Up @@ -247,7 +248,9 @@ export abstract class Methods extends EventEmitter<WebClientEvent> {
public abstract apiCall(method: string, options?: WebAPICallOptions): Promise<WebAPICallResult>;

public readonly admin = {
// TODO: admin.analytics.getFile
analytics: {
getFile: bindApiCall<AdminAnalyticsGetFileArguments, AdminAnalyticsGetFileResponse>(this, 'admin.analytics.getFile'),
},
apps: {
approve: bindApiCall<AdminAppsApproveArguments, AdminAppsApproveResponse>(this, 'admin.apps.approve'),
approved: {
Expand Down Expand Up @@ -845,6 +848,11 @@ export interface TraditionalPagingEnabled {
/*
* `admin.*`
*/
export interface AdminAnalyticsGetFileArguments extends WebAPICallOptions, TokenOverridable {
type: string;
date?: string;
metadata_only?: boolean;
}
export interface AdminAppsApproveArguments extends WebAPICallOptions, TokenOverridable {
app_id?: string;
request_id?: string;
Expand Down
71 changes: 71 additions & 0 deletions packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts
Expand Up @@ -10,6 +10,7 @@

import { WebAPICallResult } from '../WebClient';
export type AdminAnalyticsGetFileResponse = WebAPICallResult & {
file_data?: Array<AdminAnalyticsMemberDetails|AdminAnalyticsPublicChannelDetails|AdminAnalyticsPublicChannelMetadataDetails>[];
error?: string;
needed?: string;
ok?: boolean;
Expand All @@ -20,3 +21,73 @@ export type AdminAnalyticsGetFileResponse = WebAPICallResult & {
export interface ResponseMetadata {
messages?: string[];
}

export interface AdminAnalyticsMemberDetails {
enterprise_id: string;
date: string;
user_id: string;
email_address: string;
is_guest: boolean;
is_billable_seat: boolean;
is_active: boolean;
is_active_ios: boolean;
is_active_android: boolean;
is_active_desktop: boolean;
reactions_added_count: number;
messages_posted_count: number;
channel_messages_posted_count: number;
files_added_count: number;
is_active_apps: boolean;
is_active_workflows: boolean;
is_active_slack_connect: boolean;
total_calls_count: number;
slack_calls_count: number;
slack_huddles_count: number;
search_count: number;
date_claimed: number;
}

export interface AdminAnalyticsPublicChannelDetails {
enterprise_id: string;
originating_team: AdminAnalyticsOriginatingTeamDetails;
channel_id: string;
date_created: number;
date_last_active: number;
total_members_count: number;
full_members_count: number;
guest_member_count: number;
messages_posted_count: number;
messages_posted_by_members_count: number;
members_who_viewed_count: number;
members_who_posted_count: number;
reactions_added_count: number;
visibility: string;
channel_type: string;
is_shared_externally: boolean;
shared_with: AdminAnalyticsSharedWithDetails[];
externally_shared_with_organizations: AdminAnalyticsExternallySharedWithOrganizationsDetails[];
date: string;
}

export interface AdminAnalyticsPublicChannelMetadataDetails {
channel_id: string;
name: string;
topic: string;
description: string;
date: string;
}

export interface AdminAnalyticsOriginatingTeamDetails {
team_id: string;
name: string;
}

export interface AdminAnalyticsSharedWithDetails {
team_id: string;
name: string;
}

export interface AdminAnalyticsExternallySharedWithOrganizationsDetails {
name: string;
domain: string;
}
2 changes: 1 addition & 1 deletion packages/web-api/src/response/index.ts
@@ -1,4 +1,4 @@
export { AdminAnalyticsGetFileResponse } from './AdminAnalyticsGetFileResponse';
export { AdminAnalyticsGetFileResponse, AdminAnalyticsMemberDetails, AdminAnalyticsPublicChannelDetails, AdminAnalyticsPublicChannelMetadataDetails } from './AdminAnalyticsGetFileResponse';
export { AdminAppsApproveResponse } from './AdminAppsApproveResponse';
export { AdminAppsApprovedListResponse } from './AdminAppsApprovedListResponse';
export { AdminAppsClearResolutionResponse } from './AdminAppsClearResolutionResponse';
Expand Down
74 changes: 74 additions & 0 deletions prod-server-integration-tests/test/admin-web-api-analytics.js
@@ -0,0 +1,74 @@
require('mocha');
const { assert } = require('chai');
const { WebClient } = require('@slack/web-api');

const winston = require('winston');
const logger = winston.createLogger({
level: 'debug',
transports: [
new winston.transports.File({ filename: 'logs/console.log' }),
],
});

describe('admin.analytics.* Web API', function () {
// org-level admin user's token
const orgAdminClient = new WebClient(process.env.SLACK_SDK_TEST_GRID_ORG_ADMIN_USER_TOKEN, { logger, });

describe('admin.analytics.getFile', function () {
it('should get an available file for a public_channel type and a date that has passed', async function () {
// This test may fail depending on the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere from 1 day after
// your enterprise grid org was created to the current date.
const body = { type: 'public_channel', date: '2022-07-06' };
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
const res = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res.error);
assert.isDefined(res.file_data);
hello-ashleyintech marked this conversation as resolved.
Show resolved Hide resolved
assert.property(res.file_data[0], 'total_members_count');
assert.property(res.file_data[0], 'full_members_count');
});

it('should fail to get an available file for a future date', async function () {
// This test may fail depending on the current date and the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere after today's date.
const body = { type: 'public_channel', date: '2035-10-06' };
try {
await orgAdminClient.admin.analytics.getFile(body);
} catch (error) {
assert.isFalse(error.data.ok);
assert.equal(error.data.error, 'file_not_yet_available');
}
});

it('should get an available file for a member type and a date that has passed', async function () {
// This test may fail depending on the enterprise grid account you are testing on.
// To get this test to pass, please adjust the date to anywhere from 1 day after
// your enterprise grid org was created to the current date.
const body = { type: 'member', date: '2022-07-06' };
const res1 = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res1.error);
assert.isDefined(res1.file_data);
assert.property(res1.file_data[0], 'user_id');
assert.property(res1.file_data[0], 'email_address');
});

it('should get metadata when using public_channel type and metadata_only options', async function () {
const body = { type: 'public_channel', metadata_only: true };
const res2 = await orgAdminClient.admin.analytics.getFile(body);
assert.isUndefined(res2.error);
assert.isDefined(res2.file_data);
assert.property(res2.file_data[0], 'name');
assert.property(res2.file_data[0], 'topic');
assert.property(res2.file_data[0], 'description');
});

it('should fail to get metadata when using member type and metadata_only options', async function () {
const body = { type: 'member', metadata_only: true };
try {
await orgAdminClient.admin.analytics.getFile(body);
} catch (error) {
assert.isFalse(error.data.ok);
assert.equal(error.data.error, 'metadata_not_available');
}
});
});
});
18 changes: 18 additions & 0 deletions scripts/code_generator.rb
Expand Up @@ -48,6 +48,12 @@ def append_to_index(root_class_name, index_file)
index_f.puts("export { #{root_class_name} } from './#{root_class_name}';")
end
end

def append_multiple_classes_to_index(classes, class_file_name, index_file)
File.open(index_file, 'a') do |index_f|
index_f.puts("export { #{classes.join(', ')} } from './#{class_file_name}';")
end
end
end

ts_writer = TsWriter.new
Expand All @@ -57,6 +63,18 @@ def append_to_index(root_class_name, index_file)
root_class_name = ''
prev_c = nil
filename = json_path.split('/').last.gsub(/\.json$/, '')
if filename.include? "admin.analytics.getFile"
classes_to_export = [
"AdminAnalyticsGetFileResponse",
"AdminAnalyticsMemberDetails",
"AdminAnalyticsPublicChannelDetails",
"AdminAnalyticsPublicChannelMetadataDetails",
]
class_file_name = "AdminAnalyticsGetFileResponse"
puts "Generating #{classes_to_export.join(', ')} from #{json_path}"
ts_writer.append_multiple_classes_to_index(classes_to_export, class_file_name, index_file)
next
end
filename.split('').each do |c|
if prev_c.nil? || prev_c == '.'
root_class_name << c.upcase
Expand Down