import { basename } from "https://deno.land/std@0.86.0/path/mod.ts"
import PQueue from 'https://deno.land/x/p_queue@1.0.0/mod.ts'import pRetried, { AbortError } from 'https://deno.land/x/p_retried@1.0.3/mod.ts'import axiod from "https://deno.land/x/axiod@0.20.0-0/mod.ts"
import { Methods, CursorPaginationEnabled, cursorPaginationEnabledMethods } from './methods.ts'import { getUserAgent } from './instrument.ts'import { requestErrorWithOriginal, httpErrorFromResponse, platformErrorFromResult, rateLimitedErrorWithDelay } from './errors.ts'import { Logger, LogLevel } from 'https://deno.land/x/slack_logger@3.0.0/mod.ts'import { getLogger } from './logger.ts'import retryPolicies, { RetryOptions } from './retry-policies.ts'import { delay } from './helpers.ts'import { IAxiodResponse, IHeaderData, Data, IData } from "https://deno.land/x/axiod@0.20.0-0/interfaces.ts"
export class WebClient extends Methods { public readonly slackApiUrl: string
public readonly token?: string
private retryConfig: RetryOptions
private requestQueue: PQueue
private axiodConfig: { baseURL: string headers: { 'User-Agent': string } transformRequest: ((options: Data, headers?: IHeaderData | undefined) => Data)[] validateStatus: () => boolean }
private rejectRateLimitedCalls: boolean
private static loggerName = 'WebClient'
private logger: Logger
private teamId?: string
constructor(token?: string, { slackApiUrl = 'https://slack.com/api/', logger = undefined, logLevel = LogLevel.INFO, maxRequestConcurrency = 3, retryConfig = retryPolicies.tenRetriesInAboutThirtyMinutes, rejectRateLimitedCalls = false, headers = {}, teamId = undefined, }: WebClientOptions = {}) { super()
this.token = token this.slackApiUrl = slackApiUrl
this.retryConfig = retryConfig this.requestQueue = new PQueue({ concurrency: maxRequestConcurrency }) this.rejectRateLimitedCalls = rejectRateLimitedCalls this.teamId = teamId
if (typeof logger !== 'undefined') { this.logger = logger if (typeof logLevel !== 'undefined') { this.logger.debug('The logLevel given to WebClient was ignored as you also gave logger') } } else { this.logger = getLogger(WebClient.loggerName, logLevel, logger) }
this.axiodConfig = { baseURL: this.slackApiUrl, headers: { 'User-Agent': getUserAgent(), ...headers }, transformRequest: [this.serializeApiCallOptions.bind(this)], validateStatus: () => true, }
this.logger.debug('initialized') }
public async apiCall(method: string, options?: WebAPICallOptions): Promise<WebAPICallResult> { this.logger.debug(`apiCall('${method}') start`)
warnDeprecations(method, this.logger)
if (typeof options === 'string' || typeof options === 'number' || typeof options === 'boolean') { throw new TypeError(`Expected an options argument but instead received a ${typeof options}`) }
const response = await this.makeRequest(method, { token: this.token, teamId: this.teamId, ...options, })
const result = this.buildResult(response)
if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) { result.response_metadata.warnings.forEach(this.logger.warn.bind(this.logger)) }
if (result.response_metadata !== undefined && result.response_metadata.messages !== undefined) { result.response_metadata.messages.forEach((msg) => { const errReg = /\[ERROR\](.*)/ const warnReg = /\[WARN\](.*)/ if (errReg.test(msg)) { const errMatch = msg.match(errReg) if (errMatch != null) { this.logger.error(errMatch[1].trim()) } } else if (warnReg.test(msg)) { const warnMatch = msg.match(warnReg) if (warnMatch != null) { this.logger.warn(warnMatch[1].trim()) } } }) }
if (!result.ok) { throw platformErrorFromResult(result as (WebAPICallResult & { error: string })) }
return result }
public paginate(method: string, options?: WebAPICallOptions): AsyncIterable<WebAPICallResult> public paginate( method: string, options: WebAPICallOptions, shouldStop: PaginatePredicate, ): Promise<void> public paginate<R extends PageReducer, A extends PageAccumulator<R>>( method: string, options: WebAPICallOptions, shouldStop: PaginatePredicate, reduce?: PageReducer<A>, ): Promise<A> public paginate<R extends PageReducer, A extends PageAccumulator<R>>( method: string, options?: WebAPICallOptions, shouldStop?: PaginatePredicate, reduce?: PageReducer<A>, ): (Promise<A> | AsyncIterable<WebAPICallResult>) {
if (!cursorPaginationEnabledMethods.has(method)) { this.logger.warn(`paginate() called with method ${method}, which is not known to be cursor pagination enabled.`) }
const pageSize = (() => { if (options !== undefined && typeof options.limit === 'number') { const limit = options.limit delete options.limit return limit } return defaultPageSize })()
async function* generatePages(this: WebClient): AsyncIterableIterator<WebAPICallResult> { let result: WebAPICallResult | undefined = undefined let paginationOptions: CursorPaginationEnabled | undefined = { limit: pageSize, } if (options !== undefined && options.cursor !== undefined) { paginationOptions.cursor = options.cursor as string }
while (result === undefined || paginationOptions !== undefined) { result = await this.apiCall(method, { ...(options !== undefined ? options : {}), ...paginationOptions }) yield result paginationOptions = paginationOptionsForNextPage(result, pageSize) } }
if (shouldStop === undefined) { return generatePages.call(this) }
const pageReducer: PageReducer<A> = (reduce !== undefined) ? reduce : noopPageReducer let index = 0
return (async () => {
const pageIterator: AsyncIterableIterator<WebAPICallResult> = generatePages.call(this) const firstIteratorResult = await pageIterator.next(undefined) const firstPage = firstIteratorResult.value let accumulator: A = pageReducer(undefined, firstPage, index) index += 1 if (shouldStop(firstPage)) { return accumulator }
for await (const page of pageIterator) { accumulator = pageReducer(accumulator, page, index) if (shouldStop(page)) { return accumulator } index += 1 } return accumulator })() }
private makeRequest( url: string, body: IData, headers: IHeaderData = {} ): Promise<IAxiodResponse> { const task = () => this.requestQueue.add(async () => { this.logger.debug('will perform http request') try { const response = await axiod.request({ ...this.axiodConfig, method: 'POST', data: body, url, headers: { ...headers, ...this.axiodConfig.headers }, })
this.logger.debug('http response received')
if (response.status === 429) { const retrySec = parseRetryHeaders(response) if (retrySec !== undefined) { this.dispatchEvent(new CustomEvent(WebClientEvent.RATE_LIMITED, { detail: retrySec })) if (this.rejectRateLimitedCalls) { throw new AbortError(rateLimitedErrorWithDelay(retrySec)) } this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`) this.requestQueue.pause() await delay(retrySec * 1000) this.requestQueue.start() throw Error('A rate limit was exceeded.') } else { throw new AbortError(new Error('Retry header did not contain a valid timeout.')) } }
if (response.status !== 200) { throw httpErrorFromResponse(response) }
return response } catch (error) { this.logger.warn('http request failed', error.message) if (error.request) { throw requestErrorWithOriginal(error) } throw error } })
return pRetried(task, this.retryConfig) }
private serializeApiCallOptions(options: Data, headers?: IHeaderData): Data { let containsBinaryData = false const flattened = Object.entries(options as IData) .flatMap(([key, value]): [[string, string | Blob]] | [] => { if (value === undefined || value === null) { return [] }
let serializedValue: string | Blob = value
if (value instanceof Blob) { containsBinaryData = true } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { serializedValue = value.toString() } else { serializedValue = JSON.stringify(value) }
return [[key, serializedValue]] })
if (containsBinaryData) { this.logger.debug('request arguments contain binary data') const form = flattened.reduce( (form, [key, value]) => { if (value instanceof File) { form.append(key, value, basename(value.name)) } else { form.append(key, value) } return form }, new FormData(), ) for (const [header, value] of form.entries()) { headers![header] = value }
return form }
headers!['Content-Type'] = 'application/x-www-form-urlencoded'
return new URLSearchParams( flattened as [string, string][] ).toString() }
private buildResult(response: IAxiodResponse): WebAPICallResult { const data = response.data
if (data.response_metadata === undefined) { data.response_metadata = {} }
const xOauthScopes = response.headers.get('x-oauth-scopes') const xAcceptedOauthScopes = response.headers.get('x-accepted-oauth-scopes')
if (xOauthScopes) { data.response_metadata.scopes = xOauthScopes.trim().split(/\s*,\s*/) } if (xAcceptedOauthScopes) { data.response_metadata.acceptedScopes = xAcceptedOauthScopes.trim().split(/\s*,\s*/) }
const retrySec = parseRetryHeaders(response) if (retrySec !== undefined) { data.response_metadata.retryAfter = retrySec }
return data }}
export default WebClient
export interface WebClientOptions { slackApiUrl?: string logger?: Logger logLevel?: LogLevel maxRequestConcurrency?: number retryConfig?: RetryOptions rejectRateLimitedCalls?: boolean headers?: Record<string, unknown> teamId?: string}
export enum WebClientEvent { RATE_LIMITED = 'rate_limited',}
export interface WebAPICallOptions { [argument: string]: unknown}
export interface WebAPICallResult { ok: boolean error?: string response_metadata?: { warnings?: string[] next_cursor?: string
scopes?: string[] acceptedScopes?: string[] retryAfter?: number messages?: string[] } [key: string]: unknown}
export interface PaginatePredicate { (page: WebAPICallResult): boolean | undefined | void}
export interface PageReducer<A = any> { (accumulator: A | undefined, page: WebAPICallResult, index: number): A}
export type PageAccumulator<R extends PageReducer> = R extends (accumulator: (infer A) | undefined, page: WebAPICallResult, index: number) => infer A ? A : never
const defaultFilename = 'Untitled'const defaultPageSize = 200const noopPageReducer: PageReducer = () => undefined
function paginationOptionsForNextPage( previousResult: WebAPICallResult | undefined, pageSize: number,): CursorPaginationEnabled | undefined { if ( previousResult !== undefined && previousResult.response_metadata !== undefined && previousResult.response_metadata.next_cursor !== undefined && previousResult.response_metadata.next_cursor !== '' ) { return { limit: pageSize, cursor: previousResult.response_metadata.next_cursor as string, } } return}
function parseRetryHeaders(response: IAxiodResponse): number | undefined { if (response.headers.get('retry-after') !== undefined) { const retryAfter = parseInt((response.headers.get('retry-after') as string), 10)
if (!Number.isNaN(retryAfter)) { return retryAfter } } return undefined}
function warnDeprecations(method: string, logger: Logger): void { const deprecatedConversationsMethods = ['channels.', 'groups.', 'im.', 'mpim.']
const deprecatedMethods = ['admin.conversations.whitelist.']
const isDeprecatedConversations = deprecatedConversationsMethods.some((depMethod) => { const re = new RegExp(`^${depMethod}`) return re.test(method) })
const isDeprecated = deprecatedMethods.some((depMethod) => { const re = new RegExp(`^${depMethod}`) return re.test(method) })
if (isDeprecatedConversations) { logger.warn(`${method} is deprecated. Please use the Conversations API instead. For more info, go to https://api.slack.com/changelog/2020-01-deprecating-antecedents-to-the-conversations-api`) } else if (isDeprecated) { logger.warn(`${method} is deprecated. Please check on https://api.slack.com/methods for an alternative.`) }}