diff --git a/packages/api/package.json b/packages/api/package.json index 111fc6502..b387616f4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -24,6 +24,7 @@ "@google-cloud/tasks": "^4.0.0", "@graphql-tools/utils": "^9.1.1", "@langchain/openai": "^0.0.14", + "@notionhq/client": "^2.2.14", "@omnivore/content-handler": "1.0.0", "@omnivore/liqe": "1.0.0", "@omnivore/readability": "1.0.0", diff --git a/packages/api/src/entity/integration.ts b/packages/api/src/entity/integration.ts index e446f10c3..dcb7f03c1 100644 --- a/packages/api/src/entity/integration.ts +++ b/packages/api/src/entity/integration.ts @@ -59,4 +59,7 @@ export class Integration { @Column('enum', { enum: ImportItemState, nullable: true }) importItemState?: ImportItemState | null + + @Column('jsonb', { nullable: true }) + settings?: any } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 17320ff8f..b40e7d8b1 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1091,6 +1091,7 @@ export type Integration = { enabled: Scalars['Boolean']; id: Scalars['ID']; name: Scalars['String']; + settings?: Maybe; taskName?: Maybe; token: Scalars['String']; type: IntegrationType; @@ -2594,6 +2595,7 @@ export type SetIntegrationInput = { id?: InputMaybe; importItemState?: InputMaybe; name: Scalars['String']; + settings?: InputMaybe; syncedAt?: InputMaybe; taskName?: InputMaybe; token: Scalars['String']; @@ -5321,6 +5323,7 @@ export type IntegrationResolvers; id?: Resolver; name?: Resolver; + settings?: Resolver, ParentType, ContextType>; taskName?: Resolver, ParentType, ContextType>; token?: Resolver; type?: Resolver; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 81959567a..0a1aed314 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -974,6 +974,7 @@ type Integration { enabled: Boolean! id: ID! name: String! + settings: JSON taskName: String token: String! type: IntegrationType! @@ -2009,6 +2010,7 @@ input SetIntegrationInput { id: ID importItemState: ImportItemState name: String! + settings: JSON syncedAt: Date taskName: String token: String! diff --git a/packages/api/src/jobs/integration/export_item.ts b/packages/api/src/jobs/integration/export_item.ts index 327f16206..318a94ec0 100644 --- a/packages/api/src/jobs/integration/export_item.ts +++ b/packages/api/src/jobs/integration/export_item.ts @@ -35,41 +35,54 @@ export const exportItem = async (jobData: ExportItemJobData) => { return } - // currently only readwise integration is supported - const integration = integrations[0] + await Promise.all( + integrations.map(async (integration) => { + try { + const logObject = { + userId, + integrationId: integration.id, + } + logger.info('exporting item...', logObject) - const logObject = { - userId, - integrationId: integration.id, - } - logger.info('exporting item...', logObject) + const client = getIntegrationClient( + integration.name, + integration.token, + integration + ) - const client = getIntegrationClient(integration.name) + const synced = await client.export(libraryItems) + if (!synced) { + logger.error('failed to export item', logObject) + return false + } - const synced = await client.export(integration.token, libraryItems) - if (!synced) { - logger.error('failed to export item', logObject) - return false - } + const syncedAt = new Date() + logger.info('updating integration...', { + ...logObject, + syncedAt, + }) - const syncedAt = new Date() - logger.info('updating integration...', { - ...logObject, - syncedAt, - }) - - // update integration syncedAt if successful - const updated = await updateIntegration( - integration.id, - { - syncedAt, - }, - userId + // update integration syncedAt if successful + const updated = await updateIntegration( + integration.id, + { + syncedAt, + }, + userId + ) + logger.info('integration updated', { + ...logObject, + updated, + }) + } catch (error) { + logger.error('failed to export item', { + userId, + integrationId: integration.id, + error, + }) + } + }) ) - logger.info('integration updated', { - ...logObject, - updated, - }) return true } diff --git a/packages/api/src/resolvers/integrations/index.ts b/packages/api/src/resolvers/integrations/index.ts index 90c118129..d50992b28 100644 --- a/packages/api/src/resolvers/integrations/index.ts +++ b/packages/api/src/resolvers/integrations/index.ts @@ -55,6 +55,8 @@ export const setIntegrationResolver = authorized< input.type === IntegrationType.Import ? input.importItemState || ImportItemState.Unarchived // default to unarchived : undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + settings: input.settings, } if (input.id) { // Update @@ -69,9 +71,9 @@ export const setIntegrationResolver = authorized< integrationToSave.taskName = existingIntegration.taskName } else { // Create - const integrationService = getIntegrationClient(input.name) + const integrationService = getIntegrationClient(input.name, input.token) // authorize and get access token - const token = await integrationService.accessToken(input.token) + const token = await integrationService.accessToken() if (!token) { return { errorCodes: [SetIntegrationErrorCode.InvalidToken], diff --git a/packages/api/src/routers/integration_router.ts b/packages/api/src/routers/integration_router.ts index cc6a520a7..7bf5cbdc6 100644 --- a/packages/api/src/routers/integration_router.ts +++ b/packages/api/src/routers/integration_router.ts @@ -2,6 +2,7 @@ import axios from 'axios' import cors from 'cors' import express from 'express' import { env } from '../env' +import { getIntegrationClient } from '../services/integrations' import { getClaimsByToken } from '../utils/auth' import { corsConfig } from '../utils/corsConfig' import { logger } from '../utils/logger' @@ -10,10 +11,9 @@ export function integrationRouter() { const router = express.Router() // request token from pocket router.post( - '/pocket/auth', + '/:name/auth', cors(corsConfig), async (req: express.Request, res: express.Response) => { - logger.info('pocket/request-token') // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const token = (req.cookies.auth as string) || req.headers.authorization const claims = await getClaimsByToken(token) @@ -21,37 +21,19 @@ export function integrationRouter() { return res.status(401).send('UNAUTHORIZED') } - const consumerKey = env.pocket.consumerKey - const redirectUri = `${env.client.url}/settings/integrations` + const integrationClient = getIntegrationClient(req.params.name, '') + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const state = req.body.state as string try { - // make a POST request to Pocket to get a request token - const response = await axios.post<{ code: string }>( - 'https://getpocket.com/v3/oauth/request', - { - consumer_key: consumerKey, - redirect_uri: redirectUri, - }, - { - headers: { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', - }, - } - ) - const { code } = response.data + const redirectUri = await integrationClient.auth(state) // redirect the user to Pocket to authorize the request token - res.redirect( - `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( - `?pocketToken=${code}&state=${state}` - )}` - ) + res.redirect(redirectUri) } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) } else { - logger.error('pocket/request-token exception:', error) + logger.error(error) } res.redirect( diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ef17d9643..2b588cfcb 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -2015,6 +2015,7 @@ const schema = gql` createdAt: Date! updatedAt: Date taskName: String + settings: JSON } enum IntegrationType { @@ -2050,6 +2051,7 @@ const schema = gql` syncedAt: Date importItemState: ImportItemState taskName: String + settings: JSON } union IntegrationsResult = IntegrationsSuccess | IntegrationsError diff --git a/packages/api/src/services/integrations/index.ts b/packages/api/src/services/integrations/index.ts index 286ac59e7..a925a2765 100644 --- a/packages/api/src/services/integrations/index.ts +++ b/packages/api/src/services/integrations/index.ts @@ -2,20 +2,25 @@ import { DeepPartial, FindOptionsWhere } from 'typeorm' import { Integration } from '../../entity/integration' import { authTrx } from '../../repository' import { IntegrationClient } from './integration' +import { NotionClient } from './notion' import { PocketClient } from './pocket' import { ReadwiseClient } from './readwise' -const integrations: IntegrationClient[] = [ - new ReadwiseClient(), - new PocketClient(), -] - -export const getIntegrationClient = (name: string): IntegrationClient => { - const service = integrations.find((s) => s.name === name) - if (!service) { - throw new Error(`Integration client not found: ${name}`) +export const getIntegrationClient = ( + name: string, + token: string, + integrationData?: Integration +): IntegrationClient => { + switch (name.toLowerCase()) { + case 'readwise': + return new ReadwiseClient(token) + case 'pocket': + return new PocketClient(token) + case 'notion': + return new NotionClient(token, integrationData) + default: + throw new Error(`Integration client not found: ${name}`) } - return service } export const deleteIntegrations = async ( diff --git a/packages/api/src/services/integrations/integration.ts b/packages/api/src/services/integrations/integration.ts index e3f1edbc8..13b61e82a 100644 --- a/packages/api/src/services/integrations/integration.ts +++ b/packages/api/src/services/integrations/integration.ts @@ -20,9 +20,11 @@ export interface RetrieveRequest { export interface IntegrationClient { name: string - apiUrl: string + _token: string - accessToken(token: string): Promise + accessToken(): Promise - export(token: string, items: LibraryItem[]): Promise + auth(state: string): Promise + + export(items: LibraryItem[]): Promise } diff --git a/packages/api/src/services/integrations/notion.ts b/packages/api/src/services/integrations/notion.ts new file mode 100644 index 000000000..4a21d5606 --- /dev/null +++ b/packages/api/src/services/integrations/notion.ts @@ -0,0 +1,314 @@ +import { Client } from '@notionhq/client' +import axios from 'axios' +import { updateIntegration } from '.' +import { Integration } from '../../entity/integration' +import { LibraryItem } from '../../entity/library_item' +import { env } from '../../env' +import { Merge } from '../../util' +import { logger } from '../../utils/logger' +import { IntegrationClient } from './integration' + +type AnnotationColor = + | 'default' + | 'gray' + | 'brown' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'red' + | 'gray_background' + | 'brown_background' + | 'orange_background' + | 'yellow_background' + | 'green_background' + | 'blue_background' + | 'purple_background' + | 'pink_background' + | 'red_background' + +interface NotionPage { + parent: { + database_id: string + } + cover?: { + external: { + url: string + } + } + icon?: { + external: { + url: string + } + } + properties: { + Title: { + title: [ + { + text: { + content: string + } + } + ] + } + Author: { + rich_text: Array<{ + text: { + content: string + } + }> + } + 'Original URL': { + url: string | null + } + 'Omnivore URL': { + url: string | null + } + Tags?: { + multi_select: Array<{ name: string }> + } + } + children?: Array<{ + type: 'paragraph' + paragraph: { + rich_text: Array<{ + text: { + content: string + link?: { url: string } + } + annotations?: { + bold?: boolean + italic?: boolean + strikethrough?: boolean + underline?: boolean + code?: boolean + color?: AnnotationColor + } + }> + } + }> +} + +interface Settings { + parentPageId: string + parentDatabaseId: string +} + +export class NotionClient implements IntegrationClient { + name = 'NOTION' + _headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'Notion-Version': '2022-06-28', + } + _timeout = 5000 // 5 seconds + _axios = axios.create({ + baseURL: 'https://api.notion.com/v1', + timeout: this._timeout, + }) + + _token: string + _client: Client + _integrationData?: Merge + + constructor(token: string, integration?: Integration) { + this._token = token + this._client = new Client({ + auth: token, + timeoutMs: this._timeout, + }) + this._integrationData = integration + } + + accessToken = async (): Promise => { + try { + // encode in base 64 + const encoded = Buffer.from( + `${env.notion.clientId}:${env.notion.clientSecret}` + ).toString('base64') + + const response = await this._axios.post<{ access_token: string }>( + '/oauth/token', + { + grant_type: 'authorization_code', + code: this._token, + redirect_uri: `${env.client.url}/settings/integrations`, + }, + { + headers: { + ...this._headers, + Authorization: `Basic ${encoded}`, + }, + } + ) + return response.data.access_token + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error(error.response) + } else { + logger.error(error) + } + return null + } + } + + async auth(): Promise { + return Promise.resolve(env.notion.authUrl) + } + + private _itemToNotionPage = (item: LibraryItem): NotionPage => { + const databaseId = this._integrationData?.settings?.parentDatabaseId + if (!databaseId) { + throw new Error('Notion database id not found') + } + + return { + parent: { + database_id: databaseId, + }, + icon: item.siteIcon + ? { + external: { + url: item.siteIcon, + }, + } + : undefined, + cover: item.thumbnail + ? { + external: { + url: item.thumbnail, + }, + } + : undefined, + properties: { + Title: { + title: [ + { + text: { + content: item.title, + }, + }, + ], + }, + Author: { + rich_text: [ + { + text: { + content: item.author || 'unknown', + }, + }, + ], + }, + 'Original URL': { + url: item.originalUrl, + }, + 'Omnivore URL': { + url: `${env.client.url}/me/${item.slug}`, + }, + Tags: item.labels + ? { multi_select: item.labels.map((label) => ({ name: label.name })) } + : undefined, + }, + children: item.highlights + ? item.highlights.map((highlight) => ({ + type: 'paragraph', + paragraph: { + rich_text: [ + { + text: { + content: highlight.quote || '', + }, + annotations: { + color: highlight.color as AnnotationColor, + }, + }, + { + text: { + content: `\n${highlight.annotation || ''}`, + }, + annotations: { + italic: true, + }, + }, + ], + }, + })) + : undefined, + } + } + + _createPage = async (page: NotionPage) => { + await this._client.pages.create(page) + } + + export = async (items: LibraryItem[]): Promise => { + if (!this._integrationData || !this._integrationData.settings) { + logger.error('Notion integration data not found') + return false + } + + const pageId = this._integrationData.settings.parentPageId + if (!pageId) { + logger.error('Notion parent page id not found') + return false + } + + const databaseId = this._integrationData.settings.parentDatabaseId + if (!databaseId) { + // create a database for the items + const database = await this._client.databases.create({ + parent: { + page_id: pageId, + }, + title: [ + { + text: { + content: 'Library', + }, + }, + ], + description: [ + { + text: { + content: 'Library of saved items from Omnivore', + }, + }, + ], + properties: { + Title: { + title: {}, + }, + Author: { + rich_text: {}, + }, + 'Original URL': { + url: {}, + }, + 'Omnivore URL': { + url: {}, + }, + Tags: { + multi_select: {}, + }, + }, + }) + + // save the database id + this._integrationData.settings.parentDatabaseId = database.id + await updateIntegration( + this._integrationData.id, + { + settings: this._integrationData.settings, + }, + this._integrationData.user.id + ) + } + + const pages = items.map(this._itemToNotionPage) + await Promise.all(pages.map((page) => this._createPage(page))) + + return true + } +} diff --git a/packages/api/src/services/integrations/pocket.ts b/packages/api/src/services/integrations/pocket.ts index 517d3befa..20f4c212c 100644 --- a/packages/api/src/services/integrations/pocket.ts +++ b/packages/api/src/services/integrations/pocket.ts @@ -5,24 +5,27 @@ import { IntegrationClient } from './integration' export class PocketClient implements IntegrationClient { name = 'POCKET' - apiUrl = 'https://getpocket.com/v3' - headers = { - 'Content-Type': 'application/json', - 'X-Accept': 'application/json', + _token: string + _axios = axios.create({ + baseURL: 'https://getpocket.com/v3', + headers: { + 'Content-Type': 'application/json', + 'X-Accept': 'application/json', + }, + timeout: 5000, // 5 seconds + }) + + constructor(token: string) { + this._token = token } - accessToken = async (token: string): Promise => { - const url = `${this.apiUrl}/oauth/authorize` + accessToken = async (): Promise => { try { - const response = await axios.post<{ access_token: string }>( - url, + const response = await this._axios.post<{ access_token: string }>( + '/oauth/authorize', { consumer_key: env.pocket.consumerKey, - code: token, - }, - { - headers: this.headers, - timeout: 5000, // 5 seconds + code: this._token, } ) return response.data.access_token @@ -36,7 +39,26 @@ export class PocketClient implements IntegrationClient { } } - export = async (): Promise => { - return Promise.resolve(false) + export = () => { + throw new Error('Method not implemented.') + } + + async auth(state: string) { + const consumerKey = env.pocket.consumerKey + const redirectUri = `${env.client.url}/settings/integrations` + + // make a POST request to Pocket to get a request token + const response = await this._axios.post<{ code: string }>( + '/oauth/request', + { + consumer_key: consumerKey, + redirect_uri: redirectUri, + } + ) + const { code } = response.data + + return `https://getpocket.com/auth/authorize?request_token=${code}&redirect_uri=${redirectUri}${encodeURIComponent( + `?pocketToken=${code}&state=${state}` + )}` } } diff --git a/packages/api/src/services/integrations/readwise.ts b/packages/api/src/services/integrations/readwise.ts index ae23810bd..25a61f438 100644 --- a/packages/api/src/services/integrations/readwise.ts +++ b/packages/api/src/services/integrations/readwise.ts @@ -33,17 +33,28 @@ interface ReadwiseHighlight { export class ReadwiseClient implements IntegrationClient { name = 'READWISE' - apiUrl = 'https://readwise.io/api/v2' + _headers = { + 'Content-Type': 'application/json', + } + _axios = axios.create({ + baseURL: 'https://readwise.io/api/v2', + timeout: 5000, // 5 seconds + }) + _token: string - accessToken = async (token: string): Promise => { - const authUrl = `${this.apiUrl}/auth` + constructor(token: string) { + this._token = token + } + + accessToken = async (): Promise => { try { - const response = await axios.get(authUrl, { + const response = await this._axios.get('/auth', { headers: { - Authorization: `Token ${token}`, + ...this._headers, + Authorization: `Token ${this._token}`, }, }) - return response.status === 204 ? token : null + return response.status === 204 ? this._token : null } catch (error) { if (axios.isAxiosError(error)) { logger.error(error.response) @@ -54,20 +65,26 @@ export class ReadwiseClient implements IntegrationClient { } } - export = async (token: string, items: LibraryItem[]): Promise => { + export = async (items: LibraryItem[]): Promise => { let result = true - const highlights = items.flatMap(this.itemToReadwiseHighlight) + const highlights = items.flatMap(this._itemToReadwiseHighlight) // If there are no highlights, we will skip the sync if (highlights.length > 0) { - result = await this.syncWithReadwise(token, highlights) + result = await this._syncWithReadwise(highlights) } return result } - itemToReadwiseHighlight = (item: LibraryItem): ReadwiseHighlight[] => { + auth = () => { + throw new Error('Method not implemented.') + } + + private _itemToReadwiseHighlight = ( + item: LibraryItem + ): ReadwiseHighlight[] => { const category = item.siteName === 'Twitter' ? 'tweets' : 'articles' return item.highlights ?.map((highlight) => { @@ -93,22 +110,19 @@ export class ReadwiseClient implements IntegrationClient { .filter((highlight) => highlight !== undefined) as ReadwiseHighlight[] } - syncWithReadwise = async ( - token: string, + private _syncWithReadwise = async ( highlights: ReadwiseHighlight[] ): Promise => { - const url = `${this.apiUrl}/highlights` - const response = await axios.post( - url, + const response = await this._axios.post( + '/highlights', { highlights, }, { headers: { - Authorization: `Token ${token}`, - 'Content-Type': 'application/json', + ...this._headers, + Authorization: `Token ${this._token}`, }, - timeout: 5000, // 5 seconds } ) return response.status === 200 diff --git a/packages/api/src/services/library_item.ts b/packages/api/src/services/library_item.ts index 9127ca8f9..52d44f7ff 100644 --- a/packages/api/src/services/library_item.ts +++ b/packages/api/src/services/library_item.ts @@ -995,7 +995,7 @@ export const createOrUpdateLibraryItem = async ( ) } - if (skipPubSub) { + if (skipPubSub || libraryItem.state === LibraryItemState.Processing) { return newLibraryItem } diff --git a/packages/api/src/util.ts b/packages/api/src/util.ts index b03a19c15..d42f84ce1 100755 --- a/packages/api/src/util.ts +++ b/packages/api/src/util.ts @@ -113,6 +113,11 @@ export interface BackendEnv { mq: redisConfig cache: redisConfig } + notion: { + clientId: string + clientSecret: string + authUrl: string + } } const nullableEnvVars = [ @@ -165,6 +170,9 @@ const nullableEnvVars = [ 'MQ_REDIS_CERT', 'IMPORTER_METRICS_COLLECTOR_URL', 'INTERNAL_API_URL', + 'NOTION_CLIENT_ID', + 'NOTION_CLIENT_SECRET', + 'NOTION_AUTH_URL', ] // Allow some vars to be null/empty /* If not in GAE and Prod/QA/Demo env (f.e. on localhost/dev env), allow following env vars to be null */ @@ -311,6 +319,11 @@ export function getEnv(): BackendEnv { cert: parse('REDIS_CERT')?.replace(/\\n/g, '\n'), // replace \n with new line }, } + const notion = { + clientId: parse('NOTION_CLIENT_ID'), + clientSecret: parse('NOTION_CLIENT_SECRET'), + authUrl: parse('NOTION_AUTH_URL'), + } return { pg, @@ -333,6 +346,7 @@ export function getEnv(): BackendEnv { pocket, subscription, redis, + notion, } } diff --git a/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql b/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql new file mode 100755 index 000000000..d8c2521b8 --- /dev/null +++ b/packages/db/migrations/0167.do.add_settings_column_to_integrations.sql @@ -0,0 +1,9 @@ +-- Type: DO +-- Name: add_settings_column_to_integrations +-- Description: Add settings column to integrations table + +BEGIN; + +ALTER TABLE omnivore.integrations ADD COLUMN settings jsonb; + +COMMIT; diff --git a/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql b/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql new file mode 100755 index 000000000..34a03ae7e --- /dev/null +++ b/packages/db/migrations/0167.undo.add_settings_column_to_integrations.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: add_settings_column_to_integrations +-- Description: Add settings column to integrations table + +BEGIN; + +ALTER TABLE omnivore.integrations DROP COLUMN settings; + +COMMIT; diff --git a/yarn.lock b/yarn.lock index 7c6054ce7..cf307822f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3973,6 +3973,14 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@notionhq/client@^2.2.14": + version "2.2.14" + resolved "https://registry.yarnpkg.com/@notionhq/client/-/client-2.2.14.tgz#6807ec27ee89584529abfd28d058b2661f828b74" + integrity sha512-oqUefZtCiJPCX+74A1Os9OVTef3fSnVWe2eVQtU1HJSD+nsfxfhwvDKnzJTh2Tw1ZHKLxpieHB/nzGdY+Uo12A== + dependencies: + "@types/node-fetch" "^2.5.10" + node-fetch "^2.6.1" + "@npmcli/arborist@^5.6.3": version "5.6.3" resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-5.6.3.tgz#40810080272e097b4a7a4f56108f4a31638a9874" @@ -7871,6 +7879,14 @@ dependencies: "@types/node" "*" +"@types/node-fetch@^2.5.10", "@types/node-fetch@^2.6.4": + version "2.6.11" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" + integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-fetch@^2.5.7": version "2.6.1" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" @@ -7879,14 +7895,6 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node-fetch@^2.6.4": - version "2.6.11" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.11.tgz#9b39b78665dae0e82a08f02f4967d62c66f95d24" - integrity sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g== - dependencies: - "@types/node" "*" - form-data "^4.0.0" - "@types/node-fetch@^2.6.6": version "2.6.7" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.7.tgz#a1abe2ce24228b58ad97f99480fdcf9bbc6ab16d"