import { httpErrorFromResponse, platformErrorFromResult, rateLimitedErrorWithDelay, requestErrorWithOriginal,} from "./errors.ts"import { delay } from "./helpers.ts"import { getUserAgent } from "./instrument.ts"import { getLogger } from "./logger.ts"import { CursorPaginationEnabled, cursorPaginationEnabledMethods, Methods } from "./methods.ts"import retryPolicies, { RetryOptions } from "./retry-policies.ts"
import { AbortError, axiod, basename, Data, IAxiodResponse, IData, IHeaderData, Logger, LogLevel, PQueue, pRetried,} from "../deps.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.`) }}