diff --git a/integrations/linear/definitions/states.ts b/integrations/linear/definitions/states.ts index 7f885ccfd75..e6d3e86fed2 100644 --- a/integrations/linear/definitions/states.ts +++ b/integrations/linear/definitions/states.ts @@ -6,6 +6,10 @@ export const states = { type: 'integration', schema: z.object({ accessToken: z.string().title('Access Token').describe('The access token for Linear'), + refreshToken: z + .string() + .title('Refresh Token') + .describe('The refresh token needed when the access token expires'), expiresAt: z.string().title('Expires At').describe('The time when the access token expires'), }), }, diff --git a/integrations/linear/integration.definition.ts b/integrations/linear/integration.definition.ts index dd20a2c3e4c..2ba9492b3fb 100644 --- a/integrations/linear/integration.definition.ts +++ b/integrations/linear/integration.definition.ts @@ -7,7 +7,7 @@ import listable from './bp_modules/listable' import { actions, channels, events, configuration, configurations, user, states, entities } from './definitions' export const INTEGRATION_NAME = 'linear' -export const INTEGRATION_VERSION = '1.3.0' +export const INTEGRATION_VERSION = '2.0.0' export default new IntegrationDefinition({ name: INTEGRATION_NAME, diff --git a/integrations/linear/src/actions/create-issue.ts b/integrations/linear/src/actions/create-issue.ts index 1f8126a1d06..a9d64b5abb7 100644 --- a/integrations/linear/src/actions/create-issue.ts +++ b/integrations/linear/src/actions/create-issue.ts @@ -10,7 +10,7 @@ export const createIssue: bp.IntegrationProps['actions']['createIssue'] = async input: { title, description, priority, teamName, labels, project }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const team = await getTeam(linearClient, undefined, teamName) diff --git a/integrations/linear/src/actions/delete-issue.ts b/integrations/linear/src/actions/delete-issue.ts index 64827ee2f9d..110d1dd6c28 100644 --- a/integrations/linear/src/actions/delete-issue.ts +++ b/integrations/linear/src/actions/delete-issue.ts @@ -4,10 +4,10 @@ import * as bp from '.botpress' export const deleteIssue: bp.IntegrationProps['actions']['deleteIssue'] = async (args) => { const { - ctx, input: { id: issueId }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + + const linearClient = await getLinearClient(args) const existingIssue = await linearClient.issue(issueId).catch((thrown) => { if (thrown instanceof LinearError && thrown.type === LinearErrorType.InvalidInput) { diff --git a/integrations/linear/src/actions/find-target.ts b/integrations/linear/src/actions/find-target.ts index 74c6e1f4547..4b48cc7d366 100644 --- a/integrations/linear/src/actions/find-target.ts +++ b/integrations/linear/src/actions/find-target.ts @@ -23,8 +23,9 @@ const findIssues = async (issues: IssueConnection, targets: Target[]) => { export const findTarget: bp.IntegrationProps['actions']['findTarget'] = async (args) => { const targets: Target[] = [] - const { input, ctx } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const { input } = args + + const linearClient = await getLinearClient(args) const issues = await linearClient.issues({ filter: { or: [ diff --git a/integrations/linear/src/actions/get-issue.ts b/integrations/linear/src/actions/get-issue.ts index 400833effae..1f13f9deb87 100644 --- a/integrations/linear/src/actions/get-issue.ts +++ b/integrations/linear/src/actions/get-issue.ts @@ -20,10 +20,9 @@ export const getIssueFields = (issue: Issue): z.infer => ({ export const getIssue: bp.IntegrationProps['actions']['getIssue'] = async (args) => { const { - ctx, input: { issueId }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const issue = await linearClient.issue(issueId) return getIssueFields(issue) diff --git a/integrations/linear/src/actions/get-user.ts b/integrations/linear/src/actions/get-user.ts index eb5bd0deeb7..0b676eb5de1 100644 --- a/integrations/linear/src/actions/get-user.ts +++ b/integrations/linear/src/actions/get-user.ts @@ -6,10 +6,9 @@ import * as bp from '.botpress' export const getUser: bp.IntegrationProps['actions']['getUser'] = async (args) => { const { - ctx, input: { linearUserId }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const user = linearUserId ? await linearClient.user(linearUserId) : await linearClient.viewer return userProfileSchema.parse({ diff --git a/integrations/linear/src/actions/list-issues.ts b/integrations/linear/src/actions/list-issues.ts index 42f27a4606b..23b663ab642 100644 --- a/integrations/linear/src/actions/list-issues.ts +++ b/integrations/linear/src/actions/list-issues.ts @@ -6,10 +6,9 @@ import * as bp from '.botpress' export const listIssues: bp.IntegrationProps['actions']['listIssues'] = async (args) => { const { - ctx, input: { count, startCursor, startDate, teamId }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const query = await linearClient.issues({ orderBy: LinearDocument.PaginationOrderBy.UpdatedAt, diff --git a/integrations/linear/src/actions/list-states.ts b/integrations/linear/src/actions/list-states.ts index 8fe46ba5091..e00fc150056 100644 --- a/integrations/linear/src/actions/list-states.ts +++ b/integrations/linear/src/actions/list-states.ts @@ -4,11 +4,10 @@ import * as bp from '.botpress' export const listStates: bp.IntegrationProps['actions']['listStates'] = async (args) => { const { - ctx, input: { count, startCursor }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) try { const states = await linearClient.workflowStates({ after: startCursor, first: count }) diff --git a/integrations/linear/src/actions/list-teams.ts b/integrations/linear/src/actions/list-teams.ts index 46ea8dc7dc4..24d75d16b58 100644 --- a/integrations/linear/src/actions/list-teams.ts +++ b/integrations/linear/src/actions/list-teams.ts @@ -3,10 +3,9 @@ import * as bp from '.botpress' export const listTeams: bp.IntegrationProps['actions']['listTeams'] = async (args) => { const { - ctx, input: {}, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const teams = await linearClient.teams() return { diff --git a/integrations/linear/src/actions/list-users.ts b/integrations/linear/src/actions/list-users.ts index c6808be65d0..75403cd865b 100644 --- a/integrations/linear/src/actions/list-users.ts +++ b/integrations/linear/src/actions/list-users.ts @@ -5,10 +5,9 @@ import * as bp from '.botpress' export const listUsers: bp.IntegrationProps['actions']['listUsers'] = async (args) => { const { - ctx, input: { count, startCursor }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const query = await linearClient.users({ orderBy: LinearDocument.PaginationOrderBy.UpdatedAt, diff --git a/integrations/linear/src/actions/mark-as-duplicate.ts b/integrations/linear/src/actions/mark-as-duplicate.ts index f6b98ee049c..657ad9c2015 100644 --- a/integrations/linear/src/actions/mark-as-duplicate.ts +++ b/integrations/linear/src/actions/mark-as-duplicate.ts @@ -4,10 +4,9 @@ import * as bp from '.botpress' export const markAsDuplicate: bp.IntegrationProps['actions']['markAsDuplicate'] = async (args) => { const { - ctx, input: { issueId, relatedIssueId }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) await linearClient.createIssueRelation({ issueId, relatedIssueId, diff --git a/integrations/linear/src/actions/proactive-conversation.ts b/integrations/linear/src/actions/proactive-conversation.ts index 8326dfa3985..4415d11518a 100644 --- a/integrations/linear/src/actions/proactive-conversation.ts +++ b/integrations/linear/src/actions/proactive-conversation.ts @@ -5,8 +5,8 @@ import * as bp from '.botpress' export const getOrCreateIssueConversation: bp.IntegrationProps['actions']['getOrCreateIssueConversation'] = async ( args ) => { - const { client, input, ctx } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const { client, input } = args + const linearClient = await getLinearClient(args) const issue = await linearClient.issue(input.conversation.id).catch((thrown) => { const message = thrown instanceof Error ? thrown.message : new Error(thrown).message throw new RuntimeError(`Failed to get issue with ID ${input.conversation.id}: ${message}`) diff --git a/integrations/linear/src/actions/resolve-comment.ts b/integrations/linear/src/actions/resolve-comment.ts index 0c06d6f7108..64aa699040b 100644 --- a/integrations/linear/src/actions/resolve-comment.ts +++ b/integrations/linear/src/actions/resolve-comment.ts @@ -3,11 +3,10 @@ import * as bp from '.botpress' export const resolveComment: bp.IntegrationProps['actions']['resolveComment'] = async (args) => { const { - ctx, input: { id }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) try { const { success } = await linearClient.commentResolve(id) diff --git a/integrations/linear/src/actions/send-raw-graphql-query.ts b/integrations/linear/src/actions/send-raw-graphql-query.ts index 65822151bfb..fbaccb1cae0 100644 --- a/integrations/linear/src/actions/send-raw-graphql-query.ts +++ b/integrations/linear/src/actions/send-raw-graphql-query.ts @@ -4,7 +4,6 @@ import * as bp from '.botpress' export const sendRawGraphqlQuery: bp.IntegrationProps['actions']['sendRawGraphqlQuery'] = async (args) => { const { - ctx, input: { query, parameters }, } = args @@ -16,7 +15,7 @@ export const sendRawGraphqlQuery: bp.IntegrationProps['actions']['sendRawGraphql {} as Record ) try { - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const result = await linearClient.client.rawRequest(query, mappedParams) return { result: result.data } } catch (thrown) { diff --git a/integrations/linear/src/actions/update-issue.ts b/integrations/linear/src/actions/update-issue.ts index 842d866fc3d..cb94ef5ab9c 100644 --- a/integrations/linear/src/actions/update-issue.ts +++ b/integrations/linear/src/actions/update-issue.ts @@ -4,10 +4,9 @@ import * as bp from '.botpress' export const updateIssue: bp.IntegrationProps['actions']['updateIssue'] = async (args) => { const { - ctx, input: { issueId, teamName, labels, project, priority }, } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const existingIssue = await linearClient.issue(issueId) const team = await getTeam(linearClient, await existingIssue.team, teamName) diff --git a/integrations/linear/src/handler.ts b/integrations/linear/src/handler.ts index 7f7da6398e3..68b76ec78ec 100644 --- a/integrations/linear/src/handler.ts +++ b/integrations/linear/src/handler.ts @@ -12,9 +12,10 @@ import * as bp from '.botpress' const LINEAR_WEBHOOK_SIGNATURE_HEADER = 'linear-signature' const LINEAR_WEBHOOK_TS_FIELD = 'webhookTimestamp' -export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client, logger }) => { +export const handler: bp.IntegrationProps['handler'] = async (props) => { + const { req, ctx, client, logger } = props if (req.path === '/oauth') { - return handleOauth(req, client, ctx).catch((err) => { + return await handleOauth(props).catch((err) => { logger.forBot().error('Error while processing OAuth', err.response?.data || err.message) throw err }) @@ -130,7 +131,7 @@ const _getWebhookSigningSecret = ({ ctx }: { ctx: bp.Context }) => ctx.configurationType === 'apiKey' ? ctx.configuration.webhookSigningSecret : bp.secrets.WEBHOOK_SIGNING_SECRET const _getLinearBotId = async ({ client, ctx }: { client: bp.Client; ctx: bp.Context }) => { - const linearClient = await getLinearClient({ client, ctx }, ctx.integrationId) + const linearClient = await getLinearClient({ client, ctx }) const me = await linearClient.viewer return me.id } diff --git a/integrations/linear/src/misc/linear.ts b/integrations/linear/src/misc/linear.ts index 123d074d12b..f61b37e7b98 100644 --- a/integrations/linear/src/misc/linear.ts +++ b/integrations/linear/src/misc/linear.ts @@ -1,9 +1,11 @@ -import { z, Request, RuntimeError } from '@botpress/sdk' +import { RuntimeError, z } from '@botpress/sdk' import { LinearClient } from '@linear/sdk' import axios from 'axios' import queryString from 'query-string' import * as bp from '.botpress' +type Credentials = bp.states.States['credentials']['payload'] + type BaseEvent = { action: 'create' | 'update' | 'remove' | 'restore' type: string @@ -69,62 +71,117 @@ const oauthHeaders = { 'Content-Type': 'application/x-www-form-urlencoded', } as const -export async function getAccessToken(code: string) { - await axios.post( - `${linearEndpoint}/oauth/token`, - { - code, - grant_type: 'authorization_code', - }, - { - headers: oauthHeaders, - } - ) -} - const oauthSchema = z.object({ access_token: z.string(), + refresh_token: z.string(), expires_in: z.number(), }) +type OAuthResponse = z.infer + +const tokenRequestSchema = z.object({ + actor: z.literal('application'), + redirect_uri: z.string(), +}) + +const getAccessTokenRequestSchema = tokenRequestSchema.extend({ + grant_type: z.literal('authorization_code'), + code: z.string(), +}) + +const refreshTokenRequestSchema = tokenRequestSchema.extend({ + grant_type: z.literal('refresh_token'), + refresh_token: z.string(), +}) + +const migrateTokenRequestSchema = z.object({ + access_token: z.string(), +}) + export class LinearOauthClient { private _clientId: string private _clientSecret: string + private _redirectUri: string public constructor() { this._clientId = bp.secrets.CLIENT_ID this._clientSecret = bp.secrets.CLIENT_SECRET + this._redirectUri = `${process.env.BP_WEBHOOK_URL}/oauth` } - public async getAccessToken(code: string) { + private async _handleOAuthRequest( + url: string, + body: z.infer + ): Promise { + const { data } = await axios.post( + url, + { client_id: this._clientId, client_secret: this._clientSecret, ...body }, + { headers: oauthHeaders } + ) + return data + } + + private _parseCredentials(res: OAuthResponse): Credentials { + const { data, error } = oauthSchema.safeParse(res) + if (error) { + throw new RuntimeError(`Failed to parse OAuth token response: ${error.message}`) + } const expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + data.expires_in) - const res = await axios.post( - `${linearEndpoint}/oauth/token`, - { - client_id: this._clientId, - client_secret: this._clientSecret, - actor: 'application', - redirect_uri: `${process.env.BP_WEBHOOK_URL}/oauth`, - code, - grant_type: 'authorization_code', - }, + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: expiresAt.toISOString(), + } + } + + public async migrateOldToken(oldAccessToken: string): Promise { + const data = await this._handleOAuthRequest( + `${linearEndpoint}/oauth/migrate_old_token`, { - headers: oauthHeaders, + access_token: oldAccessToken, } ) + return this._parseCredentials(data) + } - const { access_token, expires_in } = oauthSchema.parse(res.data) + public async getAccessTokenFromRefreshToken(oldRefreshToken: string): Promise { + const data = await this._handleOAuthRequest(`${linearEndpoint}/oauth/token`, { + grant_type: 'refresh_token', + refresh_token: oldRefreshToken, + actor: 'application', + redirect_uri: this._redirectUri, + }) + return this._parseCredentials(data) + } - expiresAt.setSeconds(expiresAt.getSeconds() + expires_in) + public async getAccessTokenFromOAuthCode(code: string) { + const data = await this._handleOAuthRequest(`${linearEndpoint}/oauth/token`, { + grant_type: 'authorization_code', + code, + actor: 'application', + redirect_uri: this._redirectUri, + }) + if (!data.refresh_token) { + return this.migrateOldToken(data.access_token) + } + return this._parseCredentials(data) + } - return { - accessToken: access_token, - expiresAt: expiresAt.toISOString(), + public async resolveValidCredentials(current: Credentials): Promise { + const FIVE_MINUTES_MS = 5 * 60 * 1000 + const isExpired = new Date(current.expiresAt).getTime() <= Date.now() + FIVE_MINUTES_MS + + if (isExpired) { + return this.getAccessTokenFromRefreshToken(current.refreshToken) } + + return current } - public async getLinearClient(client: bp.Client, ctx: bp.Context, integrationId: string) { + public static async create(props: { client: bp.Client; ctx: bp.Context }) { + const { ctx, client } = props if (ctx.configurationType === 'apiKey') { return new LinearClient({ apiKey: ctx.configuration.apiKey }) } @@ -134,14 +191,21 @@ export class LinearOauthClient { } = await client.getState({ type: 'integration', name: 'credentials', - id: integrationId, + id: ctx.integrationId, }) - return new LinearClient({ accessToken: payload.accessToken }) + const linearOauthClient = new LinearOauthClient() + const credentials = await linearOauthClient.resolveValidCredentials(payload) + + if (credentials.accessToken !== payload.accessToken) { + await client.setState({ type: 'integration', name: 'credentials', id: ctx.integrationId, payload: credentials }) + } + + return new LinearClient({ accessToken: credentials.accessToken }) } } -export const handleOauth = async (req: Request, client: bp.Client, ctx: bp.Context) => { +export const handleOauth = async ({ req, ctx, client, logger }: bp.HandlerProps) => { const linearOauthClient = new LinearOauthClient() const query = queryString.parse(req.query) @@ -151,19 +215,18 @@ export const handleOauth = async (req: Request, client: bp.Client, ctx: bp.Conte throw new RuntimeError('Handler received an empty code') } - const { accessToken, expiresAt } = await linearOauthClient.getAccessToken(code) - + const credentials = await linearOauthClient.getAccessTokenFromOAuthCode(code) + // const oAuthResponse = await linearOauthClient.getAccessTokenFromOAuthCode(code) + // const credentials = await linearOauthClient.resolveValidCredentials(oAuthResponse) + logger.forBot().info('Obtained credentials from OAuth flow, saving to state...') await client.setState({ type: 'integration', name: 'credentials', id: ctx.integrationId, - payload: { - accessToken, - expiresAt, - }, + payload: credentials, }) - const linearClient = new LinearClient({ accessToken }) + const linearClient = new LinearClient({ accessToken: credentials.accessToken }) const organization = await linearClient.organization - await client.configureIntegration({ identifier: organization.id }) + await client.configureIntegration({ identifier: organization.id, scheduleRegisterCall: 'monthly' }) } diff --git a/integrations/linear/src/misc/utils.ts b/integrations/linear/src/misc/utils.ts index 8aabc4e0b7e..2cff106c254 100644 --- a/integrations/linear/src/misc/utils.ts +++ b/integrations/linear/src/misc/utils.ts @@ -7,16 +7,15 @@ export type LinearClientProps = { client: bp.Client ctx: bp.Context } -export function getLinearClient({ client, ctx }: LinearClientProps, integrationId: string) { - const linearOauthClient = new LinearOauthClient() - return linearOauthClient.getLinearClient(client, ctx, integrationId) +export async function getLinearClient({ client, ctx }: LinearClientProps) { + return await LinearOauthClient.create({ client, ctx }) } type ValueOf = T[keyof T] type CreateCommentProps = Omit, 'payload'> & { content: string } export async function createComment(args: CreateCommentProps) { const { ctx, conversation, ack, content } = args - const linearClient = await getLinearClient(args, ctx.integrationId) + const linearClient = await getLinearClient(args) const issueId = getIssueId(conversation) let createAsUser: string | undefined = undefined @@ -87,7 +86,7 @@ export const getUserAndConversation = async (props: { discriminateByTags: ['id'], }) - const linearClient = await getLinearClient(props, props.integrationId) + const linearClient = await getLinearClient(props) // TODO: better way to know if the conversation was just created if (props.forceUpdate || !conversation.tags.url) { diff --git a/integrations/linear/src/setup.ts b/integrations/linear/src/setup.ts index 9cbc2cc6c29..0bff9987abf 100644 --- a/integrations/linear/src/setup.ts +++ b/integrations/linear/src/setup.ts @@ -1,7 +1,10 @@ +import { LinearOauthClient } from './misc/linear' import * as bp from '.botpress' -export const register: bp.IntegrationProps['register'] = async () => { - // nothing to register +export const register: bp.IntegrationProps['register'] = async ({ client, ctx, logger }) => { + logger.forBot().info('Registering integration...') + await LinearOauthClient.create({ client, ctx }) + logger.forBot().info('Integration registered successfully.') } export const unregister: bp.IntegrationProps['unregister'] = async () => {