chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Generated files
|
||||
.botpress/
|
||||
.claude/
|
||||
|
||||
# Planning
|
||||
plan/
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
const { z } = sdk
|
||||
|
||||
export const actions = {
|
||||
createPost: {
|
||||
title: 'Create Post',
|
||||
description: 'Create a LinkedIn post with optional image or article link',
|
||||
input: {
|
||||
schema: z.object({
|
||||
text: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(3000)
|
||||
.title('Post Text')
|
||||
.describe('The text content of your LinkedIn post (required, max 3000 characters)'),
|
||||
visibility: z
|
||||
.enum(['PUBLIC', 'CONNECTIONS'])
|
||||
.title('Visibility')
|
||||
.describe('Who can see this post: PUBLIC (anyone) or CONNECTIONS (1st degree only)'),
|
||||
imageUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.title('Image URL')
|
||||
.describe(
|
||||
'URL of an image to attach (JPG, PNG, GIF, max 8MB). The image will be downloaded and uploaded to LinkedIn. If both imageUrl and articleUrl are provided, only the image will be used.'
|
||||
),
|
||||
articleUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.title('Article URL')
|
||||
.describe(
|
||||
'URL of an article/link to share. LinkedIn will generate a preview card. Ignored if imageUrl is provided.'
|
||||
),
|
||||
articleTitle: z.string().optional().title('Article Title').describe('Custom title for the article preview.'),
|
||||
articleDescription: z
|
||||
.string()
|
||||
.optional()
|
||||
.title('Article Description')
|
||||
.describe(
|
||||
'Custom description for the article preview (optional - LinkedIn will scrape from URL if not provided)'
|
||||
),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
postUrn: z
|
||||
.string()
|
||||
.title('Post URN')
|
||||
.describe('The URN identifier of the created post (e.g., urn:li:ugcPost:123456)'),
|
||||
postUrl: z.string().title('Post URL').describe('Direct link to view the post on LinkedIn'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
deletePost: {
|
||||
title: 'Delete Post',
|
||||
description: 'Delete a LinkedIn post created by the authenticated user',
|
||||
input: {
|
||||
schema: z.object({
|
||||
postUrn: z
|
||||
.string()
|
||||
.title('Post URN')
|
||||
.describe('The URN of the post to delete (e.g., urn:li:ugcPost:123456 or urn:li:share:123456)'),
|
||||
}),
|
||||
},
|
||||
output: {
|
||||
schema: z.object({
|
||||
success: z.boolean().title('Success').describe('Whether the post was successfully deleted'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['actions']
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
const { z } = sdk
|
||||
|
||||
export const configuration = {
|
||||
identifier: {
|
||||
linkTemplateScript: 'linkTemplate.vrl',
|
||||
required: true,
|
||||
},
|
||||
schema: z.object({}),
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['configuration']
|
||||
|
||||
export const configurations = {
|
||||
manual: {
|
||||
title: 'Manual Configuration',
|
||||
description: 'Configure with your own LinkedIn Developer application',
|
||||
schema: z.object({
|
||||
clientId: z.string().min(1).title('Client ID').describe('Client ID from your LinkedIn Developer application'),
|
||||
clientSecret: z
|
||||
.string()
|
||||
.min(1)
|
||||
.secret()
|
||||
.title('Client Secret')
|
||||
.describe('Primary Client Secret from your LinkedIn Developer application'),
|
||||
authorizationCode: z.string().min(1).secret().title('Authorization Code').describe('Authorization Code'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['configurations']
|
||||
|
||||
export const identifier = {
|
||||
extractScript: 'extract.vrl',
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['identifier']
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './secrets'
|
||||
export * from './states'
|
||||
export * from './configuration'
|
||||
export * from './actions'
|
||||
@@ -0,0 +1,12 @@
|
||||
import { posthogHelper } from '@botpress/common'
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
export const secrets = {
|
||||
CLIENT_ID: {
|
||||
description: 'Botpress LinkedIn OAuth Client ID',
|
||||
},
|
||||
CLIENT_SECRET: {
|
||||
description: 'Botpress LinkedIn OAuth Client Secret',
|
||||
},
|
||||
...posthogHelper.COMMON_SECRET_NAMES,
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['secrets']
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
|
||||
const { z } = sdk
|
||||
|
||||
export const states = {
|
||||
oauthCredentials: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
accessToken: z
|
||||
.object({
|
||||
token: z.string().secret(),
|
||||
issuedAt: z.number(),
|
||||
expiresAt: z.number(),
|
||||
})
|
||||
.title('Access Token')
|
||||
.describe('The access token generated by LinkedIn'),
|
||||
refreshToken: z
|
||||
.object({
|
||||
token: z.string().secret(),
|
||||
issuedAt: z.number(),
|
||||
expiresAt: z.number().optional(),
|
||||
})
|
||||
.optional()
|
||||
.title('Refresh Token')
|
||||
.describe('The refresh token generated by LinkedIn'),
|
||||
grantedScopes: z.array(z.string()).title('Granted Scopes').describe('The scopes granted by the user'),
|
||||
linkedInUserId: z.string().title('LinkedIn User ID').describe('The user ID of the authenticated user'),
|
||||
}),
|
||||
},
|
||||
processedNotifications: {
|
||||
type: 'integration',
|
||||
schema: z.object({
|
||||
notificationIds: z
|
||||
.array(z.string())
|
||||
.title('Notification IDs')
|
||||
.describe('Rolling list of recently processed notification IDs to prevent duplicate processing'),
|
||||
}),
|
||||
},
|
||||
} as const satisfies sdk.IntegrationDefinitionProps['states']
|
||||
@@ -0,0 +1,17 @@
|
||||
# Parse incoming webhook body
|
||||
body = parse_json!(.body)
|
||||
|
||||
# LinkedIn webhooks contain actor/author URNs like "urn:li:person:abc123"
|
||||
# We need to extract just the ID to match our OAuth identifier
|
||||
# Fallback chain: actor -> author -> owner
|
||||
|
||||
actorUrn = body.actor ?? body.author ?? body.owner ?? ""
|
||||
|
||||
# Extract ID from URN format: "urn:li:person:abc123" -> "abc123"
|
||||
# Split by colon and take the last segment
|
||||
if actorUrn != "" {
|
||||
parts = split(actorUrn, ":")
|
||||
parts[length(parts) - 1]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# LinkedIn
|
||||
|
||||
Connect your Botpress chatbot to LinkedIn to share posts and engage with your professional network. This integration enables your bot to interact with LinkedIn's API using OAuth authentication.
|
||||
|
||||
## Configuration
|
||||
|
||||
The LinkedIn integration requires OAuth authentication to establish a secure connection between Botpress and LinkedIn. You can configure the integration using either automatic or manual configuration methods.
|
||||
|
||||
### Automatic configuration with OAuth
|
||||
|
||||
To set up the LinkedIn integration using automatic configuration, click the authorization button and follow the on-screen instructions to connect your Botpress chatbot to LinkedIn.
|
||||
|
||||
When using this configuration mode, a Botpress-managed LinkedIn application will be used to connect to your LinkedIn account. Actions taken by the bot will be attributed to the LinkedIn account that authorized the connection.
|
||||
|
||||
#### Configuring the integration in Botpress
|
||||
|
||||
1. Authorize the LinkedIn integration by clicking the authorization button.
|
||||
2. Follow the on-screen instructions to connect your Botpress chatbot to LinkedIn.
|
||||
3. Once the connection is established, you can save the configuration and enable the integration.
|
||||
|
||||
### Manual configuration with OAuth
|
||||
|
||||
To set up the LinkedIn integration manually, you must create a LinkedIn application and configure OAuth credentials. You will also need to obtain an authorization code and configure the integration in Botpress.
|
||||
|
||||
#### Creating a LinkedIn Application
|
||||
|
||||
1. Go to the [LinkedIn Developer Portal](https://www.linkedin.com/developers/apps).
|
||||
2. Click the `Create app` button.
|
||||
3. Fill in the required information:
|
||||
- App name
|
||||
- LinkedIn Page (you must associate your app with a LinkedIn Page)
|
||||
- App logo
|
||||
- Legal agreement
|
||||
4. Click `Create app` to create your application.
|
||||
|
||||
#### Configuring OAuth Settings
|
||||
|
||||
1. In your LinkedIn application settings, navigate to the `Products` tab.
|
||||
2. Request access to the following products:
|
||||
- `Share on LinkedIn` - Required for posting content
|
||||
- `Sign In with LinkedIn using OpenID Connect` - Required for authentication
|
||||
3. Wait for approval (this may be instant or require review by LinkedIn).
|
||||
4. Navigate to the `Auth` tab.
|
||||
5. Under `OAuth 2.0 settings`, add the following redirect URL:
|
||||
```
|
||||
https://webhook.botpress.cloud/oauth
|
||||
```
|
||||
6. Copy your **Client ID** and **Client Secret** for use in the next steps.
|
||||
|
||||
#### Authorizing the OAuth Application
|
||||
|
||||
1. Construct the authorization URL with your Client ID:
|
||||
|
||||
```
|
||||
https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https://webhook.botpress.cloud/oauth&scope=openid%20profile%20email%20w_member_social&state=manual
|
||||
```
|
||||
|
||||
> Replace `YOUR_CLIENT_ID` with your actual Client ID.
|
||||
|
||||
2. Visit this URL in your browser while logged into the LinkedIn account you want to use with the integration.
|
||||
3. Follow the on-screen instructions to authorize the application.
|
||||
4. You will be redirected to `webhook.botpress.cloud`. **Do not close this page**.
|
||||
5. Copy the **authorization code** from the URL in your browser's address bar.
|
||||
> The authorization code is the string that appears after `code=` in the URL.
|
||||
6. You may now safely close this page.
|
||||
|
||||
#### Configuring the integration in Botpress
|
||||
|
||||
1. Select the `Manual` configuration mode in the Botpress integration settings.
|
||||
2. Enter your LinkedIn **Client ID** and **Client Secret**.
|
||||
3. Enter the **authorization code** you obtained in the previous step.
|
||||
> The authorization code is only valid for a short period of time. If the code has expired, you will need to repeat the authorization steps to obtain a new code.
|
||||
4. Save the configuration and enable the integration.
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#0076b2" d="M21.75 0.5625H2.25C1.42031 0.5625 0.75 1.23281 0.75 2.0625V21.5625C0.75 22.3922 1.42031 23.0625 2.25 23.0625H21.75C22.5797 23.0625 23.25 22.3922 23.25 21.5625V2.0625C23.25 1.23281 22.5797 0.5625 21.75 0.5625"/>
|
||||
<path fill="#fff" d="M3.94219 9.14531H7.33594V20.0156H3.94219V9.14531ZM5.64063 3.70313C6.60469 3.70313 7.38281 4.48125 7.38281 5.44531C7.38281 6.40938 6.60469 7.1875 5.64063 7.1875C4.67656 7.1875 3.89844 6.40938 3.89844 5.44531C3.89844 4.48125 4.67656 3.70313 5.64063 3.70313ZM9.46875 9.14531H12.7219V10.6406H12.7688C13.2141 9.79219 14.3578 8.89688 16.0578 8.89688C19.5234 8.89688 20.0625 11.1328 20.0625 14.0625V20.0156H16.6734V14.7328C16.6734 13.4672 16.6500 11.8578 14.9266 11.8578C13.1797 11.8578 12.9047 13.2328 12.9047 14.625V20.0156H9.51563V9.14531H9.46875Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1,25 @@
|
||||
import { IntegrationDefinition } from '@botpress/sdk'
|
||||
import { configuration, configurations, identifier, states, secrets, actions } from './definitions'
|
||||
|
||||
export const INTEGRATION_NAME = 'linkedin'
|
||||
export const INTEGRATION_VERSION = '0.1.4'
|
||||
|
||||
export default new IntegrationDefinition({
|
||||
name: INTEGRATION_NAME,
|
||||
version: INTEGRATION_VERSION,
|
||||
title: 'LinkedIn',
|
||||
description: 'Connect to LinkedIn to share posts and engage with your professional network.',
|
||||
readme: 'hub.md',
|
||||
icon: 'icon.svg',
|
||||
configuration,
|
||||
configurations,
|
||||
identifier,
|
||||
states,
|
||||
secrets,
|
||||
actions,
|
||||
attributes: {
|
||||
category: 'Business Operations',
|
||||
guideSlug: 'linkedin',
|
||||
repo: 'botpress',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
webhookId = to_string!(.webhookId)
|
||||
webhookUrl = to_string!(.webhookUrl)
|
||||
env = to_string!(.env)
|
||||
|
||||
clientId = "789ku4ucqpvndz"
|
||||
if env == "production" {
|
||||
clientId = "786rl66mwxted8"
|
||||
}
|
||||
|
||||
redirectUri = "{{ webhookUrl }}/oauth"
|
||||
|
||||
"https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id={{ clientId }}&redirect_uri={{ redirectUri }}&state={{ webhookId }}&scope=openid%20profile%20email%20w_member_social"
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@botpresshub/linkedin",
|
||||
"scripts": {
|
||||
"build": "bp add -y && bp build",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@types/node": "^22.16.4",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
},
|
||||
"include": [".botpress/**/*", "definitions/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user