diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index 8783fded2..23f0b1766 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -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'; @@ -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) { @@ -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')) { + throw platformErrorFromResult(result as (WebAPICallResult & { error: string; })); + } else if ('ok' in result && result.ok === false) { throw platformErrorFromResult(result as (WebAPICallResult & { error: string; })); } @@ -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) { @@ -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 { 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 = []; + 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 diff --git a/packages/web-api/src/methods.ts b/packages/web-api/src/methods.ts index 47e1444f0..c9a1a2f10 100644 --- a/packages/web-api/src/methods.ts +++ b/packages/web-api/src/methods.ts @@ -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, @@ -247,7 +248,9 @@ export abstract class Methods extends EventEmitter { public abstract apiCall(method: string, options?: WebAPICallOptions): Promise; public readonly admin = { - // TODO: admin.analytics.getFile + analytics: { + getFile: bindApiCall(this, 'admin.analytics.getFile'), + }, apps: { approve: bindApiCall(this, 'admin.apps.approve'), approved: { @@ -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; diff --git a/packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts b/packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts index aaab3d88d..f5a1a9d1f 100644 --- a/packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts +++ b/packages/web-api/src/response/AdminAnalyticsGetFileResponse.ts @@ -10,6 +10,7 @@ import { WebAPICallResult } from '../WebClient'; export type AdminAnalyticsGetFileResponse = WebAPICallResult & { + file_data?: Array[]; error?: string; needed?: string; ok?: boolean; @@ -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; +} diff --git a/packages/web-api/src/response/index.ts b/packages/web-api/src/response/index.ts index f935fb7be..665b0de63 100644 --- a/packages/web-api/src/response/index.ts +++ b/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'; diff --git a/prod-server-integration-tests/test/admin-web-api-analytics.js b/prod-server-integration-tests/test/admin-web-api-analytics.js new file mode 100644 index 000000000..385ac8031 --- /dev/null +++ b/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' }; + const res = await orgAdminClient.admin.analytics.getFile(body); + assert.isUndefined(res.error); + assert.isDefined(res.file_data); + 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'); + } + }); + }); +}); diff --git a/scripts/code_generator.rb b/scripts/code_generator.rb index 905fa56cb..5e6edb3aa 100644 --- a/scripts/code_generator.rb +++ b/scripts/code_generator.rb @@ -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 @@ -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