integrate with notion api client

This commit is contained in:
Hongbo Wu
2024-03-05 12:43:18 +08:00
parent e20908a0c2
commit 741f3213d9
10 changed files with 127 additions and 130 deletions

View File

@ -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",

View File

@ -44,9 +44,9 @@ export const exportItem = async (jobData: ExportItemJobData) => {
}
logger.info('exporting item...', logObject)
const client = getIntegrationClient(integration.name)
const client = getIntegrationClient(integration.name, integration.token)
const synced = await client.export(integration.token, libraryItems)
const synced = await client.export(libraryItems)
if (!synced) {
logger.error('failed to export item', logObject)
return false

View File

@ -69,9 +69,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],

View File

@ -21,7 +21,7 @@ export function integrationRouter() {
return res.status(401).send('UNAUTHORIZED')
}
const integrationClient = getIntegrationClient(req.params.name)
const integrationClient = getIntegrationClient(req.params.name, '')
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const state = req.body.state as string

View File

@ -6,20 +6,20 @@ import { NotionClient } from './notion'
import { PocketClient } from './pocket'
import { ReadwiseClient } from './readwise'
const integrations: IntegrationClient[] = [
new ReadwiseClient(),
new PocketClient(),
new NotionClient(),
]
export const getIntegrationClient = (name: string): IntegrationClient => {
const service = integrations.find(
(s) => s.name.toLowerCase() === name.toLowerCase()
)
if (!service) {
throw new Error(`Integration client not found: ${name}`)
export const getIntegrationClient = (
name: string,
token: string
): IntegrationClient => {
switch (name.toLowerCase()) {
case 'readwise':
return new ReadwiseClient(token)
case 'pocket':
return new PocketClient(token)
case 'notion':
return new NotionClient(token)
default:
throw new Error(`Integration client not found: ${name}`)
}
return service
}
export const deleteIntegrations = async (

View File

@ -20,11 +20,11 @@ export interface RetrieveRequest {
export interface IntegrationClient {
name: string
apiUrl: string
_token: string
accessToken(token: string): Promise<string | null>
accessToken(): Promise<string | null>
auth(state: string): Promise<string>
export(token: string, items: LibraryItem[]): Promise<boolean>
export(items: LibraryItem[]): Promise<boolean>
}

View File

@ -1,3 +1,4 @@
import { Client } from '@notionhq/client'
import axios from 'axios'
import { LibraryItem } from '../../entity/library_item'
import { env } from '../../env'
@ -6,7 +7,7 @@ import { IntegrationClient } from './integration'
interface NotionPage {
parent: {
database_id: string
page_id: string
}
cover?: {
external: {
@ -14,55 +15,56 @@ interface NotionPage {
}
}
properties: {
Name: {
title: Array<{
text: {
content: string
}
}>
}
URL: {
url: string
}
Tags: {
multi_select: Array<{
name: string
}>
}
title: Array<{
text: {
content: string
}
}>
}
}
export class NotionClient implements IntegrationClient {
name = 'NOTION'
apiUrl = 'https://api.notion.com/v1'
headers = {
_headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
'Notion-Version': '2022-06-28',
}
timeout = 5000 // 5 seconds
_timeout = 5000 // 5 seconds
_axios = axios.create({
baseURL: 'https://api.notion.com/v1',
timeout: this._timeout,
})
_token: string
_client: Client
accessToken = async (code: string): Promise<string | null> => {
const authUrl = `${this.apiUrl}/oauth/token`
constructor(token: string) {
this._token = token
this._client = new Client({
auth: token,
timeoutMs: this._timeout,
})
}
accessToken = async (): Promise<string | null> => {
try {
// encode in base 64
const encoded = Buffer.from(
`${env.notion.clientId}:${env.notion.clientSecret}`
).toString('base64')
const response = await axios.post<{ access_token: string }>(
authUrl,
const response = await this._axios.post<{ access_token: string }>(
'/oauth/token',
{
grant_type: 'authorization_code',
code,
code: this._token,
redirect_uri: `${env.client.url}/settings/integrations`,
},
{
headers: {
authorization: `Basic ${encoded}`,
...this.headers,
...this._headers,
Authorization: `Basic ${encoded}`,
},
timeout: this.timeout,
}
)
return response.data.access_token
@ -83,7 +85,7 @@ export class NotionClient implements IntegrationClient {
private _itemToNotionPage = (item: LibraryItem): NotionPage => {
return {
parent: {
database_id: item.id,
page_id: '83a3f627ab9e44ac83fe657141aec615',
},
cover: item.thumbnail
? {
@ -93,49 +95,28 @@ export class NotionClient implements IntegrationClient {
}
: undefined,
properties: {
Name: {
title: [
{
text: {
content: item.title,
},
title: [
{
text: {
content: item.title,
},
],
},
URL: {
url: item.originalUrl,
},
Tags: {
multi_select:
item.labels?.map((label) => {
return {
name: label.name,
}
}) || [],
},
},
],
},
}
}
export = async (token: string, items: LibraryItem[]): Promise<boolean> => {
const url = `${this.apiUrl}/pages`
const page = this._itemToNotionPage(items[0])
try {
const response = await axios.post(url, page, {
headers: {
Authorization: `Bearer ${token}`,
...this.headers,
},
timeout: this.timeout,
})
return response.status === 200
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(error.response)
} else {
logger.error(error)
}
return false
}
_createPage = async (page: NotionPage) => {
await this._client.pages.create(page)
}
export = async (items: LibraryItem[]): Promise<boolean> => {
// find/create a parent page for all the items
const parentPageName = 'Omnivore'
const pages = items.map(this._itemToNotionPage)
await Promise.all(pages.map((page) => this._createPage(page)))
return true
}
}

View File

@ -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<string | null> => {
const url = `${this.apiUrl}/oauth/authorize`
accessToken = async (): Promise<string | null> => {
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
@ -45,15 +48,11 @@ export class PocketClient implements IntegrationClient {
const redirectUri = `${env.client.url}/settings/integrations`
// make a POST request to Pocket to get a request token
const response = await axios.post<{ code: string }>(
`${this.apiUrl}/oauth/request`,
const response = await this._axios.post<{ code: string }>(
'/oauth/request',
{
consumer_key: consumerKey,
redirect_uri: redirectUri,
},
{
headers: this.headers,
timeout: 5000, // 5 seconds
}
)
const { code } = response.data

View File

@ -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<string | null> => {
const authUrl = `${this.apiUrl}/auth`
constructor(token: string) {
this._token = token
}
accessToken = async (): Promise<string | null> => {
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,14 +65,14 @@ export class ReadwiseClient implements IntegrationClient {
}
}
export = async (token: string, items: LibraryItem[]): Promise<boolean> => {
export = async (items: LibraryItem[]): Promise<boolean> => {
let result = true
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
@ -100,21 +111,18 @@ export class ReadwiseClient implements IntegrationClient {
}
private _syncWithReadwise = async (
token: string,
highlights: ReadwiseHighlight[]
): Promise<boolean> => {
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

View File

@ -3931,6 +3931,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"
@ -7877,6 +7885,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"
@ -7885,14 +7901,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"