chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
@@ -0,0 +1,26 @@
import { LinkedInClient } from '../linkedin-api'
import * as bp from '.botpress'
export const createPost: bp.IntegrationProps['actions']['createPost'] = async ({ client, ctx, input, logger }) => {
const { text, visibility, imageUrl, articleUrl, articleTitle, articleDescription } = input
const linkedIn = await LinkedInClient.create({ client, ctx, logger })
if (imageUrl) {
logger.forBot().info('Processing image for LinkedIn post...')
}
const result = await linkedIn.posts.createPost({
authorUrn: linkedIn.authorUrn,
text,
visibility,
imageUrl,
articleUrl,
articleTitle,
articleDescription,
})
logger.forBot().info('Post created successfully', { postUrn: result.postUrn })
return result
}
@@ -0,0 +1,14 @@
import { LinkedInClient } from '../linkedin-api'
import * as bp from '.botpress'
export const deletePost: bp.IntegrationProps['actions']['deletePost'] = async ({ client, ctx, input, logger }) => {
const { postUrn } = input
const linkedIn = await LinkedInClient.create({ client, ctx, logger })
await linkedIn.posts.deletePost(postUrn)
logger.forBot().info('Post deleted successfully', { postUrn })
return { success: true }
}
@@ -0,0 +1,2 @@
export { createPost } from './create-post'
export { deletePost } from './delete-post'
+102
View File
@@ -0,0 +1,102 @@
import { generateRedirection, getInterstitialUrl } from '@botpress/common/src/oauth-wizard/interstitial'
import * as crypto from 'crypto'
import { LinkedInOAuthClient } from './linkedin-api'
import { verifyLinkedInWebhook, dispatchWebhookEvent } from './webhook'
import * as bp from '.botpress'
export const handler: bp.IntegrationProps['handler'] = async (props) => {
const { req, logger } = props
try {
if (req.path.startsWith('/oauth')) {
return await handleOAuthCallback(props)
}
if (isWebhookChallenge(req)) {
return handleWebhookChallenge(props)
}
if (req.method === 'POST') {
const isValid = verifyLinkedInWebhook(props)
if (!isValid) {
logger.forBot().error('Webhook signature verification failed')
return { status: 403 }
}
return await dispatchWebhookEvent(props)
}
logger.forBot().warn(`Unhandled request: ${req.method} ${req.path}`)
return { status: 404 }
} catch (thrown: unknown) {
const error = thrown instanceof Error ? thrown.message : String(thrown)
logger.forBot().error(`LinkedIn handler failed with error: ${error}`)
throw thrown
}
}
const isWebhookChallenge = (req: bp.HandlerProps['req']): boolean => {
const params = new URLSearchParams(req.query)
return params.has('challengeCode') && req.method === 'GET'
}
const handleWebhookChallenge = ({ req, ctx, logger }: bp.HandlerProps) => {
const params = new URLSearchParams(req.query)
const challengeCode = params.get('challengeCode')
if (!challengeCode) {
return { status: 400, body: 'Missing challengeCode' }
}
logger.forBot().info('Responding to LinkedIn webhook challenge')
const clientSecret = ctx.configurationType === 'manual' ? ctx.configuration.clientSecret : bp.secrets.CLIENT_SECRET
const challengeResponse = crypto.createHmac('sha256', clientSecret).update(challengeCode).digest('hex')
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challengeCode,
challengeResponse,
}),
}
}
const handleOAuthCallback = async ({ req, client, ctx, logger }: bp.HandlerProps) => {
logger.forBot().debug('Handling OAuth callback')
try {
const searchParams = new URLSearchParams(req.query)
const error = searchParams.get('error')
if (error) {
throw new Error(`${error} - ${searchParams.get('error_description') ?? ''}`)
}
const authorizationCode = searchParams.get('code')
if (!authorizationCode) {
throw new Error('Authorization code not present in OAuth callback')
}
const oauthClient = await LinkedInOAuthClient.createFromAuthorizationCode({
authorizationCode,
client,
ctx,
logger,
})
logger.forBot().info(`Successfully authenticated LinkedIn user: ${oauthClient.getUserId()}`)
logger.forBot().info(`Granted scopes: ${oauthClient.getGrantedScopes().join(', ')}`)
await client.configureIntegration({
identifier: oauthClient.getUserId(),
})
return generateRedirection(getInterstitialUrl(true))
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
const errorMessage = 'OAuth error: ' + msg
logger.forBot().error(errorMessage)
return generateRedirection(getInterstitialUrl(false, errorMessage))
}
}
+25
View File
@@ -0,0 +1,25 @@
import { posthogHelper } from '@botpress/common'
import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition'
import { createPost, deletePost } from './actions'
import { handler } from './handler'
import { register, unregister } from './setup'
import * as bp from '.botpress'
const config: posthogHelper.PostHogConfig = {
integrationName: INTEGRATION_NAME,
integrationVersion: INTEGRATION_VERSION,
key: bp.secrets.POSTHOG_KEY,
}
const props: bp.IntegrationProps = {
register,
unregister,
handler,
actions: {
createPost,
deletePost,
},
channels: {},
}
export default posthogHelper.wrapIntegration(config, props)
@@ -0,0 +1,109 @@
import { describe, expect, test } from 'vitest'
import { TestCase } from '../../tests/types'
import { getImageBufferFromResponse } from './get-image-buffer-from-response'
const BYTES_PER_MB = 1024 * 1024
type GetImageBufferTestCase = TestCase<Response, { ok: boolean; bufferLength?: number }>
const testCases: GetImageBufferTestCase[] = [
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 200,
headers: {
'content-type': 'image/png',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: true, bufferLength: BYTES_PER_MB },
description: 'image/png content-type should be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 200,
headers: {
'content-type': 'image/jpeg',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: true, bufferLength: BYTES_PER_MB },
description: 'image/jpeg content-type should be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 200,
headers: {
'content-type': 'image/gif',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: true, bufferLength: BYTES_PER_MB },
description: 'image/gif content-type should be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB * 8), {
status: 200,
headers: {
'content-type': 'image/png',
'content-length': (BYTES_PER_MB * 8).toString(),
},
}),
expects: { ok: true, bufferLength: BYTES_PER_MB * 8 },
description: '8MB buffer should be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB * 8 + 1), {
status: 200,
headers: {
'content-type': 'image/png',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: false },
description: 'buffer larger than 8MB should not be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 200,
headers: {
'content-type': 'image/png',
'content-length': (BYTES_PER_MB * 8 + 1).toString(),
},
}),
expects: { ok: false },
description: 'content length larger than 8MB should not be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 200,
headers: {
'content-type': 'application/json',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: false },
description: 'content type that does not represent an image should not be valid',
},
{
input: new Response(new ArrayBuffer(BYTES_PER_MB), {
status: 400,
headers: {
'content-type': 'image/png',
'content-length': BYTES_PER_MB.toString(),
},
}),
expects: { ok: false },
description: 'error status should not be valid',
},
]
describe('Markdown to Telegram HTML Conversion with Extracted Images', () => {
test.each(testCases)('$description', async ({ input, expects }) => {
const response = await getImageBufferFromResponse(input)
expect(response.success).toEqual(expects.ok)
if (response.success) {
expect(response.buffer.byteLength).toEqual(expects.bufferLength)
}
})
})
@@ -0,0 +1,49 @@
const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif']
const BYTES_PER_MB = 1024 * 1024
const MAX_IMAGE_SIZE_BYTES = 8 * BYTES_PER_MB // 8MB
export const getImageBufferFromResponse = async (
response: Response
): Promise<{ success: true; buffer: ArrayBuffer } | { success: false; message: string }> => {
if (!response.ok) {
return {
success: false,
message: `Failed to download image from provided URL (status: ${response.status})`,
}
}
const contentType = response.headers.get('content-type')?.toLowerCase() || ''
const contentLength = response.headers.get('content-length')
const isValidType = SUPPORTED_IMAGE_TYPES.some((type) => contentType.includes(type))
if (!isValidType) {
return {
success: false,
message: `Unsupported image format: ${contentType}. LinkedIn supports: JPEG, PNG, GIF.`,
}
}
if (contentLength) {
const size = parseInt(contentLength, 10)
if (size > MAX_IMAGE_SIZE_BYTES) {
return {
success: false,
message: `Image size (${Math.round(size / BYTES_PER_MB)}MB) exceeds LinkedIn's 8MB limit.`,
}
}
}
const imageBuffer = await response.arrayBuffer()
if (imageBuffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
return {
success: false,
message: `Image size (${Math.round(imageBuffer.byteLength / BYTES_PER_MB)}MB) exceeds LinkedIn's 8MB limit.`,
}
}
return {
success: true,
buffer: imageBuffer,
}
}
@@ -0,0 +1,2 @@
export { PostsApi } from './posts-api'
export type { CreatePostParams, CreatePostResult } from './posts-api'
@@ -0,0 +1,152 @@
import * as sdk from '@botpress/sdk'
import { LinkedInBaseApi } from '../base-api'
import { extractLinkedInHeaders } from '../linkedin-oauth-client'
import { initializeUploadResponseSchema } from '../schemas'
import { getImageBufferFromResponse } from './get-image-buffer-from-response'
export type CreatePostParams = {
authorUrn: string
text: string
visibility?: 'PUBLIC' | 'CONNECTIONS'
imageUrl?: string
articleUrl?: string
articleTitle?: string
articleDescription?: string
}
export type CreatePostResult = {
postUrn: string
postUrl: string
}
export class PostsApi extends LinkedInBaseApi {
public async createPost(params: CreatePostParams): Promise<CreatePostResult> {
const { authorUrn, text, visibility, imageUrl, articleUrl, articleTitle, articleDescription } = params
let content: Record<string, unknown> | undefined = undefined
if (imageUrl) {
const imageUrn = await this._uploadImageFromUrl(imageUrl, authorUrn)
content = {
media: {
id: imageUrn,
},
}
} else if (articleUrl) {
content = {
article: {
source: articleUrl,
...(articleTitle && { title: articleTitle }),
...(articleDescription && { description: articleDescription }),
},
}
}
const response = await this.fetchWithErrorHandling(
'/posts',
{
method: 'POST',
body: {
author: authorUrn,
lifecycleState: 'PUBLISHED',
commentary: text,
visibility,
distribution: {
feedDistribution: 'MAIN_FEED',
},
...(content && { content }),
},
},
'Failed to create LinkedIn post'
)
const postUrn = response.headers.get('x-restli-id')
if (!postUrn) {
throw new sdk.RuntimeError('LinkedIn did not return a post URN in response headers')
}
return {
postUrn,
postUrl: `https://www.linkedin.com/feed/update/${postUrn}/`,
}
}
public async deletePost(postUrn: string): Promise<void> {
const encodedUrn = encodeURIComponent(postUrn)
await this.fetchWithErrorHandling(
`/posts/${encodedUrn}`,
{ method: 'DELETE' },
'Failed to delete LinkedIn post',
{ successStatuses: [204, 404] } // 204 = success, 404 = already deleted (treat as success)
)
}
private async _uploadImageFromUrl(imageUrl: string, authorUrn: string): Promise<string> {
const startTime = Date.now()
this.logger.forBot().debug('Starting image upload for LinkedIn post')
const imageBuffer = await this._downloadAndValidateImage(imageUrl)
this.logger.forBot().debug('Image downloaded and validated', { size: imageBuffer.byteLength })
const { uploadUrl, imageUrn } = await this._initializeImageUpload(authorUrn)
await this._uploadImageBinary(uploadUrl, imageBuffer)
this.logger.forBot().debug('Image upload completed', { imageUrn, duration: Date.now() - startTime })
return imageUrn
}
private async _downloadAndValidateImage(imageUrl: string): Promise<ArrayBuffer> {
const result = await getImageBufferFromResponse(await fetch(imageUrl))
if (!result.success) {
throw new sdk.RuntimeError(result.message)
}
return result.buffer
}
private async _initializeImageUpload(authorUrn: string): Promise<{ uploadUrl: string; imageUrn: string }> {
const response = await this.fetchWithErrorHandling(
'/images?action=initializeUpload',
{
method: 'POST',
body: {
initializeUploadRequest: {
owner: authorUrn,
},
},
},
'Failed to initialize image upload with LinkedIn'
)
const data = initializeUploadResponseSchema.parse(await response.json())
return {
uploadUrl: data.value.uploadUrl,
imageUrn: data.value.image,
}
}
private async _uploadImageBinary(uploadUrl: string, imageBuffer: ArrayBuffer): Promise<void> {
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: {
Authorization: `Bearer ${this.accessToken}`,
'Content-Type': 'application/octet-stream',
},
body: imageBuffer,
})
if (!uploadResponse.ok) {
const headers = extractLinkedInHeaders(uploadResponse)
this.logger.forBot().error('Failed to upload image binary to LinkedIn', {
status: uploadResponse.status,
...headers,
})
throw new sdk.RuntimeError(
`Failed to upload image to LinkedIn (status: ${uploadResponse.status}, x-li-uuid: ${headers['x-li-uuid']})`
)
}
}
}
@@ -0,0 +1,111 @@
import * as sdk from '@botpress/sdk'
import { formatLinkedInError } from './linkedin-oauth-client'
import * as bp from '.botpress'
const LINKEDIN_REST_BASE_URL = 'https://api.linkedin.com/rest'
const LINKEDIN_API_VERSION = '202511'
export type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
body?: unknown
headers?: Record<string, string>
}
export class LinkedInBaseApi {
protected readonly accessToken: string
protected readonly baseUrl = LINKEDIN_REST_BASE_URL
protected readonly apiVersion = LINKEDIN_API_VERSION
protected readonly logger: bp.Logger
public constructor(accessToken: string, logger: bp.Logger) {
this.accessToken = accessToken
this.logger = logger
}
private async _fetch(path: string, options: RequestOptions = {}): Promise<Response> {
const { method = 'GET', body, headers = {} } = options
const url = `${this.baseUrl}${path}`
const response = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.accessToken}`,
'X-Restli-Protocol-Version': '2.0.0',
'LinkedIn-Version': this.apiVersion,
...(body !== undefined && { 'Content-Type': 'application/json' }),
...headers,
},
...(body !== undefined && { body: JSON.stringify(body) }),
})
return response
}
/**
* Makes an HTTP request with automatic exponential retry logic for rate limits and custom error handling.
*
* @param successStatuses - Additional HTTP status codes to treat as successful (beyond the default 200-299 range).
* Useful when APIs use non-2xx codes like 201 (Created) or 204 (No Content) to indicate success.
*/
protected async fetchWithErrorHandling(
path: string,
options: RequestOptions,
errorContext: string,
{ successStatuses }: { successStatuses?: number[] } = {}
): Promise<Response> {
const maxRetries = 3
const method = options.method ?? 'GET'
const startTime = Date.now()
this.logger.forBot().debug(`LinkedIn API request: ${method} ${path}`)
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await this._fetch(path, options)
if (response.status === 429) {
if (attempt === maxRetries) {
this.logger.forBot().error(`LinkedIn API rate limit exceeded after ${maxRetries} retries`, { path, method })
throw new sdk.RuntimeError(`${errorContext}: Rate limit exceeded after ${maxRetries} retries`)
}
const delayMs = this._getRetryDelayMs(attempt)
this.logger.forBot().warn(`LinkedIn API rate limited, retrying in ${Math.round(delayMs)}ms`, {
path,
attempt: attempt + 1,
maxRetries,
})
await this._sleep(delayMs)
continue
}
const isSuccess = response.ok || successStatuses?.includes(response.status)
if (!isSuccess) {
const errorMsg = await formatLinkedInError(response, errorContext)
this.logger.forBot().error(`LinkedIn API request failed: ${method} ${path}`, {
status: response.status,
duration: Date.now() - startTime,
})
throw new sdk.RuntimeError(errorMsg)
}
this.logger.forBot().debug(`LinkedIn API request completed: ${method} ${path}`, {
status: response.status,
duration: Date.now() - startTime,
})
return response
}
throw new sdk.RuntimeError(`${errorContext}: Unexpected retry loop exit`)
}
private _getRetryDelayMs(attempt: number): number {
const baseDelayMs = Math.pow(2, attempt) * 1000
const jitterMs = Math.random() * 1000
return baseDelayMs + jitterMs
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
@@ -0,0 +1,29 @@
import { PostsApi } from './apis'
import { LinkedInOAuthClient } from './linkedin-oauth-client'
import * as bp from '.botpress'
export class LinkedInClient {
public readonly posts: PostsApi
public readonly authorUrn: string
private constructor(accessToken: string, userId: string, logger: bp.Logger) {
this.posts = new PostsApi(accessToken, logger)
this.authorUrn = `urn:li:person:${userId}`
}
public static async create({
client,
ctx,
logger,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}): Promise<LinkedInClient> {
const oauthClient = await LinkedInOAuthClient.createFromState({ client, ctx, logger })
const accessToken = await oauthClient.getAccessToken()
const userId = oauthClient.getUserId()
return new LinkedInClient(accessToken, userId, logger)
}
}
@@ -0,0 +1,3 @@
export { LinkedInClient } from './client'
export { LinkedInOAuthClient } from './linkedin-oauth-client'
export type { CreatePostParams, CreatePostResult } from './apis'
@@ -0,0 +1,427 @@
import { handleErrorsDecorator as handleErrors } from '@botpress/common'
import * as sdk from '@botpress/sdk'
import { linkedInErrorResponseSchema, linkedInTokenResponseSchema, userInfoSchema, type UserInfo } from './schemas'
import * as bp from '.botpress'
const LINKEDIN_TOKEN_URL = 'https://www.linkedin.com/oauth/v2/accessToken'
const LINKEDIN_USERINFO_URL = 'https://api.linkedin.com/v2/userinfo'
const OAUTH_REDIRECT_URI = `${process.env.BP_WEBHOOK_URL}/oauth`
const ACCESS_TOKEN_BUFFER_MS = 5 * 60 * 1000 // 5 minutes
const REFRESH_TOKEN_BUFFER_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const ACCESS_TOKEN_EXPIRED_ISSUE_TITLE = 'Access token expired'
const ACCESS_TOKEN_EXPIRED_ISSUE_DESC =
'The LinkedIn api access token is expired or expiring within 7 days and no refresh token is available. Please re-authorize the integration through the OAuth flow.'
const REFRESH_TOKEN_EXPIRED_ISSUE_TITLE = 'Refresh token expired'
const REFRESH_TOKEN_EXPIRED_ISSUE_DESC =
'The LinkedIn api refresh token is expired or expiring within 7 days. Please re-authorize the integration through the OAuth flow.'
type OAuthCredentialsPayload = bp.states.oauthCredentials.OauthCredentials['payload']
type ClientCredentials = {
clientId: string
clientSecret: string
}
export function extractLinkedInHeaders(response: Response): Record<string, string> {
return {
'x-li-uuid': response.headers.get('x-li-uuid') ?? 'N/A',
'x-li-fabric': response.headers.get('x-li-fabric') ?? 'N/A',
'x-li-request-id': response.headers.get('x-li-request-id') ?? 'N/A',
}
}
export async function formatLinkedInError(response: Response, action: string): Promise<string> {
const headers = extractLinkedInHeaders(response)
const responseClone = response.clone()
let errorMessage: string
try {
const parseResult = linkedInErrorResponseSchema.safeParse(await responseClone.json())
if (parseResult.success) {
const errorData = parseResult.data
errorMessage = `${errorData.message ?? 'Unknown error'} (serviceErrorCode: ${errorData.serviceErrorCode ?? 'N/A'})`
} else {
errorMessage = await response.text()
}
} catch {
errorMessage = await response.text()
}
return `${action}: ${errorMessage} (x-li-uuid: ${headers['x-li-uuid']}, x-li-request-id: ${headers['x-li-request-id']})`
}
export class LinkedInOAuthClient {
private _credentials: OAuthCredentialsPayload
private _client: bp.Client
private _ctx: bp.Context
private _clientId: string
private _clientSecret: string
private _logger: bp.Logger
private constructor({
credentials,
client,
ctx,
clientId,
clientSecret,
logger,
}: {
credentials: OAuthCredentialsPayload
client: bp.Client
ctx: bp.Context
clientId: string
clientSecret: string
logger: bp.Logger
}) {
this._credentials = credentials
this._client = client
this._ctx = ctx
this._clientId = clientId
this._clientSecret = clientSecret
this._logger = logger
}
/**
* Creates OAuth client using Botpress's official LinkedIn app credentials.
* Used for automatic OAuth configuration flow.
*/
@handleErrors('Failed to obtain LinkedIn OAuth access token from authorization code')
public static async createFromAuthorizationCode({
authorizationCode,
client,
ctx,
logger,
}: {
authorizationCode: string
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}): Promise<LinkedInOAuthClient> {
const clientCredentials = LinkedInOAuthClient._getBotpressClientCredentials()
return LinkedInOAuthClient._exchangeCodeForTokens({
authorizationCode,
clientCredentials,
client,
ctx,
logger,
})
}
@handleErrors('Failed to obtain LinkedIn OAuth access token from manual config')
public static async createFromManualConfig({
authorizationCode,
clientId,
clientSecret,
client,
ctx,
logger,
}: {
authorizationCode: string
clientId: string
clientSecret: string
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}): Promise<LinkedInOAuthClient> {
return LinkedInOAuthClient._exchangeCodeForTokens({
authorizationCode,
clientCredentials: { clientId, clientSecret },
client,
ctx,
logger,
})
}
public static async createFromState({
client,
ctx,
logger,
}: {
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}): Promise<LinkedInOAuthClient> {
const { state } = await client.getState({
type: 'integration',
name: 'oauthCredentials',
id: ctx.integrationId,
})
const clientCredentials = LinkedInOAuthClient._getClientCredentials(ctx)
return new LinkedInOAuthClient({
credentials: state.payload,
client,
ctx,
clientId: clientCredentials.clientId,
clientSecret: clientCredentials.clientSecret,
logger,
})
}
public async getAccessToken(): Promise<string> {
await this._refreshTokenIfNeeded()
return this._credentials.accessToken.token
}
public getUserId(): string {
return this._credentials.linkedInUserId
}
public getGrantedScopes(): string[] {
return this._credentials.grantedScopes
}
private static async _exchangeCodeForTokens({
authorizationCode,
clientCredentials,
client,
ctx,
logger,
}: {
authorizationCode: string
clientCredentials: ClientCredentials
client: bp.Client
ctx: bp.Context
logger: bp.Logger
}): Promise<LinkedInOAuthClient> {
logger.forBot().debug('Exchanging authorization code for LinkedIn tokens')
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: OAUTH_REDIRECT_URI,
client_id: clientCredentials.clientId,
client_secret: clientCredentials.clientSecret,
})
const response = await fetch(LINKEDIN_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
if (!response.ok) {
const errorMsg = await formatLinkedInError(response, 'Failed to exchange authorization code')
logger.forBot().error('Failed to exchange authorization code for LinkedIn tokens', {
status: response.status,
})
throw new sdk.RuntimeError(errorMsg)
}
const tokenData = linkedInTokenResponseSchema.parse(await response.json())
logger.forBot().debug('Successfully obtained LinkedIn tokens')
logger.forBot().debug('Fetching LinkedIn user info')
const userInfo = await LinkedInOAuthClient._fetchUserInfo(tokenData.access_token, logger)
const credentials = LinkedInOAuthClient._generateCredentials(tokenData, userInfo.sub)
const oauthClient = new LinkedInOAuthClient({
credentials,
client,
ctx,
clientId: clientCredentials.clientId,
clientSecret: clientCredentials.clientSecret,
logger,
})
await oauthClient._saveCredentials()
logger.forBot().debug('LinkedIn OAuth credentials saved successfully')
return oauthClient
}
private async _refreshTokenIfNeeded(): Promise<void> {
const now = new Date().getTime()
if (!this._credentials.refreshToken) {
if (this._credentials.accessToken.expiresAt <= now + REFRESH_TOKEN_BUFFER_MS) {
this._logger.issue({
type: 'issue',
title: ACCESS_TOKEN_EXPIRED_ISSUE_TITLE,
description: ACCESS_TOKEN_EXPIRED_ISSUE_DESC,
category: 'configuration',
groupBy: ['access_token_expired'],
code: 'access_token_expired',
data: {
details: {
raw: ACCESS_TOKEN_EXPIRED_ISSUE_TITLE,
pretty: ACCESS_TOKEN_EXPIRED_ISSUE_DESC,
},
expiryDate: {
raw: new Date(this._credentials.accessToken.expiresAt).toISOString(),
},
},
})
}
return
}
if (this._credentials.accessToken.expiresAt > now + ACCESS_TOKEN_BUFFER_MS) {
return
}
this._logger.forBot().debug('LinkedIn access token expired or expiring soon, refreshing')
if (this._credentials.refreshToken.expiresAt) {
if (this._credentials.refreshToken.expiresAt <= now + REFRESH_TOKEN_BUFFER_MS) {
this._logger.issue({
type: 'issue',
title: REFRESH_TOKEN_EXPIRED_ISSUE_TITLE,
description: REFRESH_TOKEN_EXPIRED_ISSUE_DESC,
category: 'configuration',
groupBy: ['refresh_token_expired'],
code: 'refresh_token_expired',
data: {
details: {
raw: REFRESH_TOKEN_EXPIRED_ISSUE_TITLE,
pretty: REFRESH_TOKEN_EXPIRED_ISSUE_DESC,
},
expiryDate: {
raw: new Date(this._credentials.refreshToken.expiresAt).toISOString(),
},
},
})
}
}
await this._refreshAccessToken()
}
private async _refreshAccessToken(): Promise<void> {
if (!this._credentials.refreshToken) {
throw new sdk.RuntimeError('No refresh token available')
}
this._logger.forBot().debug('Refreshing LinkedIn access token')
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this._credentials.refreshToken.token,
client_id: this._clientId,
client_secret: this._clientSecret,
})
const response = await fetch(LINKEDIN_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
})
if (!response.ok) {
const headers = extractLinkedInHeaders(response)
const errorText = await response.text()
// LinkedIn returns 400 with specific error messages when refresh token is expired/revoked/invalid
// This requires the user to go through the full OAuth flow again to get a new refresh token
if (errorText.includes('expired') || errorText.includes('revoked') || errorText.includes('invalid')) {
this._logger.forBot().error('LinkedIn refresh token is expired, revoked, or invalid', {
status: response.status,
...headers,
})
throw new sdk.RuntimeError(
'LinkedIn refresh token has expired, been revoked, or is invalid. ' +
'Please re-authorize the integration through the OAuth flow to obtain a new refresh token. ' +
`(x-li-uuid: ${headers['x-li-uuid']}, x-li-request-id: ${headers['x-li-request-id']})`
)
}
this._logger.forBot().error('Failed to refresh LinkedIn access token', {
status: response.status,
...headers,
})
throw new sdk.RuntimeError(
`Failed to refresh LinkedIn access token: ${errorText} ` +
`(x-li-uuid: ${headers['x-li-uuid']}, x-li-request-id: ${headers['x-li-request-id']})`
)
}
const tokenData = linkedInTokenResponseSchema.parse(await response.json())
this._credentials = LinkedInOAuthClient._generateCredentials(
tokenData,
this._credentials.linkedInUserId,
this._credentials.grantedScopes
)
await this._saveCredentials()
this._logger.forBot().debug('LinkedIn access token refreshed successfully')
}
private async _saveCredentials(): Promise<void> {
await this._client.setState({
type: 'integration',
name: 'oauthCredentials',
id: this._ctx.integrationId,
payload: this._credentials,
})
}
private static _generateCredentials(
tokenData: sdk.z.infer<typeof linkedInTokenResponseSchema>,
userId: string,
defaultScopes?: string[]
) {
defaultScopes = defaultScopes ?? []
const now = new Date().getTime()
return {
accessToken: {
token: tokenData.access_token,
issuedAt: now,
expiresAt: now + tokenData.expires_in * 1000,
},
refreshToken: tokenData.refresh_token
? {
token: tokenData.refresh_token,
issuedAt: now,
expiresAt: tokenData.refresh_token_expires_in ? now + tokenData.refresh_token_expires_in * 1000 : undefined,
}
: undefined,
grantedScopes: tokenData.scope ? tokenData.scope.split(' ') : defaultScopes,
linkedInUserId: userId,
}
}
private static async _fetchUserInfo(accessToken: string, logger: bp.Logger): Promise<UserInfo> {
const response = await fetch(LINKEDIN_USERINFO_URL, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
if (!response.ok) {
const errorMsg = await formatLinkedInError(response, 'Failed to fetch LinkedIn user info')
logger.forBot().error('Failed to fetch LinkedIn user info', { status: response.status })
throw new sdk.RuntimeError(errorMsg)
}
logger.forBot().debug('Successfully fetched LinkedIn user info')
return userInfoSchema.parse(await response.json())
}
private static _getBotpressClientCredentials(): ClientCredentials {
return {
clientId: bp.secrets.CLIENT_ID,
clientSecret: bp.secrets.CLIENT_SECRET,
}
}
/**
* Resolves client credentials based on configuration type.
* - For manual config: uses user-provided clientId/clientSecret from ctx.configuration
* - For automatic OAuth: uses Botpress's official LinkedIn app credentials
*/
private static _getClientCredentials(ctx: bp.Context): ClientCredentials {
if (ctx.configurationType === 'manual') {
return {
clientId: ctx.configuration.clientId,
clientSecret: ctx.configuration.clientSecret,
}
}
return LinkedInOAuthClient._getBotpressClientCredentials()
}
}
@@ -0,0 +1,48 @@
import { z } from '@botpress/sdk'
export const linkedInTokenResponseSchema = z
.object({
access_token: z.string(),
expires_in: z.number(),
refresh_token: z.string().optional(),
refresh_token_expires_in: z.number().optional(),
scope: z.string().optional(),
})
.passthrough()
export type LinkedInTokenResponse = z.infer<typeof linkedInTokenResponseSchema>
export const linkedInErrorResponseSchema = z
.object({
message: z.string().optional(),
serviceErrorCode: z.number().optional(),
status: z.number().optional(),
})
.passthrough()
export type LinkedInErrorResponse = z.infer<typeof linkedInErrorResponseSchema>
export const userInfoSchema = z
.object({
sub: z.string(),
name: z.string().optional(),
given_name: z.string().optional(),
family_name: z.string().optional(),
picture: z.string().optional(),
email: z.string().optional(),
email_verified: z.boolean().optional(),
})
.passthrough()
export type UserInfo = z.infer<typeof userInfoSchema>
export const initializeUploadResponseSchema = z
.object({
value: z
.object({
uploadUrl: z.string(),
image: z.string(),
})
.passthrough(),
})
.passthrough()
+40
View File
@@ -0,0 +1,40 @@
import * as sdk from '@botpress/sdk'
import { LinkedInOAuthClient } from './linkedin-api'
import * as bp from '.botpress'
export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => {
logger.forBot().debug('Registering LinkedIn integration')
if (ctx.configurationType !== 'manual') {
return
}
logger.forBot().debug('Using manual configuration, exchanging authorization code for tokens')
const { clientId, clientSecret, authorizationCode } = ctx.configuration
try {
const oauthClient = await LinkedInOAuthClient.createFromManualConfig({
authorizationCode,
clientId,
clientSecret,
client,
ctx,
logger,
})
await client.configureIntegration({
identifier: oauthClient.getUserId(),
})
logger.forBot().info(`LinkedIn integration registered for user: ${oauthClient.getUserId()}`)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
logger.forBot().error(`Failed to exchange authorization code: ${message}`)
throw new sdk.RuntimeError(
`Failed to exchange authorization code: ${message}. The code may have expired - please generate a new one.`
)
}
}
export const unregister: bp.IntegrationProps['unregister'] = async ({}) => {}
+6
View File
@@ -0,0 +1,6 @@
export type TestCase<INPUT = unknown, EXPECTED = unknown> = {
input: INPUT
expects: EXPECTED
description: string
skip?: boolean
}
@@ -0,0 +1,20 @@
import * as bp from '.botpress'
export const dispatchWebhookEvent = async ({ req, logger }: bp.HandlerProps) => {
if (!req.body) {
logger.forBot().warn('Received empty LinkedIn webhook body')
return { status: 200 }
}
let event: unknown
try {
event = JSON.parse(req.body)
} catch (error) {
logger.forBot().error('Failed to parse webhook body', { error })
return { status: 200 }
}
logger.forBot().info('Received LinkedIn webhook event', { event })
return { status: 200 }
}
@@ -0,0 +1,2 @@
export { verifyLinkedInWebhook } from './verify'
export { dispatchWebhookEvent } from './dispatcher'
@@ -0,0 +1,43 @@
import * as crypto from 'crypto'
import * as bp from '.botpress'
const SIGNATURE_PREFIX = 'hmacsha256='
export const verifyLinkedInWebhook = ({ req, ctx, logger }: bp.HandlerProps): boolean => {
const signatureHeader = req.headers['x-li-signature']
if (!signatureHeader) {
logger.forBot().warn('Missing LinkedIn webhook signature (X-LI-Signature header)')
return false
}
if (!req.body) {
logger.forBot().warn('Missing webhook body')
return false
}
// LinkedIn signature format: "hmacsha256={signature}"
if (!signatureHeader.startsWith(SIGNATURE_PREFIX)) {
logger.forBot().warn(`Invalid signature format - missing ${SIGNATURE_PREFIX} prefix`)
return false
}
const receivedSignature = signatureHeader.slice(SIGNATURE_PREFIX.length)
const clientSecret = getClientSecret(ctx)
const expectedSignature = crypto.createHmac('sha256', clientSecret).update(req.body).digest('hex')
try {
return crypto.timingSafeEqual(Buffer.from(receivedSignature, 'hex'), Buffer.from(expectedSignature, 'hex'))
} catch (error) {
logger.forBot().error('Signature comparison failed', { error })
return false
}
}
function getClientSecret(ctx: bp.Context): string {
if (ctx.configurationType === 'manual') {
return ctx.configuration.clientSecret
}
return bp.secrets.CLIENT_SECRET
}