diff --git a/bots/bugbuster/src/services/command-processor.ts b/bots/bugbuster/src/services/command-processor.ts index 5699c0a879d..c8ca88b8537 100644 --- a/bots/bugbuster/src/services/command-processor.ts +++ b/bots/bugbuster/src/services/command-processor.ts @@ -13,7 +13,8 @@ export class CommandProcessor { private _listTeams: types.CommandImplementation = async () => { const teams = await this._teamsManager.listWatchedTeams() - return { success: true, message: teams.join(', ') } + const message = teams.length > 0 ? teams.join(', ') : 'You have no watched teams.' + return { success: true, message } } private _addTeam: types.CommandImplementation = async ([team]: string[]) => { diff --git a/bots/bugbuster/src/services/issue-processor/index.ts b/bots/bugbuster/src/services/issue-processor/index.ts index e202bc8079f..bdffca0ec68 100644 --- a/bots/bugbuster/src/services/issue-processor/index.ts +++ b/bots/bugbuster/src/services/issue-processor/index.ts @@ -40,6 +40,9 @@ export class IssueProcessor { public async listRelevantIssues(endCursor?: string): Promise<{ issues: lin.Issue[]; pagination?: lin.Pagination }> { const watchedTeams = await this._teamsManager.listWatchedTeams() + if (watchedTeams.length === 0) { + throw new Error('You have no watched teams.') + } return await this._linear.listIssues( { diff --git a/bots/bugbuster/src/services/teams-manager.ts b/bots/bugbuster/src/services/teams-manager.ts index d24a9cb14c3..df271011975 100644 --- a/bots/bugbuster/src/services/teams-manager.ts +++ b/bots/bugbuster/src/services/teams-manager.ts @@ -30,9 +30,6 @@ export class TeamsManager { public async listWatchedTeams(): Promise { const teamKeys = await this._getWatchedTeams() - if (teamKeys.length === 0) { - throw new Error('You have no watched teams.') - } return teamKeys } diff --git a/bots/slackbox/package.json b/bots/slackbox/package.json index 0181b6727ac..0c9aaf21b02 100644 --- a/bots/slackbox/package.json +++ b/bots/slackbox/package.json @@ -1,7 +1,7 @@ { "name": "@bp-bots/slackbox", "scripts": { - "postinstall": "genenv -o ./.genenv/index.ts -e SLACKBOX_SLACK_REFRESH_TOKEN -e SLACKBOX_SLACK_CLIENT_ID -e SLACKBOX_SLACK_CLIENT_SECRET -e SLACKBOX_SLACK_SIGNING_SECRET -e SLACKBOX_GMAIL_OAUTH_CLIENT_ID -e SLACKBOX_GMAIL_OAUTH_CLIENT_SECRET -e SLACKBOX_GMAIL_OAUTH_AUTHORIZATION_CODE -e SLACKBOX_GMAIL_PUBSUB_TOPIC_NAME -e SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SHARED_SECRET -e SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SERVICE_ACCOUNT -e SLACKBOX_SLACK_CHANNEL", + "postinstall": "genenv -o ./.genenv/index.ts -e SLACKBOX_SLACK_REFRESH_TOKEN -e SLACKBOX_SLACK_CLIENT_ID -e SLACKBOX_SLACK_CLIENT_SECRET -e SLACKBOX_SLACK_SIGNING_SECRET -e SLACKBOX_GMAIL_OAUTH_CLIENT_ID -e SLACKBOX_GMAIL_OAUTH_CLIENT_SECRET -e SLACKBOX_GMAIL_OAUTH_AUTHORIZATION_CODE -e SLACKBOX_GMAIL_PUBSUB_TOPIC_NAME -e SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SHARED_SECRET -e SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SERVICE_ACCOUNT -e SLACKBOX_SLACK_CHANNEL -e SLACKBOX_FALLBACK_SLACK_CHANNEL", "check:type": "tsc --noEmit", "check:bplint": "bp lint", "build": "bp add -y && bp build" diff --git a/bots/slackbox/src/index.ts b/bots/slackbox/src/index.ts index 768497386aa..13a7610cdee 100644 --- a/bots/slackbox/src/index.ts +++ b/bots/slackbox/src/index.ts @@ -4,38 +4,9 @@ import * as genenv from '../.genenv' import * as bp from '.botpress' const DEFAULT_SLACK_CHANNEL = genenv.SLACKBOX_SLACK_CHANNEL +const FALLBACK_SLACK_CHANNEL = genenv.SLACKBOX_FALLBACK_SLACK_CHANNEL || DEFAULT_SLACK_CHANNEL -let cachedSlackConversationId: string | undefined - -const getSlackConversationId = async (client: bp.Client, logger: bp.MessageHandlerProps['logger']): Promise => { - if (cachedSlackConversationId) { - return cachedSlackConversationId - } - - logger.info('Fetching Slack conversation ID (first time)') - const maxRetries = 3 - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const response = await client.callAction({ - type: 'slack:startChannelConversation', - input: { - channelName: DEFAULT_SLACK_CHANNEL, - }, - }) - cachedSlackConversationId = response.output.conversationId - return cachedSlackConversationId - } catch (err) { - logger.warn(`Attempt ${attempt}/${maxRetries} failed: ${err}`) - if (attempt === maxRetries) { - throw err - } - await new Promise((resolve) => setTimeout(resolve, 2000)) - } - } - - throw new Error('Failed to get Slack conversation ID after retries') -} +const cachedSlackConversationIds: Record = {} const bot = new bp.Bot({ actions: {}, @@ -45,11 +16,21 @@ bot.on.message('*', async (props) => { const { conversation, message, client, ctx, logger } = props if (!conversation.integration.includes('gmail')) { + logger.info('[Slackbox] Not a Gmail message, skipping') return } try { - const slackConversationId = await getSlackConversationId(client, logger) + const shouldForward = await _shouldForwardEmail(client, conversation, logger) + if (!shouldForward) { + logger.info('Email filtered out - no matching Integrations label') + return + } + + const subject = (conversation.tags['gmail:subject'] || conversation.tags.subject) as string | undefined + const targetChannel = _getTargetChannel(subject) + const slackConversationId = await _getSlackConversationId(client, logger, targetChannel) + const notificationMessage = _mapGmailToSlack(conversation, message) await client.createMessage({ @@ -61,13 +42,116 @@ bot.on.message('*', async (props) => { text: notificationMessage, }, }) - - logger.info('Email notification sent to Slack') } catch (error) { logger.error(`Failed to send email notification: ${error}`) } }) +const _shouldForwardEmail = async ( + client: bp.Client, + conversation: Conversation, + logger: bp.MessageHandlerProps['logger'] +): Promise => { + const threadId = conversation.tags['gmail:id'] + + if (!threadId) { + logger.info('[LabelCheck] No threadId, forwarding email') + return true + } + + try { + const labelsResponse = await client.callAction({ + type: 'gmail:listLabels', + input: {}, + }) + const labels = labelsResponse.output.labels || [] + + const threadResponse = await client.callAction({ + type: 'gmail:getThread', + input: { id: threadId }, + }) + const messages = threadResponse.output.messages || [] + + const allLabelIds = new Set() + messages.forEach((msg) => { + msg.labelIds?.forEach((labelId) => allLabelIds.add(labelId)) + }) + + if (allLabelIds.size === 0) { + logger.info('[LabelCheck] No labels on thread, forwarding email') + return true + } + + const labelsById = new Map() + labels.forEach((label) => { + if (label.id) { + labelsById.set(label.id, { type: label.type || undefined, name: label.name || undefined }) + } + }) + + const userLabels = [...allLabelIds].filter((labelId) => { + const label = labelsById.get(labelId) + return label?.type === 'user' + }) + + if (userLabels.length === 0) { + logger.info('[LabelCheck] No user labels, forwarding email') + return true + } + + const hasIntegrationLabel = userLabels.some((labelId) => { + const label = labelsById.get(labelId) + return label?.name === 'Integrations' || label?.name?.startsWith('Integrations/') + }) + + return hasIntegrationLabel + } catch (error) { + logger.error(`[LabelCheck] Error checking email labels: ${error}`) + return true + } +} + +const _getSlackConversationId = async ( + client: bp.Client, + logger: bp.MessageHandlerProps['logger'], + channelName: string +): Promise => { + if (cachedSlackConversationIds[channelName]) { + return cachedSlackConversationIds[channelName] + } + + logger.info(`Fetching Slack conversation ID for channel: ${channelName}`) + const maxRetries = 3 + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await client.callAction({ + type: 'slack:startChannelConversation', + input: { + channelName, + }, + }) + cachedSlackConversationIds[channelName] = response.output.conversationId + return cachedSlackConversationIds[channelName] + } catch (err) { + logger.warn(`Attempt ${attempt}/${maxRetries} failed: ${err}`) + if (attempt === maxRetries) { + throw err + } + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } + + throw new Error('Failed to get Slack conversation ID after retries') +} + +const _getTargetChannel = (subject: string | undefined): string => { + if (subject?.toLowerCase().includes('test')) { + return FALLBACK_SLACK_CHANNEL + } + return DEFAULT_SLACK_CHANNEL +} + const _mapGmailToSlack = (conversation: Conversation, message: AnyIncomingMessage) => { const subject = (conversation.tags['gmail:subject'] || conversation.tags.subject) as string | undefined const fromEmail = (conversation.tags['gmail:email'] || conversation.tags.email) as string | undefined diff --git a/integrations/gmail/integration.definition.ts b/integrations/gmail/integration.definition.ts index 407397684ea..56f3edaf394 100644 --- a/integrations/gmail/integration.definition.ts +++ b/integrations/gmail/integration.definition.ts @@ -12,7 +12,7 @@ import { } from './definitions' export const INTEGRATION_NAME = 'gmail' -export const INTEGRATION_VERSION = '1.0.3' +export const INTEGRATION_VERSION = '1.0.4' export default new sdk.IntegrationDefinition({ name: INTEGRATION_NAME, diff --git a/integrations/gmail/src/webhook-events/new-mail.ts b/integrations/gmail/src/webhook-events/new-mail.ts index 85eca66e969..4ad5a5967ba 100644 --- a/integrations/gmail/src/webhook-events/new-mail.ts +++ b/integrations/gmail/src/webhook-events/new-mail.ts @@ -1,4 +1,6 @@ // @ts-ignore +import { AxiosError } from 'axios' +// @ts-ignore import parseMessage from 'gmail-api-parse-message' import { parse as parseHtml } from 'node-html-parser' import { GoogleClient } from '../google-api' @@ -6,13 +8,13 @@ import { decodeBase64URL } from '../utils/string-utils' import * as bp from '.botpress' export const handleIncomingEmail = async (props: bp.HandlerProps) => { - const { req, client, ctx } = props + const { req, client, ctx, logger } = props const bodyContent = JSON.parse(req.body || '{}') const data = bodyContent.message?.data if (!data) { - console.warn('Handler received an invalid body (no data)') + logger.warn('Handler received an invalid body (no data)') return } @@ -22,13 +24,10 @@ export const handleIncomingEmail = async (props: bp.HandlerProps) => { const historyId = `${historyIdNumber}` if (!historyId) { - console.warn('Handler received an invalid body (no historyId)') + logger.warn('Handler received an invalid body (no historyId)') return } - // Only proceed if the incoming historyId is greater that the latest processed historyId - const googleClient = await GoogleClient.create({ client, ctx }) - const { state: { payload }, } = await client.getState({ @@ -39,18 +38,21 @@ export const handleIncomingEmail = async (props: bp.HandlerProps) => { const lastHistoryId = payload.lastHistoryId ?? _fakeHistoryId(historyId) - if (!payload.lastHistoryId) { - await client.getOrSetState({ - type: 'integration', - name: 'configuration', - id: ctx.integrationId, - payload: { - ...payload, - lastHistoryId, - }, - }) + if (Number(historyId) <= Number(lastHistoryId)) { + logger.info(`HistoryId ${historyId} already processed (last: ${lastHistoryId}), skipping`) + return } + await client.setState({ + type: 'integration', + name: 'configuration', + id: ctx.integrationId, + payload: { + ...payload, + lastHistoryId: historyId, + }, + }) + const googleClient = await GoogleClient.create({ client, ctx }) const history = await googleClient.getMyHistory(lastHistoryId) const messageIds = history.history?.reduce((acc, h) => { @@ -64,37 +66,38 @@ export const handleIncomingEmail = async (props: bp.HandlerProps) => { }, [] as string[]) if (!messageIds?.length) { - console.info('Handler received an empty message id') + logger.info('Handler received an empty message id') return } for (const messageId of messageIds) { - await _processMessage(props, messageId, googleClient, emailAddress) + await _processMessage(props, messageId, googleClient, emailAddress, logger) } - - await client.getOrSetState({ - type: 'integration', - name: 'configuration', - id: ctx.integrationId, - payload: { - ...payload, - lastHistoryId: historyId, - }, - }) } const _processMessage = async ( { client }: bp.HandlerProps, messageId: string, googleClient: GoogleClient, - emailAddress: string + emailAddress: string, + logger: bp.HandlerProps['logger'] ) => { - const gmailMessage = await googleClient.messages.get(messageId) + let gmailMessage + try { + gmailMessage = await googleClient.messages.get(messageId) + } catch (error: unknown) { + if (error instanceof AxiosError && (error?.code === '404' || error?.response?.status === 404)) { + logger.info(`Message ${messageId} not found, skipping (likely deleted)`) + return + } + throw error + } + const message = parseMessage(gmailMessage) const threadId = message.threadId if (!threadId) { - console.info('Handler received an empty chat id') + logger.info('Handler received an empty chat id') throw new Error('Handler received an empty chat id') } @@ -150,17 +153,26 @@ const _processMessage = async ( // Extract the text content: content = messageRoot.structuredText } catch (thrown) { - console.error('Error while parsing html content', thrown) + logger.error('Error while parsing html content', thrown) } } - await client.getOrCreateMessage({ - tags: { id: messageId }, - type: 'text', - userId: user.id, - conversationId: conversation.id, - payload: { text: content }, - }) + try { + await client.getOrCreateMessage({ + tags: { id: messageId }, + type: 'text', + userId: user.id, + conversationId: conversation.id, + payload: { text: content }, + }) + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)) + if (err?.message?.includes('already exists for a different conversation')) { + logger.info(`Message ${messageId} already exists, skipping`) + return + } + throw error + } await client.getOrSetState({ type: 'conversation', diff --git a/integrations/linear/.sentryclirc b/integrations/linear/.sentryclirc deleted file mode 100644 index 07b4cbb7ce6..00000000000 --- a/integrations/linear/.sentryclirc +++ /dev/null @@ -1,5 +0,0 @@ -[defaults] -project=integration-linear - -[auth] -dsn=https://166dae5f279f4f21a84a22bb4f073fa5@o363631.ingest.sentry.io/4505274818428928 \ No newline at end of file diff --git a/integrations/linear/definitions/actions.ts b/integrations/linear/definitions/actions.ts index 95a0ecc28fe..073b0cb1c60 100644 --- a/integrations/linear/definitions/actions.ts +++ b/integrations/linear/definitions/actions.ts @@ -81,7 +81,7 @@ const userSchema = z.object({ .title('Description') .describe("The user's description, such as their title or bio"), lastSeen: z.string().optional().title('Last Seen').describe('The last time the user was seen'), - updatedAt: z.date().title('Updated At').describe('The last time the user was updated'), + updatedAt: z.string().datetime().title('Updated At').describe('The last time the user was updated'), }) const listUsers = { @@ -113,6 +113,7 @@ const listTeams = { .array( z.object({ id: z.string().title('ID').describe('The unique identifier of the entity'), + key: z.string().title('Key').describe("The team's key"), name: z.string().title('Name').describe("The team's name"), description: z.string().optional().title('Description').describe("The team's description"), icon: z.string().optional().title('Icon').describe('The icon of the team'), @@ -124,6 +125,31 @@ const listTeams = { }, } as const satisfies ActionDefinition +const listStates = { + title: 'List States', + description: 'List states from Linear', + input: { + schema: z.object({ + count: z.number().optional().default(10).title('Fetch Amount').describe('The number of states to return'), + startCursor: z.string().optional().title('Start Cursor').describe('The cursor to start from'), + }), + }, + output: { + schema: z.object({ + states: z + .array( + z.object({ + id: z.string().title('ID').describe('The unique identifier of the entity'), + name: z.string().title('Name').describe("The state's name"), + }) + ) + .title('States') + .describe('The list of states'), + nextCursor: z.string().optional().title('Next Cursor').describe('The cursor to fetch the next page'), + }), + }, +} as const satisfies ActionDefinition + const markAsDuplicate = { title: 'Mark Issue as Duplicate', description: 'Mark an issue as a duplicate of another', @@ -145,8 +171,11 @@ const getUser = { schema: z.object({ linearUserId: z .string() + .optional() .title('User ID') - .describe("The user's ID on Linear. Ex: {{event.payload.linearIds.creatorId}}"), + .describe( + "The user's ID on Linear. Ex: {{event.payload.linearIds.creatorId}}. If omitted, returns the current user." + ), }), }, output: { @@ -214,15 +243,58 @@ const deleteIssue = { }, } as const satisfies ActionDefinition +const sendRawGraphqlQuery = { + title: 'Send Raw GraphQL Query', + description: 'Send a raw GraphQL query to the linear API', + input: { + schema: z.object({ + query: z.string().title('Query').describe('The GraphQL query'), + parameters: z + .array( + z.object({ + name: z.string().title('Name').describe('The parameter name'), + value: z.any().title('Value').describe('The parameter value'), + }) + ) + .optional() + .title('Parameters') + .describe('The query parameters'), + }), + }, + output: { + schema: z.object({ + result: z.unknown().title('Result').describe('The query result'), + }), + }, +} as const satisfies ActionDefinition + +const resolveComment = { + title: 'Resolve Comment', + description: 'Resolve a comment by id', + input: { + schema: z.object({ + id: z.string().title('ID').describe('The comment ID'), + }), + }, + output: { + schema: z.object({ + success: z.boolean().title('Success').describe('Whether the operation was successful'), + }), + }, +} as const satisfies ActionDefinition + export const actions = { findTarget, listIssues, listTeams, listUsers, + listStates, markAsDuplicate, getIssue, getUser, updateIssue, createIssue, deleteIssue, + sendRawGraphqlQuery, + resolveComment, } as const satisfies IntegrationDefinitionProps['actions'] diff --git a/integrations/linear/definitions/index.ts b/integrations/linear/definitions/index.ts index a1b18c1d660..a71accccfeb 100644 --- a/integrations/linear/definitions/index.ts +++ b/integrations/linear/definitions/index.ts @@ -90,4 +90,9 @@ export const entities = { description: 'A linear issue', schema: issueSchema, }, + issueConversation: { + title: 'Issue Conversation', + description: 'A conversation representing a linear issue', + schema: z.object({ id: z.string().title('Issue ID').describe('The issue ID on Linear') }), + }, } as const satisfies IntegrationDefinitionProps['entities'] diff --git a/integrations/linear/integration.definition.ts b/integrations/linear/integration.definition.ts index d82cfbdd7dd..dd20a2c3e4c 100644 --- a/integrations/linear/integration.definition.ts +++ b/integrations/linear/integration.definition.ts @@ -1,12 +1,17 @@ +import { posthogHelper } from '@botpress/common' import { IntegrationDefinition } from '@botpress/sdk' -import { sentry as sentryHelpers } from '@botpress/sdk-addons' +import proactiveConversation from 'bp_modules/proactive-conversation' import deletable from './bp_modules/deletable' 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 default new IntegrationDefinition({ - name: 'linear', - version: '1.2.0', + name: INTEGRATION_NAME, + version: INTEGRATION_VERSION, title: 'Linear', description: 'Manage your projects autonomously. Have your bot participate in discussions, manage issues and teams, and track progress.', @@ -33,10 +38,7 @@ export default new IntegrationDefinition({ WEBHOOK_SIGNING_SECRET: { description: 'The signing secret of your Linear webhook.', }, - ...sentryHelpers.COMMON_SECRET_NAMES, - }, - __advanced: { - useLegacyZuiTransformer: true, + ...posthogHelper.COMMON_SECRET_NAMES, }, }) .extend(listable, ({ entities }) => ({ @@ -54,3 +56,9 @@ export default new IntegrationDefinition({ deleted: { name: 'issueDeleted' }, }, })) + .extend(proactiveConversation, ({ entities }) => ({ + entities: { + conversation: entities.issueConversation, + }, + actions: { getOrCreateConversation: { name: 'getOrCreateIssueConversation' } }, + })) diff --git a/integrations/linear/package.json b/integrations/linear/package.json index dfd0d0aef4a..cb9167a8c61 100644 --- a/integrations/linear/package.json +++ b/integrations/linear/package.json @@ -9,7 +9,6 @@ "dependencies": { "@botpress/client": "workspace:*", "@botpress/sdk": "workspace:*", - "@botpress/sdk-addons": "workspace:*", "@linear/sdk": "^65.2.0", "axios": "^1.4.0", "query-string": "^6.14.1", @@ -21,10 +20,11 @@ "@botpress/sdk": "workspace:*", "@botpresshub/deletable": "workspace:*", "@botpresshub/listable": "workspace:*", - "@sentry/cli": "^2.39.1" + "@botpresshub/proactive-conversation": "workspace:*" }, "bpDependencies": { "listable": "../../interfaces/listable", - "deletable": "../../interfaces/deletable" + "deletable": "../../interfaces/deletable", + "proactive-conversation": "../../interfaces/proactive-conversation" } } diff --git a/integrations/linear/src/actions/create-issue.ts b/integrations/linear/src/actions/create-issue.ts index 4f1ff3ed851..1f8126a1d06 100644 --- a/integrations/linear/src/actions/create-issue.ts +++ b/integrations/linear/src/actions/create-issue.ts @@ -1,3 +1,4 @@ +import { RuntimeError } from '@botpress/client' import { getIssueTags, getLinearClient, getTeam } from '../misc/utils' import { getIssueFields } from './get-issue' import * as bp from '.botpress' @@ -14,7 +15,7 @@ export const createIssue: bp.IntegrationProps['actions']['createIssue'] = async const team = await getTeam(linearClient, undefined, teamName) if (!team.id) { - throw new Error(`Could not find team "${teamName}"`) + throw new RuntimeError(`Could not find team "${teamName}"`) } const labelIds = labels ? await team.findLabelIds(labels) : undefined @@ -40,7 +41,7 @@ export const createIssue: bp.IntegrationProps['actions']['createIssue'] = async const fullIssue = await issueFetch if (!fullIssue) { - throw new Error('Could not create issue') + throw new RuntimeError('Could not create issue') } const issue = getIssueFields(fullIssue) @@ -49,6 +50,7 @@ export const createIssue: bp.IntegrationProps['actions']['createIssue'] = async await client.getOrCreateConversation({ channel: 'issue', tags: issueTags, + discriminateByTags: ['id'], }) return { diff --git a/integrations/linear/src/actions/get-user.ts b/integrations/linear/src/actions/get-user.ts index 4c34b94d8e5..eb5bd0deeb7 100644 --- a/integrations/linear/src/actions/get-user.ts +++ b/integrations/linear/src/actions/get-user.ts @@ -10,7 +10,7 @@ export const getUser: bp.IntegrationProps['actions']['getUser'] = async (args) = input: { linearUserId }, } = args const linearClient = await getLinearClient(args, ctx.integrationId) - const user = await linearClient.user(linearUserId) + const user = linearUserId ? await linearClient.user(linearUserId) : await linearClient.viewer return userProfileSchema.parse({ linearId: user.id, diff --git a/integrations/linear/src/actions/index.ts b/integrations/linear/src/actions/index.ts index a57a64b1ea1..f60cc45ed3a 100644 --- a/integrations/linear/src/actions/index.ts +++ b/integrations/linear/src/actions/index.ts @@ -3,10 +3,16 @@ import { deleteIssue } from './delete-issue' import { findTarget } from './find-target' import { getIssue } from './get-issue' import { getUser } from './get-user' +import { issueDelete } from './issue-delete' +import { issueList } from './issue-list' import { listIssues } from './list-issues' +import { listStates } from './list-states' import { listTeams } from './list-teams' import { listUsers } from './list-users' import { markAsDuplicate } from './mark-as-duplicate' +import { getOrCreateIssueConversation } from './proactive-conversation' +import { resolveComment } from './resolve-comment' +import { sendRawGraphqlQuery } from './send-raw-graphql-query' import { updateIssue } from './update-issue' import * as bp from '.botpress' @@ -18,7 +24,13 @@ export default { listIssues, listTeams, listUsers, + listStates, markAsDuplicate, createIssue, deleteIssue, + sendRawGraphqlQuery, + resolveComment, + getOrCreateIssueConversation, + issueDelete, + issueList, } satisfies Partial diff --git a/integrations/linear/src/actions/issue-delete.ts b/integrations/linear/src/actions/issue-delete.ts new file mode 100644 index 00000000000..8a450aa6641 --- /dev/null +++ b/integrations/linear/src/actions/issue-delete.ts @@ -0,0 +1,10 @@ +import { deleteIssue } from './delete-issue' +import * as bp from '.botpress' + +export const issueDelete: bp.IntegrationProps['actions']['issueDelete'] = async (args) => { + return deleteIssue({ + ...args, + type: 'deleteIssue', + input: { id: args.input.id }, + }) +} diff --git a/integrations/linear/src/actions/issue-list.ts b/integrations/linear/src/actions/issue-list.ts new file mode 100644 index 00000000000..cfe22978188 --- /dev/null +++ b/integrations/linear/src/actions/issue-list.ts @@ -0,0 +1,19 @@ +import { listIssues } from './list-issues' +import * as bp from '.botpress' + +export const issueList: bp.IntegrationProps['actions']['issueList'] = async (args) => { + const count = 20 + const startCursor = args.input.nextToken + const res = await listIssues({ + ...args, + type: 'listIssues', + input: { + count, + startCursor, + }, + }) + return { + items: res.issues.map(({ linearIds: _, ...item }) => item), + meta: { nextToken: res.nextCursor }, + } +} diff --git a/integrations/linear/src/actions/list-states.ts b/integrations/linear/src/actions/list-states.ts new file mode 100644 index 00000000000..8fe46ba5091 --- /dev/null +++ b/integrations/linear/src/actions/list-states.ts @@ -0,0 +1,25 @@ +import { RuntimeError } from '@botpress/client' +import { getLinearClient } from '../misc/utils' +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) + + try { + const states = await linearClient.workflowStates({ after: startCursor, first: count }) + return { + nextCursor: states.pageInfo.endCursor, + states: states.nodes.map((state) => { + return { id: state.id, name: state.name } + }), + } + } catch (thrown: unknown) { + const msg = thrown instanceof Error ? thrown.message : String(thrown) + throw new RuntimeError(`Failed to list states: ${msg}`) + } +} diff --git a/integrations/linear/src/actions/list-teams.ts b/integrations/linear/src/actions/list-teams.ts index c9745dada20..46ea8dc7dc4 100644 --- a/integrations/linear/src/actions/list-teams.ts +++ b/integrations/linear/src/actions/list-teams.ts @@ -12,6 +12,7 @@ export const listTeams: bp.IntegrationProps['actions']['listTeams'] = async (arg return { teams: teams.nodes.map((x) => ({ id: x.id, + key: x.key, name: x.name, description: x.description, icon: x.icon, diff --git a/integrations/linear/src/actions/list-users.ts b/integrations/linear/src/actions/list-users.ts index 02ebd75571e..c6808be65d0 100644 --- a/integrations/linear/src/actions/list-users.ts +++ b/integrations/linear/src/actions/list-users.ts @@ -19,7 +19,7 @@ export const listUsers: bp.IntegrationProps['actions']['listUsers'] = async (arg const users: bp.actions.listUsers.output.Output['users'] = query.nodes.map((user) => ({ ...user, lastSeen: user.lastSeen?.toISOString(), - updatedAt: user.updatedAt, + updatedAt: user.updatedAt.toISOString(), })) return { diff --git a/integrations/linear/src/actions/proactive-conversation.ts b/integrations/linear/src/actions/proactive-conversation.ts new file mode 100644 index 00000000000..8326dfa3985 --- /dev/null +++ b/integrations/linear/src/actions/proactive-conversation.ts @@ -0,0 +1,24 @@ +import { RuntimeError } from '@botpress/client' +import { getIssueTags, getLinearClient } from 'src/misc/utils' +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 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}`) + }) + + const { conversation } = await client.getOrCreateConversation({ + channel: 'issue', + tags: await getIssueTags(issue), + discriminateByTags: ['id'], + }) + + return { + conversationId: conversation.id, + } +} diff --git a/integrations/linear/src/actions/resolve-comment.ts b/integrations/linear/src/actions/resolve-comment.ts new file mode 100644 index 00000000000..0c06d6f7108 --- /dev/null +++ b/integrations/linear/src/actions/resolve-comment.ts @@ -0,0 +1,20 @@ +import { getLinearClient } from '../misc/utils' +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) + + try { + const { success } = await linearClient.commentResolve(id) + return { success } + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)) + args.logger.error('An error occured while trying to resolve comment: ', error.message) + return { success: false } + } +} diff --git a/integrations/linear/src/actions/send-raw-graphql-query.ts b/integrations/linear/src/actions/send-raw-graphql-query.ts new file mode 100644 index 00000000000..65822151bfb --- /dev/null +++ b/integrations/linear/src/actions/send-raw-graphql-query.ts @@ -0,0 +1,26 @@ +import { RuntimeError } from '@botpress/sdk' +import { getLinearClient } from '../misc/utils' +import * as bp from '.botpress' + +export const sendRawGraphqlQuery: bp.IntegrationProps['actions']['sendRawGraphqlQuery'] = async (args) => { + const { + ctx, + input: { query, parameters }, + } = args + + const mappedParams = parameters?.reduce( + (mapped, param) => { + mapped[param.name] = param.value + return mapped + }, + {} as Record + ) + try { + const linearClient = await getLinearClient(args, ctx.integrationId) + const result = await linearClient.client.rawRequest(query, mappedParams) + return { result: result.data } + } catch (thrown) { + const error = thrown instanceof Error ? thrown : new Error(String(thrown)) + throw new RuntimeError(`Failed to query the Linear API: ${error.message}`) + } +} diff --git a/integrations/linear/src/handler.ts b/integrations/linear/src/handler.ts index c4e8e228af8..7f7da6398e3 100644 --- a/integrations/linear/src/handler.ts +++ b/integrations/linear/src/handler.ts @@ -1,5 +1,5 @@ -import { Request } from '@botpress/sdk' -import { LinearWebhooks } from '@linear/sdk' +import { Request, RuntimeError } from '@botpress/sdk' +import { LinearWebhookClient } from '@linear/sdk/webhooks' import { fireIssueCreated } from './events/issueCreated' import { fireIssueDeleted } from './events/issueDeleted' @@ -31,7 +31,7 @@ export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client if (!result.success) { const message = `Error while verifying webhook signature: ${result.message}` logger.forBot().error(message) - throw new Error(message) + throw new RuntimeError(message) } const linearBotId = await _getLinearBotId({ client, ctx }) @@ -108,7 +108,7 @@ const _safeCheckWebhookSignature = ({ return { success: false, message: 'missing signature header or request body' } } - const webhookHandler = new LinearWebhooks(_getWebhookSigningSecret({ ctx })) + const webhookHandler = new LinearWebhookClient(_getWebhookSigningSecret({ ctx })) const bodyBuffer = Buffer.from(req.body) const timeStampHeader = linearEvent[LINEAR_WEBHOOK_TS_FIELD] try { diff --git a/integrations/linear/src/index.ts b/integrations/linear/src/index.ts index f1d755cc503..caa172c896c 100644 --- a/integrations/linear/src/index.ts +++ b/integrations/linear/src/index.ts @@ -1,46 +1,23 @@ -import { sentry as sentryHelpers } from '@botpress/sdk-addons' - +import { posthogHelper } from '@botpress/common' +import { INTEGRATION_NAME, INTEGRATION_VERSION } from 'integration.definition' import actions from './actions' import channels from './channels' import { handler } from './handler' import { register, unregister } from './setup' import * as bp from '.botpress' -const integration = new bp.Integration({ +const posthogConfig: posthogHelper.PostHogConfig = { + integrationName: INTEGRATION_NAME, + integrationVersion: INTEGRATION_VERSION, + key: bp.secrets.POSTHOG_KEY, +} + +const integrationConfig: bp.IntegrationProps = { register, unregister, - handler, + actions, channels, - actions: { - ...actions, - issueList: async (props) => { - const count = 20 - const startCursor = props.input.nextToken - const res = await actions.listIssues({ - ...props, - type: 'listIssues', - input: { - count, - startCursor, - }, - }) - return { - items: res.issues.map(({ linearIds: _, ...item }) => item), - meta: { nextToken: res.nextCursor }, - } - }, - issueDelete: async (props) => { - return actions.deleteIssue({ - ...props, - type: 'deleteIssue', - input: { id: props.input.id }, - }) - }, - }, -}) + handler, +} -export default sentryHelpers.wrapIntegration(integration, { - dsn: bp.secrets.SENTRY_DSN, - environment: bp.secrets.SENTRY_ENVIRONMENT, - release: bp.secrets.SENTRY_RELEASE, -}) +export default posthogHelper.wrapIntegration(posthogConfig, integrationConfig) diff --git a/integrations/linear/src/misc/linear.ts b/integrations/linear/src/misc/linear.ts index 866441f3f70..123d074d12b 100644 --- a/integrations/linear/src/misc/linear.ts +++ b/integrations/linear/src/misc/linear.ts @@ -1,4 +1,4 @@ -import { z, Request } from '@botpress/sdk' +import { z, Request, RuntimeError } from '@botpress/sdk' import { LinearClient } from '@linear/sdk' import axios from 'axios' import queryString from 'query-string' @@ -148,7 +148,7 @@ export const handleOauth = async (req: Request, client: bp.Client, ctx: bp.Conte const code = query.code if (typeof code !== 'string') { - throw new Error('Handler received an empty code') + throw new RuntimeError('Handler received an empty code') } const { accessToken, expiresAt } = await linearOauthClient.getAccessToken(code) diff --git a/integrations/linear/src/misc/utils.ts b/integrations/linear/src/misc/utils.ts index 799acf37277..8aabc4e0b7e 100644 --- a/integrations/linear/src/misc/utils.ts +++ b/integrations/linear/src/misc/utils.ts @@ -84,6 +84,7 @@ export const getUserAndConversation = async (props: { tags: { id: props.linearIssueId, }, + discriminateByTags: ['id'], }) const linearClient = await getLinearClient(props, props.integrationId) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c323d83df78..587ba2e178d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1352,9 +1352,6 @@ importers: '@botpress/sdk': specifier: workspace:* version: link:../../packages/sdk - '@botpress/sdk-addons': - specifier: workspace:* - version: link:../../packages/sdk-addons '@linear/sdk': specifier: ^65.2.0 version: 65.2.0 @@ -1380,9 +1377,9 @@ importers: '@botpresshub/listable': specifier: workspace:* version: link:../../interfaces/listable - '@sentry/cli': - specifier: ^2.39.1 - version: 2.39.1 + '@botpresshub/proactive-conversation': + specifier: workspace:* + version: link:../../interfaces/proactive-conversation integrations/loops: dependencies: