chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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'
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 ({}) => {}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user