chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import github from './bp_modules/github'
|
||||
import linear from './bp_modules/linear'
|
||||
import slack from './bp_modules/slack'
|
||||
import telegram from './bp_modules/telegram'
|
||||
|
||||
// TODO: use default options
|
||||
const toJSONSchemaOptions: Partial<sdk.z.transforms.JSONSchemaGenerationOptions> = {
|
||||
discriminatedUnionStrategy: 'anyOf',
|
||||
discriminator: false,
|
||||
}
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
states: {
|
||||
watchedTeams: {
|
||||
type: 'bot',
|
||||
schema: sdk.z.object({
|
||||
teamKeys: sdk.z
|
||||
.array(sdk.z.string())
|
||||
.title('Team Keys')
|
||||
.describe('The keys of the teams for which BugBuster should lint issues'),
|
||||
}),
|
||||
},
|
||||
lastLintedId: {
|
||||
type: 'workflow',
|
||||
schema: sdk.z.object({
|
||||
id: sdk.z.string().optional().title('ID').describe('The ID of the last successfully linted issue'),
|
||||
}),
|
||||
},
|
||||
lintResults: {
|
||||
type: 'workflow',
|
||||
schema: sdk.z.object({
|
||||
issues: sdk.z.array(
|
||||
sdk.z.discriminatedUnion('result', [
|
||||
sdk.z.object({
|
||||
identifier: sdk.z.string().title('Identifier').describe('The issue identifier'),
|
||||
result: sdk.z.literal('failed').title('Result').describe('The lint result'),
|
||||
messages: sdk.z.array(sdk.z.string()).title('Messages').describe('The lint error messages'),
|
||||
}),
|
||||
sdk.z.object({
|
||||
identifier: sdk.z.string().title('Identifier').describe('The issue identifier'),
|
||||
result: sdk.z.enum(['succeeded', 'ignored']).title('Result').describe('The lint result'),
|
||||
}),
|
||||
])
|
||||
),
|
||||
}),
|
||||
},
|
||||
notificationChannels: {
|
||||
type: 'bot',
|
||||
schema: sdk.z.object({
|
||||
channels: sdk.z
|
||||
.array(
|
||||
sdk.z.object({
|
||||
conversationId: sdk.z.string().title('Conversation ID').describe('The conversation ID'),
|
||||
name: sdk.z.string().title('Name').describe('The channel name'),
|
||||
teams: sdk.z
|
||||
.array(sdk.z.string())
|
||||
.title('Teams')
|
||||
.describe('The teams for which notifications will be sent to the channel'),
|
||||
})
|
||||
)
|
||||
.title('Channel')
|
||||
.describe('The Slack channel where notifications will be sent'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
lintAll: {
|
||||
input: {
|
||||
schema: sdk.z.object({
|
||||
verbose: sdk.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.title('Verbose')
|
||||
.describe('Post the detailed lint results (per issue) to the Slack channel'),
|
||||
comment: sdk.z
|
||||
.boolean()
|
||||
.optional()
|
||||
.title('Comment')
|
||||
.describe('Whether to comment the lint results on the Linear issues themselves (defaults to true)'),
|
||||
}),
|
||||
},
|
||||
output: { schema: sdk.z.object({}) },
|
||||
},
|
||||
},
|
||||
events: {
|
||||
timeToLintAll: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
timeToCheckIssuesState: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
},
|
||||
recurringEvents: {
|
||||
timeToLintAll: {
|
||||
payload: sdk.z.object({}),
|
||||
type: 'timeToLintAll',
|
||||
schedule: {
|
||||
cron: '0 13 * * 1', // runs every week on Monday at 8AM EST
|
||||
},
|
||||
},
|
||||
timeToCheckIssuesState: {
|
||||
payload: sdk.z.object({}),
|
||||
type: 'timeToCheckIssuesState',
|
||||
schedule: {
|
||||
cron: '0 * * * *', // runs every hour on the hour
|
||||
},
|
||||
},
|
||||
},
|
||||
__advanced: {
|
||||
toJSONSchemaOptions,
|
||||
},
|
||||
})
|
||||
.addIntegration(github, {
|
||||
enabled: true,
|
||||
configurationType: 'manualPAT',
|
||||
configuration: {
|
||||
personalAccessToken: genenv.BUGBUSTER_GITHUB_TOKEN,
|
||||
githubWebhookSecret: genenv.BUGBUSTER_GITHUB_WEBHOOK_SECRET,
|
||||
},
|
||||
})
|
||||
.addIntegration(telegram, {
|
||||
enabled: true,
|
||||
configurationType: null,
|
||||
configuration: {
|
||||
botToken: genenv.BUGBUSTER_TELEGRAM_BOT_TOKEN,
|
||||
typingIndicatorEmoji: true,
|
||||
},
|
||||
})
|
||||
.addIntegration(linear, {
|
||||
enabled: true,
|
||||
configurationType: 'apiKey',
|
||||
configuration: {
|
||||
apiKey: genenv.BUGBUSTER_LINEAR_API_KEY,
|
||||
webhookSigningSecret: genenv.BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET,
|
||||
},
|
||||
})
|
||||
.addIntegration(slack, {
|
||||
enabled: true,
|
||||
configurationType: null,
|
||||
configuration: {
|
||||
typingIndicatorEmoji: false,
|
||||
replyBehaviour: {
|
||||
location: 'channel',
|
||||
onlyOnBotMention: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 792 KiB |
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@bp-bots/bugbuster",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e BUGBUSTER_GITHUB_TOKEN -e BUGBUSTER_GITHUB_WEBHOOK_SECRET -e BUGBUSTER_LINEAR_API_KEY -e BUGBUSTER_LINEAR_WEBHOOK_SIGNING_SECRET -e BUGBUSTER_TELEGRAM_BOT_TOKEN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/github": "workspace:*",
|
||||
"@botpresshub/linear": "workspace:*",
|
||||
"@botpresshub/slack": "workspace:*",
|
||||
"@botpresshub/telegram": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"github": "../../integrations/github",
|
||||
"linear": "../../integrations/linear",
|
||||
"slack": "../../integrations/slack",
|
||||
"telegram": "../../integrations/telegram"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Bugbuster
|
||||
|
||||
A simple bot built on top of the Botpress [SDK](https://botpress.com/docs/integrations/sdk/overview) that lints Linear issues and synchronizes them with GitHub issues.
|
||||
|
||||
<img src="./bugbuster.png" />
|
||||
|
||||
## Linear
|
||||
|
||||
Once connected to your Linear workspace, the bot comments on newly created or updated issues when linting errors are detected, and automatically resolves those comments once the linting errors are fixed.
|
||||
|
||||
The following linting rules are applied (among others):
|
||||
|
||||
- An issue must have a title, description, priority, and estimate
|
||||
- An issue must be linked to a project or have a goal tag
|
||||
- If an issue is in the _Blocked_ state, it must have either a blocking reason tag or a blocking issue
|
||||
- If an issue is in the _In Progress_ state, it must have an assignee
|
||||
|
||||
## GitHub
|
||||
|
||||
Once connected to your GitHub repository, the bot detects new issues and automatically creates a corresponding Linear issue in the Triage state, including all relevant information.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CommandProcessor } from './services/command-processor'
|
||||
import { IssueProcessor } from './services/issue-processor'
|
||||
import { IssueStateChecker } from './services/issue-state-checker'
|
||||
import { RecentlyLintedManager } from './services/recently-linted-manager'
|
||||
import { StateService } from './services/state-service'
|
||||
import { TeamsManager } from './services/teams-manager'
|
||||
import * as types from './types'
|
||||
import * as utils from './utils'
|
||||
|
||||
export const bootstrap = (props: types.CommonHandlerProps) => {
|
||||
const { client, logger, ctx } = props
|
||||
const botpress = utils.botpress.BotpressApi.create(props)
|
||||
|
||||
const linear = utils.linear.LinearApi.create(client)
|
||||
const stateService = new StateService(linear)
|
||||
const teamsManager = new TeamsManager(linear, client, ctx.botId)
|
||||
const recentlyLintedManager = new RecentlyLintedManager(linear)
|
||||
const issueProcessor = new IssueProcessor(logger, linear, stateService, teamsManager, ctx.botId)
|
||||
const issueStateChecker = new IssueStateChecker(linear, stateService, logger, ctx.botId)
|
||||
const commandProcessor = new CommandProcessor(client, teamsManager, ctx.botId)
|
||||
|
||||
return {
|
||||
botpress,
|
||||
linear,
|
||||
stateService,
|
||||
teamsManager,
|
||||
recentlyLintedManager,
|
||||
issueProcessor,
|
||||
issueStateChecker,
|
||||
commandProcessor,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const TEAM_NAME_FOR_NEW_ISSUES = 'Engineering'
|
||||
|
||||
export const handleGithubIssueOpened: bp.EventHandlers['github:issueOpened'] = async (props): Promise<void> => {
|
||||
const githubIssue = props.event.payload
|
||||
|
||||
props.logger.info('Received GitHub issue', githubIssue)
|
||||
|
||||
const { botpress, linear } = boot.bootstrap(props)
|
||||
|
||||
const _handleError =
|
||||
(context: string) =>
|
||||
(thrown: unknown): Promise<never> =>
|
||||
botpress.handleError({ context, conversationId: undefined }, thrown)
|
||||
|
||||
const linearResponse = await props.client
|
||||
.callAction({
|
||||
type: 'linear:createIssue',
|
||||
input: {
|
||||
teamName: TEAM_NAME_FOR_NEW_ISSUES,
|
||||
description: githubIssue.issue.body,
|
||||
title: githubIssue.issue.name,
|
||||
},
|
||||
})
|
||||
.catch(_handleError('trying to create a Linear issue from the GitHub issue'))
|
||||
|
||||
const comment = [
|
||||
'This issue was created from GitHub by BugBuster Bot.',
|
||||
'',
|
||||
`GitHub Issue: [${githubIssue.issue.name}](${githubIssue.issue.url})`,
|
||||
].join('\n')
|
||||
|
||||
await linear
|
||||
.createComment({
|
||||
body: comment,
|
||||
issueId: linearResponse.output.issue.id,
|
||||
botId: props.ctx.botId,
|
||||
})
|
||||
.catch(_handleError('trying to create a comment on the Linear issue created from GitHub'))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from './github-issue-opened'
|
||||
export * from './linear-issue-updated'
|
||||
export * from './linear-issue-created'
|
||||
export * from './message-created'
|
||||
export * from './lint-all'
|
||||
export * from './time-to-lint-all'
|
||||
export * from './time-to-check-issues-state'
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleLinearIssueCreated: bp.EventHandlers['linear:issueCreated'] = async (props) => {
|
||||
const { event } = props
|
||||
const { number: issueNumber, teamKey } = event.payload
|
||||
|
||||
const { botpress, issueProcessor } = boot.bootstrap(props)
|
||||
|
||||
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
|
||||
|
||||
props.logger.info('Linear issue created event received', `${teamKey}-${issueNumber}`)
|
||||
const issue = await issueProcessor
|
||||
.findIssue(issueNumber, teamKey)
|
||||
.catch(_handleError('trying to find the created Linear issue'))
|
||||
|
||||
if (!issue) {
|
||||
return
|
||||
}
|
||||
|
||||
await issueProcessor.lintIssue(issue).catch(_handleError('trying to lint the created Linear issue'))
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleLinearIssueUpdated: bp.EventHandlers['linear:issueUpdated'] = async (props) => {
|
||||
const { event, logger } = props
|
||||
const { number: issueNumber, teamKey } = event.payload
|
||||
|
||||
const { botpress, issueProcessor, recentlyLintedManager } = boot.bootstrap(props)
|
||||
|
||||
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
|
||||
logger.info('Linear issue updated event received', `${teamKey}-${issueNumber}`)
|
||||
const issue = await issueProcessor
|
||||
.findIssue(issueNumber, teamKey)
|
||||
.catch(_handleError('trying to find the updated Linear issue'))
|
||||
|
||||
if (!issue) {
|
||||
return
|
||||
}
|
||||
|
||||
const isRecentlyLinted = await recentlyLintedManager
|
||||
.isRecentlyLinted(issue)
|
||||
.catch(_handleError('trying to get recently linted issues'))
|
||||
|
||||
await issueProcessor.lintIssue(issue, isRecentlyLinted).catch(_handleError('trying to lint the updated Linear issue'))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as types from 'src/types'
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const LINEAR_ISSUE_BASE_URL = 'https://linear.app/botpress/issue/'
|
||||
|
||||
export const handleLintAll: bp.WorkflowHandlers['lintAll'] = async (props) => {
|
||||
const { client, workflow, ctx, conversation } = props
|
||||
|
||||
const verbose = workflow.input.verbose ?? false
|
||||
const comment = workflow.input.comment ?? true
|
||||
|
||||
const { botpress, issueProcessor } = boot.bootstrap(props)
|
||||
|
||||
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { id: lastLintedId },
|
||||
},
|
||||
} = await client
|
||||
.getOrSetState({
|
||||
id: workflow.id,
|
||||
name: 'lastLintedId',
|
||||
type: 'workflow',
|
||||
payload: {},
|
||||
})
|
||||
.catch(_handleError('trying to get last linted issue ID'))
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { issues: lintResults },
|
||||
},
|
||||
} = await client
|
||||
.getOrSetState({
|
||||
id: workflow.id,
|
||||
name: 'lintResults',
|
||||
type: 'workflow',
|
||||
payload: { issues: [] },
|
||||
})
|
||||
.catch(_handleError('trying to get previous lint results'))
|
||||
|
||||
let hasNextPage = false
|
||||
let endCursor: string | undefined = lastLintedId
|
||||
do {
|
||||
const pagedIssues = await issueProcessor
|
||||
.listRelevantIssues(endCursor)
|
||||
.catch(_handleError('trying to list all issues'))
|
||||
|
||||
const pageResults: types.LintResult[] = []
|
||||
for (const issue of pagedIssues.issues) {
|
||||
const lintResult = await issueProcessor
|
||||
.lintIssue(issue, undefined, { comment })
|
||||
.catch(_handleError(`trying to lint issue ${issue.identifier}`))
|
||||
lintResults.push(lintResult)
|
||||
pageResults.push(lintResult)
|
||||
|
||||
await workflow.acknowledgeStartOfProcessing().catch(_handleError('trying to acknowledge start of processing'))
|
||||
|
||||
endCursor = issue.id
|
||||
|
||||
await Promise.all([
|
||||
client
|
||||
.setState({
|
||||
id: workflow.id,
|
||||
name: 'lastLintedId',
|
||||
type: 'workflow',
|
||||
payload: { id: endCursor },
|
||||
})
|
||||
.catch(_handleError('trying to update last linted issue ID')),
|
||||
client
|
||||
.setState({
|
||||
id: workflow.id,
|
||||
name: 'lintResults',
|
||||
type: 'workflow',
|
||||
payload: { issues: lintResults },
|
||||
})
|
||||
.catch(_handleError('trying to update lint results')),
|
||||
])
|
||||
}
|
||||
|
||||
hasNextPage = pagedIssues.pagination?.hasNextPage ?? false
|
||||
|
||||
if (verbose && conversation?.id) {
|
||||
const failedCount = lintResults.filter((result) => result.result === 'failed').length
|
||||
const newlyFailed = pageResults.filter((result) => result.result === 'failed')
|
||||
|
||||
const progressLine = `Linting... linted ${lintResults.length} issue(s) so far (${failedCount} with errors).`
|
||||
const failedList = newlyFailed.map((result) => `- ${_issueLink(result.identifier)}`).join('\n')
|
||||
const message = newlyFailed.length > 0 ? `${progressLine}\n${failedList}` : progressLine
|
||||
|
||||
await botpress.respondText(conversation.id, message).catch(() => {})
|
||||
}
|
||||
} while (hasNextPage)
|
||||
|
||||
if (conversation?.id) {
|
||||
const message = _buildResultMessage(lintResults)
|
||||
await botpress.respondText(conversation.id, message).catch(() => {})
|
||||
await workflow.setCompleted()
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { channels },
|
||||
},
|
||||
} = await client.getOrSetState({
|
||||
id: ctx.botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: [] },
|
||||
})
|
||||
|
||||
for (const channel of channels) {
|
||||
const relevantIssues = lintResults.filter((result) =>
|
||||
channel.teams.some((team) => result.identifier.includes(team))
|
||||
)
|
||||
|
||||
if (relevantIssues.length > 0) {
|
||||
await botpress.respondText(channel.conversationId, _buildResultMessage(relevantIssues)).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
await workflow.setCompleted()
|
||||
}
|
||||
|
||||
export const handleLintAllTimeout: bp.WorkflowHandlers['lintAll'] = async (props) => {
|
||||
const { conversation } = props
|
||||
const { botpress } = boot.bootstrap(props)
|
||||
|
||||
if (conversation?.id) {
|
||||
await botpress.respondText(conversation.id, "Error: the 'lintAll' operation timed out")
|
||||
}
|
||||
}
|
||||
|
||||
const _issueLink = (identifier: string) => `[${identifier}](${LINEAR_ISSUE_BASE_URL + identifier})`
|
||||
|
||||
const _buildResultMessage = (results: types.LintResult[]) => {
|
||||
const failedIssuesLinks = results
|
||||
.filter((result) => result.result === 'failed')
|
||||
.slice(0, 10) // Limit to 10 issues to avoid spamming the message
|
||||
.map((result) => _issueLink(result.identifier))
|
||||
|
||||
let messageDetail = 'No issue contained lint errors.'
|
||||
if (failedIssuesLinks.length === 1) {
|
||||
messageDetail = `This issue contained lint errors: ${failedIssuesLinks[0]}.`
|
||||
} else if (failedIssuesLinks.length > 1) {
|
||||
messageDetail = `These issues contained lint errors: ${failedIssuesLinks.join(', ')}.`
|
||||
}
|
||||
|
||||
return `Linting complete. ${messageDetail}`
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CommandDefinition } from 'src/types'
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const MESSAGING_INTEGRATIONS = ['telegram', 'slack']
|
||||
|
||||
export const handleMessageCreated: bp.MessageHandlers['*'] = async (props) => {
|
||||
const { conversation, message } = props
|
||||
if (!MESSAGING_INTEGRATIONS.includes(conversation.integration)) {
|
||||
props.logger.info(`Ignoring message from ${conversation.integration}`)
|
||||
return
|
||||
}
|
||||
if (
|
||||
conversation.integration === 'slack' &&
|
||||
(conversation.channel === 'channel' || conversation.channel === 'thread')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const { botpress, commandProcessor } = boot.bootstrap(props)
|
||||
const commandListMessage = _buildListCommandsMessage(commandProcessor.commandDefinitions)
|
||||
|
||||
if (message.type !== 'text') {
|
||||
await botpress.respondText(conversation.id, commandListMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (!message.payload.text) {
|
||||
await botpress.respondText(conversation.id, commandListMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const [command, ...args] = message.payload.text.trim().split(' ')
|
||||
if (!command) {
|
||||
await botpress.respondText(conversation.id, commandListMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const commandDefinition = commandProcessor.commandDefinitions.find((commandImpl) => commandImpl.name === command)
|
||||
if (!commandDefinition) {
|
||||
await botpress.respondText(conversation.id, commandListMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if (commandDefinition.requiredArgs && args.length < commandDefinition.requiredArgs?.length) {
|
||||
await botpress.respondText(
|
||||
conversation.id,
|
||||
`Error: a minimum of ${commandDefinition.requiredArgs.length} argument(s) is required.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const _handleError = (context: string, thrown: unknown) =>
|
||||
botpress.handleError({ context, conversationId: conversation.id }, thrown)
|
||||
|
||||
try {
|
||||
const result = await commandDefinition.implementation(args, conversation.id)
|
||||
await botpress.respondText(conversation.id, `${result.success ? '' : 'Error: '}${result.message}`)
|
||||
} catch (thrown) {
|
||||
await _handleError(`trying to run ${commandDefinition.name}`, thrown)
|
||||
}
|
||||
}
|
||||
|
||||
const _buildListCommandsMessage = (definitions: CommandDefinition[]) => {
|
||||
const commands = definitions.map(_buildCommandMessage).join('\n')
|
||||
return `Unknown command. Here's a list of possible commands:\n${commands}`
|
||||
}
|
||||
|
||||
const _buildCommandMessage = (definition: CommandDefinition) => {
|
||||
const requiredArgs = definition.requiredArgs?.map((arg) => `<${arg}>`).join(' ')
|
||||
const optionalArgs = definition.optionalArgs?.map((arg) => `[${arg}]`).join(' ')
|
||||
|
||||
return `${definition.name} ${requiredArgs ?? ''} ${optionalArgs ?? ''}`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as types from 'src/types'
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const statesToProcess: types.StateAttributes[] = [
|
||||
{
|
||||
commonStateName: 'STAGING',
|
||||
maxTimeSinceLastUpdate: '-P1W',
|
||||
warningComment: 'BugBuster bot detected that this issue has been in staging for over a week',
|
||||
buildWarningReason: (issueIdentifier) => `Issue ${issueIdentifier} has been in staging for over a week`,
|
||||
},
|
||||
{
|
||||
commonStateName: 'BLOCKED',
|
||||
maxTimeSinceLastUpdate: '-P1M',
|
||||
warningComment: 'BugBuster bot detected that this issue has been blocked for over a month',
|
||||
buildWarningReason: (issueIdentifier) => `Issue ${issueIdentifier} has been blocked for over a month`,
|
||||
},
|
||||
]
|
||||
|
||||
export const handleTimeToCheckIssuesState: bp.EventHandlers['timeToCheckIssuesState'] = async (props) => {
|
||||
const { logger } = props
|
||||
const { botpress, teamsManager, issueStateChecker } = boot.bootstrap(props)
|
||||
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
|
||||
|
||||
logger.info("Validating issues' states...")
|
||||
|
||||
const teams = await teamsManager.listWatchedTeams().catch(_handleError('trying to list teams'))
|
||||
|
||||
for (const state of statesToProcess) {
|
||||
await issueStateChecker
|
||||
.processIssues({
|
||||
stateAttributes: state,
|
||||
teams,
|
||||
})
|
||||
.catch(_handleError("trying to check issues' states"))
|
||||
}
|
||||
|
||||
logger.info("Finished validating issues' states...")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as boot from '../bootstrap'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const handleTimeToLintAll: bp.EventHandlers['timeToLintAll'] = async (props) => {
|
||||
const { client, logger } = props
|
||||
logger.info("'timeToLintAll' event received.")
|
||||
|
||||
const { botpress } = boot.bootstrap(props)
|
||||
|
||||
const _handleError = (context: string) => (thrown: unknown) => botpress.handleError({ context }, thrown)
|
||||
|
||||
await client
|
||||
.getOrCreateWorkflow({
|
||||
name: 'lintAll',
|
||||
input: {},
|
||||
discriminateByStatusGroup: 'active',
|
||||
status: 'pending',
|
||||
})
|
||||
.catch(_handleError("trying to start the 'lintAll' workflow"))
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as handlers from './handlers'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const bot = new bp.Bot({ actions: {} })
|
||||
|
||||
bot.on.event('github:issueOpened', handlers.handleGithubIssueOpened)
|
||||
bot.on.event('linear:issueUpdated', handlers.handleLinearIssueUpdated)
|
||||
bot.on.event('linear:issueCreated', handlers.handleLinearIssueCreated)
|
||||
bot.on.event('timeToLintAll', handlers.handleTimeToLintAll)
|
||||
bot.on.event('timeToCheckIssuesState', handlers.handleTimeToCheckIssuesState)
|
||||
bot.on.message('*', handlers.handleMessageCreated)
|
||||
|
||||
bot.on.workflowStart('lintAll', handlers.handleLintAll)
|
||||
bot.on.workflowContinue('lintAll', handlers.handleLintAll)
|
||||
bot.on.workflowTimeout('lintAll', handlers.handleLintAllTimeout)
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,303 @@
|
||||
import * as types from '../types'
|
||||
import { TeamsManager } from './teams-manager'
|
||||
import { Client } from '.botpress'
|
||||
|
||||
const MISSING_ARGS_ERROR = 'More arguments are required with this command.'
|
||||
|
||||
export class CommandProcessor {
|
||||
public constructor(
|
||||
private _client: Client,
|
||||
private _teamsManager: TeamsManager,
|
||||
private _botId: string
|
||||
) {}
|
||||
|
||||
private _listTeams: types.CommandImplementation = async () => {
|
||||
const teams = await this._teamsManager.listWatchedTeams()
|
||||
const message = teams.length > 0 ? teams.join(', ') : 'You have no watched teams.'
|
||||
return { success: true, message }
|
||||
}
|
||||
|
||||
private _addTeam: types.CommandImplementation = async ([team]: string[]) => {
|
||||
if (!team) {
|
||||
return { success: false, message: MISSING_ARGS_ERROR }
|
||||
}
|
||||
|
||||
await this._teamsManager.addWatchedTeam(team)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Success: the team with the key '${team}' has been added to the watched team list.`,
|
||||
}
|
||||
}
|
||||
|
||||
private _removeTeam: types.CommandImplementation = async ([team]: string[]) => {
|
||||
if (!team) {
|
||||
return { success: false, message: MISSING_ARGS_ERROR }
|
||||
}
|
||||
|
||||
await this._teamsManager.removeWatchedTeam(team)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Success: the team with the key '${team}' has been removed from the watched team list.`,
|
||||
}
|
||||
}
|
||||
|
||||
private _lintAll: types.CommandImplementation = async (args: string[], conversationId: string) => {
|
||||
let verbose = false
|
||||
let comment = true
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--verbose') {
|
||||
verbose = true
|
||||
} else if (arg === '--no-comments') {
|
||||
comment = false
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Unknown argument '${arg}'. Supported arguments are '--verbose' and '--no-comments'.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this._client.getOrCreateWorkflow({
|
||||
name: 'lintAll',
|
||||
input: { verbose, comment },
|
||||
discriminateByStatusGroup: 'active',
|
||||
conversationId,
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const details = [
|
||||
verbose ? 'detailed lint results will be posted here' : null,
|
||||
comment ? null : 'issues will not be commented on Linear',
|
||||
].filter((detail) => detail !== null)
|
||||
|
||||
const message =
|
||||
details.length > 0 ? `Launched 'lintAll' workflow: ${details.join(', ')}.` : "Launched 'lintAll' workflow."
|
||||
|
||||
return { success: true, message }
|
||||
}
|
||||
|
||||
private _addNotifChannel: types.CommandImplementation = async ([channelToAdd, ...teams]: string[]) => {
|
||||
if (!channelToAdd || !teams[0]) {
|
||||
return { success: false, message: MISSING_ARGS_ERROR }
|
||||
}
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { channels },
|
||||
},
|
||||
} = await this._client.getOrSetState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: [] },
|
||||
})
|
||||
|
||||
const watchedTeams = await this._teamsManager.listWatchedTeams()
|
||||
if (!teams.every((team) => watchedTeams.includes(team))) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'make sure every team you want to add is being watched.',
|
||||
}
|
||||
}
|
||||
|
||||
const existingChannel = channels.find((channel) => channel.name === channelToAdd)
|
||||
if (!existingChannel) {
|
||||
const channelId = await this._resolveChannelId(channelToAdd)
|
||||
if (!channelId) {
|
||||
return { success: false, message: `Could not find a Slack channel matching '${channelToAdd}'.` }
|
||||
}
|
||||
const conversation = await this._client.callAction({
|
||||
type: 'slack:getOrCreateChannelConversation',
|
||||
input: { conversation: { channelId } },
|
||||
})
|
||||
channels.push({ conversationId: conversation.output.conversationId, name: channelToAdd, teams })
|
||||
} else {
|
||||
teams.forEach((team) => {
|
||||
if (!existingChannel.teams.includes(team)) {
|
||||
existingChannel.teams.push(team)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await this._client.setState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Notifications for team(s) ${teams.join(', ')} will be posted in channel ${channelToAdd}.`,
|
||||
}
|
||||
}
|
||||
|
||||
private async _resolveChannelId(input: string): Promise<string | undefined> {
|
||||
const { output } = await this._client.callAction({
|
||||
type: 'slack:findTarget',
|
||||
input: { query: input, channel: 'channel' },
|
||||
})
|
||||
const exact = output.targets.find((t) => t.displayName === input)
|
||||
const match = exact ?? output.targets[0]
|
||||
return match?.tags.id
|
||||
}
|
||||
|
||||
private _removeNotifChannel: types.CommandImplementation = async ([channelToRemove]: string[]) => {
|
||||
if (!channelToRemove) {
|
||||
return { success: false, message: MISSING_ARGS_ERROR }
|
||||
}
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { channels },
|
||||
},
|
||||
} = await this._client.getOrSetState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: [] },
|
||||
})
|
||||
|
||||
if (!channels.find((channel) => channel.name === channelToRemove)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `channel '${channelToRemove}' is not part of the notification channels.`,
|
||||
}
|
||||
}
|
||||
|
||||
await this._client.setState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: channels.filter((channel) => channel.name !== channelToRemove) },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Notification channel ${channelToRemove} has been removed.`,
|
||||
}
|
||||
}
|
||||
|
||||
private _removeNotifChannelTeam: types.CommandImplementation = async ([channelToRemove, teamToRemove]: string[]) => {
|
||||
if (!channelToRemove || !teamToRemove) {
|
||||
return { success: false, message: MISSING_ARGS_ERROR }
|
||||
}
|
||||
|
||||
const {
|
||||
state: {
|
||||
payload: { channels },
|
||||
},
|
||||
} = await this._client.getOrSetState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: [] },
|
||||
})
|
||||
|
||||
const channel = channels.find((channel) => channel.name === channelToRemove)
|
||||
if (!channel) {
|
||||
return {
|
||||
success: false,
|
||||
message: `channel '${channelToRemove}' is not part of the notification channels.`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!channel.teams.find((team) => team === teamToRemove)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `channel ${channel.name} does not receive notifications from team '${teamToRemove}'.`,
|
||||
}
|
||||
}
|
||||
|
||||
channel.teams = channel.teams.filter((team) => team !== teamToRemove)
|
||||
|
||||
await this._client.setState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Team ${teamToRemove} has been removed from channel ${channelToRemove}.`,
|
||||
}
|
||||
}
|
||||
|
||||
private _listNotifChannels: types.CommandImplementation = async () => {
|
||||
const {
|
||||
state: {
|
||||
payload: { channels },
|
||||
},
|
||||
} = await this._client.getOrSetState({
|
||||
id: this._botId,
|
||||
name: 'notificationChannels',
|
||||
type: 'bot',
|
||||
payload: { channels: [] },
|
||||
})
|
||||
|
||||
let message = 'There is no set Slack notification channel.'
|
||||
if (channels.length > 0) {
|
||||
message = this._buildNotifChannelsMessage(channels)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
private _buildNotifChannelsMessage(channels: { name: string; teams: string[] }[]) {
|
||||
return `The Slack notification channels are:\n${channels.map(this._getMessageForChannel).join('\n')}`
|
||||
}
|
||||
|
||||
private _getMessageForChannel(channel: { name: string; teams: string[] }) {
|
||||
const { name, teams } = channel
|
||||
return `- channel ${name} for team(s) ${teams.join(', ')}`
|
||||
}
|
||||
|
||||
public commandDefinitions: types.CommandDefinition[] = [
|
||||
{
|
||||
name: '#listTeams',
|
||||
implementation: this._listTeams,
|
||||
},
|
||||
{
|
||||
name: '#addTeam',
|
||||
implementation: this._addTeam,
|
||||
requiredArgs: ['teamName'],
|
||||
},
|
||||
{
|
||||
name: '#removeTeam',
|
||||
implementation: this._removeTeam,
|
||||
requiredArgs: ['teamName'],
|
||||
},
|
||||
{
|
||||
name: '#lintAll',
|
||||
implementation: this._lintAll,
|
||||
optionalArgs: ['--verbose', '--no-comments'],
|
||||
},
|
||||
{
|
||||
name: '#addNotifChannel',
|
||||
implementation: this._addNotifChannel,
|
||||
requiredArgs: ['channelName', 'teamName1'],
|
||||
optionalArgs: ['teamName2 ...'],
|
||||
},
|
||||
{
|
||||
name: '#removeNotifChannel',
|
||||
implementation: this._removeNotifChannel,
|
||||
requiredArgs: ['channelName'],
|
||||
},
|
||||
{
|
||||
name: '#removeNotifChannelTeam',
|
||||
implementation: this._removeNotifChannelTeam,
|
||||
requiredArgs: ['channelName', 'teamName'],
|
||||
},
|
||||
{
|
||||
name: '#listNotifChannels',
|
||||
implementation: this._listNotifChannels,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as types from '../../types'
|
||||
import * as lin from '../../utils/linear-utils'
|
||||
import { StateService } from '../state-service'
|
||||
import * as tm from '../teams-manager'
|
||||
import { lintIssue } from './lint-issue'
|
||||
|
||||
const IGNORED_STATES: types.CommonStateName[] = ['TRIAGE', 'BACKLOG', 'DONE', 'CANCELED', 'STALE', 'DUPLICATE']
|
||||
const LINTIGNORE_LABEL_NAME = 'lintignore'
|
||||
|
||||
export class IssueProcessor {
|
||||
public constructor(
|
||||
private _logger: sdk.BotLogger,
|
||||
private _linear: lin.LinearApi,
|
||||
private _stateService: StateService,
|
||||
private _teamsManager: tm.TeamsManager,
|
||||
private _botId: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @returns The corresponding issue, or `undefined` if the issue is not found or not valid.
|
||||
*/
|
||||
public async findIssue(issueNumber: number, teamKey: string | undefined): Promise<lin.Issue | undefined> {
|
||||
if (!issueNumber || !teamKey) {
|
||||
this._logger.error('Missing issueNumber or teamKey in event payload')
|
||||
return
|
||||
}
|
||||
|
||||
const watchedTeams = await this._teamsManager.listWatchedTeams()
|
||||
if (!(await this._linear.isTeam(teamKey)) || !watchedTeams.includes(teamKey)) {
|
||||
this._logger.info(`Ignoring issue of team "${teamKey}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const issue = await this._linear.findIssue({ teamKey, issueNumber })
|
||||
if (!issue) {
|
||||
this._logger.warn(`Issue with number ${issueNumber} not found in team ${teamKey}`)
|
||||
return
|
||||
}
|
||||
|
||||
return issue
|
||||
}
|
||||
|
||||
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.')
|
||||
}
|
||||
|
||||
const stateIdsToOmit = await this._stateService.mapToStateIds(IGNORED_STATES)
|
||||
|
||||
return await this._linear.listIssues(
|
||||
{
|
||||
teamKeys: watchedTeams,
|
||||
stateIdsToOmit,
|
||||
},
|
||||
endCursor
|
||||
)
|
||||
}
|
||||
|
||||
public async lintIssue(
|
||||
issue: lin.Issue,
|
||||
isRecentlyLinted?: boolean,
|
||||
options?: { comment?: boolean }
|
||||
): Promise<types.LintResult> {
|
||||
const shouldComment = options?.comment ?? true
|
||||
const state = await this._stateService.getIssueCommonStateName(issue)
|
||||
if (!state) {
|
||||
this._logger.warn(
|
||||
`Issue ${issue.identifier} has an unknown state ${issue.state.name}. Ignoring linting for this issue.`
|
||||
)
|
||||
return { identifier: issue.identifier, result: 'ignored' }
|
||||
}
|
||||
|
||||
if (IGNORED_STATES.includes(state) || issue.labels.nodes.some((label) => label.name === LINTIGNORE_LABEL_NAME)) {
|
||||
return { identifier: issue.identifier, result: 'ignored' }
|
||||
}
|
||||
|
||||
const errors = lintIssue(issue, state)
|
||||
|
||||
if (errors.length === 0) {
|
||||
this._logger.info(`Issue ${issue.identifier} passed all lint checks.`)
|
||||
await this._linear.resolveComments(issue)
|
||||
return { identifier: issue.identifier, result: 'succeeded' }
|
||||
}
|
||||
|
||||
const warningMessage = `Issue ${issue.identifier} has ${errors.length} lint errors.`
|
||||
if (isRecentlyLinted) {
|
||||
this._logger.warn(`${warningMessage} Not commenting the issue because it has been linted recently.`)
|
||||
return { identifier: issue.identifier, result: 'succeeded' }
|
||||
}
|
||||
|
||||
this._logger.warn(warningMessage)
|
||||
|
||||
if (shouldComment) {
|
||||
await this._linear.createComment({
|
||||
issueId: issue.id,
|
||||
botId: this._botId,
|
||||
body: [
|
||||
`BugBuster Bot found the following problems with ${issue.identifier}:`,
|
||||
'',
|
||||
...errors.map((error) => `- ${error.message}`),
|
||||
].join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return { identifier: issue.identifier, messages: errors.map((error) => error.message), result: 'failed' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { test, assert } from 'vitest'
|
||||
import { isIssueTitleFormatValid } from './issue-title-format-validator'
|
||||
|
||||
test('assert that a title containing only words is valid', async () => {
|
||||
const title = 'any string'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets not on the first or second word is valid', async () => {
|
||||
const title = 'any string [abc]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one opening square bracket on the first word is valid', async () => {
|
||||
const title = '[abc'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one closing square bracket on the first word is valid', async () => {
|
||||
const title = 'abc]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with and one opening square bracket on the second word is valid', async () => {
|
||||
const title = 'abc[def'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one closing square bracket on the second word is valid', async () => {
|
||||
const title = 'abc def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesiss not on the first or second word is valid', async () => {
|
||||
const title = 'any string (abc)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one opening parenthesis on the first word is valid', async () => {
|
||||
const title = '(abc'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one closing parenthesis on the first word is valid', async () => {
|
||||
const title = 'abc)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one opening parenthesis on the second word is valid', async () => {
|
||||
const title = 'abc(def'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one closing parenthesis on the second word is valid', async () => {
|
||||
const title = 'abc def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with one closing parenthesis on the second word is valid', async () => {
|
||||
const title = 'abc def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isTrue(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the first word is invalid', async () => {
|
||||
const title = '[abc] def'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the second word is invalid', async () => {
|
||||
const title = 'abc [def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the second word with no space between the first and second word is invalid', async () => {
|
||||
const title = 'abc[def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the second word with multiple spaces between the first and second word is invalid', async () => {
|
||||
const title = 'abc [def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the second word with multiple spaces between the first and second word is invalid', async () => {
|
||||
const title = 'abc [def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with square brackets on the first and second words is invalid', async () => {
|
||||
const title = '[abc def]'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the first word is invalid', async () => {
|
||||
const title = '(abc) def'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the second word is invalid', async () => {
|
||||
const title = 'abc (def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the second word with no space between the first and second word is invalid', async () => {
|
||||
const title = 'abc(def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the second word with multiple spaces between the first and second word is invalid', async () => {
|
||||
const title = 'abc (def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the second word with multiple spaces between the first and second word is invalid', async () => {
|
||||
const title = 'abc (def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
|
||||
test('assert that a title with parenthesis on the first and second words is invalid', async () => {
|
||||
const title = '(abc def)'
|
||||
|
||||
const actual = isIssueTitleFormatValid(title)
|
||||
|
||||
assert.isFalse(actual)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
const PATTERN = /^\w{0,} {0,}((\[.{1,}\])|(\(.{1,}\)))/
|
||||
|
||||
export function isIssueTitleFormatValid(title: string) {
|
||||
return !title.match(PATTERN)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as types from '../../types'
|
||||
import * as lin from '../../utils/linear-utils'
|
||||
import { isIssueTitleFormatValid } from './issue-title-format-validator'
|
||||
|
||||
export type IssueLint = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const lintIssue = (issue: lin.Issue, state: types.CommonStateName): IssueLint[] => {
|
||||
const lints: string[] = []
|
||||
|
||||
if (!_hasLabelOfCategory(issue, 'type')) {
|
||||
lints.push(`Issue ${issue.identifier} is missing a type label.`)
|
||||
}
|
||||
|
||||
const hasBlockedLabel = _hasLabelOfCategory(issue, 'blocked')
|
||||
const hasBlockedRelation = issue.inverseRelations.nodes.some((relation) => relation.type === 'blocks')
|
||||
|
||||
if (state === 'BLOCKED' && !issue.assignee && !hasBlockedRelation) {
|
||||
lints.push(`Issue ${issue.identifier} is blocked but has no assignee.`)
|
||||
}
|
||||
if (state === 'BLOCKED' && !hasBlockedLabel && !hasBlockedRelation) {
|
||||
lints.push(`Issue ${issue.identifier} is blocked but missing a "blocked" label or a blocking issue.`)
|
||||
}
|
||||
if (state === 'BACKLOG' && issue.assignee) {
|
||||
lints.push(`Issue ${issue.identifier} has an assignee but is still in the backlog.`)
|
||||
}
|
||||
|
||||
const hasArea = issue.labels.nodes.some((label) => label.name.startsWith('area/'))
|
||||
if (!hasArea) {
|
||||
lints.push(`Issue ${issue.identifier} is missing an "area/" label.`)
|
||||
}
|
||||
|
||||
if (!issue.priority) {
|
||||
lints.push(`Issue ${issue.identifier} is missing a priority.`)
|
||||
}
|
||||
|
||||
if (issue.estimate === null && state !== 'BLOCKED') {
|
||||
// blocked issues can be unestimated
|
||||
lints.push(`Issue ${issue.identifier} is missing an estimate.`)
|
||||
}
|
||||
|
||||
const issueIsEpic = _hasLabel({ issue, label: 'epic', category: 'type' })
|
||||
if (issue.estimate && issue.estimate > 8 && !issueIsEpic) {
|
||||
lints.push(
|
||||
`Issue ${issue.identifier} has an estimate greater than 8 (${issue.estimate}). Consider breaking it down or making it an epic.`
|
||||
)
|
||||
}
|
||||
|
||||
if (!isIssueTitleFormatValid(issue.title)) {
|
||||
lints.push(
|
||||
`Issue ${issue.identifier} has unconventional commit syntax in the title. Issue title should not attempt to follow a formal syntax.`
|
||||
)
|
||||
}
|
||||
|
||||
const issueProject = issue.project
|
||||
if (issueProject && issueProject.completedAt) {
|
||||
lints.push(
|
||||
`Issue ${issue.identifier} is associated with a completed project (${issueProject.name}). Consider removing the project association.`
|
||||
)
|
||||
}
|
||||
|
||||
return lints.map((message) => ({ message }))
|
||||
}
|
||||
|
||||
const _hasLabelOfCategory = (issue: lin.Issue, category: string) => {
|
||||
return issue.labels.nodes.some((label) => label.parent?.name === category)
|
||||
}
|
||||
|
||||
const _hasLabel = (props: { issue: lin.Issue; label: string; category?: string }) => {
|
||||
const { issue, label, category } = props
|
||||
return issue.labels.nodes.some((l) => l.name === label && (category ? l.parent?.name === category : true))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as types from '../types'
|
||||
import * as lin from '../utils/linear-utils'
|
||||
import * as sts from './state-service'
|
||||
|
||||
export class IssueStateChecker {
|
||||
public constructor(
|
||||
private _linear: lin.LinearApi,
|
||||
private _stateService: sts.StateService,
|
||||
private _logger: sdk.BotLogger,
|
||||
private _botId: string
|
||||
) {}
|
||||
|
||||
public async processIssues(props: { stateAttributes: types.StateAttributes; teams: string[] }) {
|
||||
const { stateAttributes, teams } = props
|
||||
|
||||
const stateIdsToInclude = await this._stateService.mapToStateIds([stateAttributes.commonStateName])
|
||||
|
||||
let hasNextPage = false
|
||||
let endCursor: string | undefined = undefined
|
||||
do {
|
||||
const { issues, pagination } = await this._linear.listIssues(
|
||||
{
|
||||
teamKeys: teams,
|
||||
stateIdsToInclude,
|
||||
updatedBefore: stateAttributes.maxTimeSinceLastUpdate,
|
||||
},
|
||||
endCursor
|
||||
)
|
||||
|
||||
for (const issue of issues) {
|
||||
await this._linear.createComment({
|
||||
issueId: issue.id,
|
||||
botId: this._botId,
|
||||
body: stateAttributes.warningComment,
|
||||
})
|
||||
this._logger.warn(stateAttributes.buildWarningReason(issue.identifier))
|
||||
}
|
||||
|
||||
hasNextPage = pagination?.hasNextPage ?? false
|
||||
endCursor = pagination?.endCursor
|
||||
} while (hasNextPage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as lin from '../utils/linear-utils'
|
||||
|
||||
const RECENT_THRESHOLD: number = 1000 * 60 * 10 // 10 minutes
|
||||
|
||||
export class RecentlyLintedManager {
|
||||
public constructor(private _linear: lin.LinearApi) {}
|
||||
|
||||
public async isRecentlyLinted(issue: lin.Issue): Promise<boolean> {
|
||||
const me = await this._linear.getViewerId()
|
||||
const timestamps = issue.comments.nodes
|
||||
.filter((comment) => comment.user?.id === me)
|
||||
.map((comment) => new Date(comment.createdAt).getTime())
|
||||
const now = new Date().getTime()
|
||||
for (const timestamp of timestamps) {
|
||||
if (now - timestamp < RECENT_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as types from '../types'
|
||||
import * as lin from '../utils/linear-utils'
|
||||
|
||||
type StateResolver = {
|
||||
byName?: Record<string, types.CommonStateName>
|
||||
default: types.CommonStateName
|
||||
}
|
||||
|
||||
const COMMON_STATE_BY_TYPE: Record<types.LinearStateType, StateResolver> = {
|
||||
triage: { default: 'TRIAGE' },
|
||||
backlog: { default: 'BACKLOG' },
|
||||
unstarted: { default: 'TODO' },
|
||||
started: {
|
||||
byName: { staging: 'STAGING', blocked: 'BLOCKED' },
|
||||
default: 'IN_PROGRESS',
|
||||
},
|
||||
completed: { default: 'DONE' },
|
||||
canceled: {
|
||||
byName: { stale: 'STALE' },
|
||||
default: 'CANCELED',
|
||||
},
|
||||
duplicate: {
|
||||
default: 'DUPLICATE',
|
||||
},
|
||||
}
|
||||
|
||||
type StateEntry = { state: types.LinearState; key?: types.CommonStateName }
|
||||
|
||||
export class StateService {
|
||||
private _stateEntries?: StateEntry[] = undefined
|
||||
|
||||
public constructor(private _linear: lin.LinearApi) {}
|
||||
|
||||
private async _getClassifiedStates(): Promise<StateEntry[]> {
|
||||
if (!this._stateEntries) {
|
||||
const states = await this._linear.getStates()
|
||||
this._stateEntries = states.map((state) => ({ state, key: this._findCommonState(state) }))
|
||||
}
|
||||
return this._stateEntries
|
||||
}
|
||||
|
||||
public async mapToStateIds(keys: types.CommonStateName[]): Promise<string[]> {
|
||||
const states = await this._getClassifiedStates()
|
||||
return keys.flatMap((key) => {
|
||||
const relevantStates = states.filter((state) => state.key === key)
|
||||
const ids = relevantStates.map((state) => state.state.id)
|
||||
return ids
|
||||
})
|
||||
}
|
||||
|
||||
public async getIssueCommonStateName(issue: lin.Issue): Promise<types.CommonStateName | undefined> {
|
||||
const states = await this._getClassifiedStates()
|
||||
const state = states.find((s) => s.state.id === issue.state.id)
|
||||
if (!state) {
|
||||
throw new Error(`State with ID "${issue.state.id}" not found.`)
|
||||
}
|
||||
return state.key
|
||||
}
|
||||
|
||||
private _findCommonState = (state: types.LinearState): types.CommonStateName | undefined => {
|
||||
const resolver = COMMON_STATE_BY_TYPE[state.type]
|
||||
if (!resolver) {
|
||||
return
|
||||
}
|
||||
const name = state.name.toLowerCase()
|
||||
return resolver.byName?.[name] ?? resolver.default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as lin from '../utils/linear-utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export class TeamsManager {
|
||||
public constructor(
|
||||
private _linear: lin.LinearApi,
|
||||
private _client: bp.Client,
|
||||
private _botId: string
|
||||
) {}
|
||||
|
||||
public async addWatchedTeam(key: string): Promise<void> {
|
||||
const teamKeys = await this._getWatchedTeams()
|
||||
if (teamKeys.includes(key)) {
|
||||
throw new Error(`The team with the key '${key}' is already being watched.`)
|
||||
}
|
||||
if (!(await this._linear.isTeam(key))) {
|
||||
throw new Error(`The team with the key '${key}' does not exist.`)
|
||||
}
|
||||
|
||||
await this._setWatchedTeams([...teamKeys, key])
|
||||
}
|
||||
|
||||
public async removeWatchedTeam(key: string): Promise<void> {
|
||||
const teamKeys = await this._getWatchedTeams()
|
||||
if (!teamKeys.includes(key)) {
|
||||
throw new Error(`The team with the key '${key}' is not currently being watched.`)
|
||||
}
|
||||
await this._setWatchedTeams(teamKeys.filter((team) => team !== key))
|
||||
}
|
||||
|
||||
public async listWatchedTeams(): Promise<string[]> {
|
||||
const teamKeys = await this._getWatchedTeams()
|
||||
return teamKeys
|
||||
}
|
||||
|
||||
private _getWatchedTeams = async () => {
|
||||
return (
|
||||
await this._client.getOrSetState({
|
||||
id: this._botId,
|
||||
name: 'watchedTeams',
|
||||
type: 'bot',
|
||||
payload: {
|
||||
teamKeys: [],
|
||||
},
|
||||
})
|
||||
).state.payload.teamKeys
|
||||
}
|
||||
|
||||
private _setWatchedTeams = async (teamKeys: string[]) => {
|
||||
await this._client.setState({
|
||||
id: this._botId,
|
||||
name: 'watchedTeams',
|
||||
type: 'bot',
|
||||
payload: {
|
||||
teamKeys,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export type CommonHandlerProps = bp.WorkflowHandlerProps['lintAll'] | bp.EventHandlerProps | bp.MessageHandlerProps
|
||||
|
||||
export type LintResult =
|
||||
| {
|
||||
identifier: string
|
||||
result: 'failed'
|
||||
messages: string[]
|
||||
}
|
||||
| {
|
||||
identifier: string
|
||||
result: 'succeeded' | 'ignored'
|
||||
}
|
||||
|
||||
export type LinearStateType = 'triage' | 'backlog' | 'unstarted' | 'started' | 'completed' | 'canceled' | 'duplicate'
|
||||
export type CommonStateName =
|
||||
| 'IN_PROGRESS'
|
||||
| 'STAGING'
|
||||
| 'DONE'
|
||||
| 'BACKLOG'
|
||||
| 'TODO'
|
||||
| 'TRIAGE'
|
||||
| 'CANCELED'
|
||||
| 'BLOCKED'
|
||||
| 'STALE'
|
||||
| 'DUPLICATE'
|
||||
|
||||
export type StateAttributes = {
|
||||
commonStateName: CommonStateName
|
||||
maxTimeSinceLastUpdate: ISO8601Duration
|
||||
warningComment: string
|
||||
buildWarningReason: (issueIdentifier: string) => string
|
||||
}
|
||||
|
||||
export type LinearTeam = {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
description?: string | undefined
|
||||
icon?: string | undefined
|
||||
}
|
||||
|
||||
export type LinearState = {
|
||||
id: string
|
||||
name: string
|
||||
type: LinearStateType
|
||||
}
|
||||
|
||||
export type ISO8601Duration = string
|
||||
|
||||
type CommandResult = { success: boolean; message: string }
|
||||
export type CommandImplementation = (args: string[], conversationId: string) => CommandResult | Promise<CommandResult>
|
||||
export type CommandDefinition = {
|
||||
name: string
|
||||
requiredArgs?: string[]
|
||||
optionalArgs?: string[]
|
||||
implementation: CommandImplementation
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as types from '../types'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type BotMessage = Pick<bp.ClientInputs['createMessage'], 'type' | 'payload'>
|
||||
|
||||
type ErrorHandlerProps = {
|
||||
context: string
|
||||
conversationId?: string
|
||||
}
|
||||
|
||||
export class BotpressApi {
|
||||
private constructor(
|
||||
private _client: bp.Client,
|
||||
private _botId: string,
|
||||
private _logger: sdk.BotLogger
|
||||
) {}
|
||||
|
||||
public static create(props: types.CommonHandlerProps): BotpressApi {
|
||||
return new BotpressApi(props.client, props.ctx.botId, props.logger)
|
||||
}
|
||||
|
||||
public async respond(conversationId: string, msg: BotMessage): Promise<void> {
|
||||
await this._client.createMessage({
|
||||
type: msg.type,
|
||||
payload: msg.payload,
|
||||
conversationId,
|
||||
userId: this._botId,
|
||||
tags: {},
|
||||
})
|
||||
}
|
||||
|
||||
public async respondText(conversationId: string, msg: string): Promise<void> {
|
||||
return this.respond(conversationId, {
|
||||
type: 'text',
|
||||
payload: { text: msg },
|
||||
})
|
||||
}
|
||||
|
||||
public handleError = async (props: ErrorHandlerProps, thrown: unknown): Promise<never> => {
|
||||
const error = thrown instanceof Error ? thrown : new Error(String(thrown))
|
||||
const message = `An error occured while ${props.context}: ${error.message}`
|
||||
this._logger.error(message)
|
||||
if (props.conversationId) {
|
||||
await this.respondText(props.conversationId, message).catch(() => {}) // if this fails, there's nothing we can do
|
||||
}
|
||||
throw new sdk.RuntimeError(error.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * as linear from './linear-utils'
|
||||
export * as botpress from './botpress-utils'
|
||||
@@ -0,0 +1,193 @@
|
||||
import * as types from '../../types'
|
||||
import * as graphql from './graphql-queries'
|
||||
import { Client } from '.botpress'
|
||||
|
||||
const ISSUES_PER_PAGE = 50
|
||||
const STATES_PER_PAGE = 200
|
||||
|
||||
export class LinearApi {
|
||||
private _teams?: types.LinearTeam[] = undefined
|
||||
private _states?: types.LinearState[] = undefined
|
||||
private _viewerId?: string = undefined
|
||||
|
||||
private constructor(private _bpClient: Client) {}
|
||||
|
||||
public static create(bpClient: Client): LinearApi {
|
||||
return new LinearApi(bpClient)
|
||||
}
|
||||
|
||||
public async getViewerId(): Promise<string> {
|
||||
if (this._viewerId) {
|
||||
return this._viewerId
|
||||
}
|
||||
const { output: me } = await this._bpClient.callAction({
|
||||
type: 'linear:getUser',
|
||||
input: {},
|
||||
})
|
||||
if (!me) {
|
||||
throw new Error('Viewer not found. Please ensure you are authenticated.')
|
||||
}
|
||||
this._viewerId = me.linearId
|
||||
return this._viewerId
|
||||
}
|
||||
|
||||
public async isTeam(teamKey: string) {
|
||||
return (await this.getTeams()).some((team) => team.key === teamKey)
|
||||
}
|
||||
|
||||
public async findIssue(filter: { teamKey: string; issueNumber: number }): Promise<graphql.Issue | undefined> {
|
||||
const { teamKey, issueNumber } = filter
|
||||
|
||||
const { issues } = await this.listIssues({
|
||||
teamKeys: [teamKey],
|
||||
issueNumber,
|
||||
})
|
||||
|
||||
const [issue] = issues
|
||||
if (!issue) {
|
||||
return undefined
|
||||
}
|
||||
return issue
|
||||
}
|
||||
|
||||
public async listIssues(
|
||||
filter: {
|
||||
teamKeys: string[]
|
||||
issueNumber?: number
|
||||
stateIdsToOmit?: string[]
|
||||
stateIdsToInclude?: string[]
|
||||
updatedBefore?: types.ISO8601Duration
|
||||
},
|
||||
nextPage?: string
|
||||
): Promise<{ issues: graphql.Issue[]; pagination?: graphql.Pagination }> {
|
||||
const { teamKeys, issueNumber, stateIdsToOmit, stateIdsToInclude, updatedBefore } = filter
|
||||
|
||||
const teams = await this.getTeams()
|
||||
const teamsExist = teamKeys.every((key) => teams.some((team) => team.key === key))
|
||||
if (!teamsExist) {
|
||||
return { issues: [] }
|
||||
}
|
||||
|
||||
const queryInput: graphql.GRAPHQL_QUERIES['listIssues'][graphql.QUERY_INPUT] = {
|
||||
filter: {
|
||||
team: { key: { in: teamKeys } },
|
||||
...(issueNumber && { number: { eq: issueNumber } }),
|
||||
state: {
|
||||
id: {
|
||||
...(stateIdsToOmit && { nin: stateIdsToOmit }),
|
||||
...(stateIdsToInclude && { in: stateIdsToInclude }),
|
||||
},
|
||||
},
|
||||
...(updatedBefore && { updatedAt: { lt: updatedBefore } }),
|
||||
},
|
||||
...(nextPage && { after: nextPage }),
|
||||
first: ISSUES_PER_PAGE,
|
||||
orderBy: 'createdAt',
|
||||
}
|
||||
|
||||
const data = await this._executeGraphqlQuery('listIssues', queryInput)
|
||||
|
||||
return { issues: data.issues.nodes, pagination: data.issues.pageInfo }
|
||||
}
|
||||
|
||||
public async resolveComments(issue: graphql.Issue): Promise<void> {
|
||||
const comments = issue.comments.nodes
|
||||
const me = await this.getViewerId()
|
||||
|
||||
const promises: ReturnType<typeof this._bpClient.callAction<'linear:resolveComment'>>[] = []
|
||||
for (const comment of comments) {
|
||||
if (comment.user?.id === me && !comment.parentId && !comment.resolvedAt) {
|
||||
promises.push(this._bpClient.callAction({ type: 'linear:resolveComment', input: { id: comment.id } }))
|
||||
}
|
||||
}
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
public async createComment(props: { body: string; issueId: string; botId: string }): Promise<void> {
|
||||
const { body, issueId, botId } = props
|
||||
const conversation = await this._bpClient.callAction({
|
||||
type: 'linear:getOrCreateIssueConversation',
|
||||
input: {
|
||||
conversation: { id: issueId },
|
||||
},
|
||||
})
|
||||
|
||||
await this._bpClient.createMessage({
|
||||
type: 'text',
|
||||
conversationId: conversation.output.conversationId,
|
||||
payload: { text: body },
|
||||
tags: {},
|
||||
userId: botId,
|
||||
})
|
||||
}
|
||||
|
||||
public async findTeamStates(teamKey: string): Promise<graphql.TeamStates | undefined> {
|
||||
const queryInput: graphql.GRAPHQL_QUERIES['findTeamStates'][graphql.QUERY_INPUT] = {
|
||||
filter: { key: { eq: teamKey } },
|
||||
}
|
||||
|
||||
const data = await this._executeGraphqlQuery('findTeamStates', queryInput)
|
||||
|
||||
const [team] = data.organization.teams.nodes
|
||||
if (!team) {
|
||||
return undefined
|
||||
}
|
||||
return team
|
||||
}
|
||||
|
||||
public async getTeams(): Promise<types.LinearTeam[]> {
|
||||
if (!this._teams) {
|
||||
this._teams = await this._listAllTeams()
|
||||
}
|
||||
return this._teams
|
||||
}
|
||||
|
||||
public async getStates(): Promise<types.LinearState[]> {
|
||||
if (!this._states) {
|
||||
this._states = await this._listAllStates()
|
||||
}
|
||||
return this._states
|
||||
}
|
||||
|
||||
private _listAllTeams = async (): Promise<types.LinearTeam[]> => {
|
||||
const response = await this._bpClient.callAction({ type: 'linear:listTeams', input: {} })
|
||||
return response.output.teams
|
||||
}
|
||||
|
||||
private _listAllStates = async (): Promise<types.LinearState[]> => {
|
||||
// We fetch states via GraphQL rather than the linear:listStates action because the action's
|
||||
// output does not include the state `type`, which we need to normalize states across teams.
|
||||
let states: types.LinearState[] = []
|
||||
let after: string | undefined = undefined
|
||||
|
||||
do {
|
||||
const queryInput: graphql.GRAPHQL_QUERIES['listStates'][graphql.QUERY_INPUT] = {
|
||||
first: STATES_PER_PAGE,
|
||||
...(after && { after }),
|
||||
}
|
||||
const data = await this._executeGraphqlQuery('listStates', queryInput)
|
||||
states = states.concat(data.workflowStates.nodes)
|
||||
after = data.workflowStates.pageInfo.hasNextPage ? data.workflowStates.pageInfo.endCursor : undefined
|
||||
} while (after)
|
||||
|
||||
return states
|
||||
}
|
||||
|
||||
private async _executeGraphqlQuery<K extends keyof graphql.GRAPHQL_QUERIES>(
|
||||
queryName: K,
|
||||
variables: graphql.GRAPHQL_QUERIES[K][graphql.QUERY_INPUT]
|
||||
): Promise<graphql.GRAPHQL_QUERIES[K][graphql.QUERY_RESPONSE]> {
|
||||
const params = Object.entries(variables).map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
}))
|
||||
const result = await this._bpClient.callAction({
|
||||
type: 'linear:sendRawGraphqlQuery',
|
||||
input: {
|
||||
query: graphql.GRAPHQL_QUERIES[queryName].query,
|
||||
parameters: params,
|
||||
},
|
||||
})
|
||||
return result.output.result as graphql.GRAPHQL_QUERIES[K][graphql.QUERY_RESPONSE]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as types from 'src/types'
|
||||
|
||||
const QUERY_INPUT = Symbol('graphqlInputType')
|
||||
const QUERY_RESPONSE = Symbol('graphqlResponseType')
|
||||
|
||||
type GraphQLQuery<TInput, TResponse> = {
|
||||
query: string
|
||||
[QUERY_INPUT]: TInput
|
||||
[QUERY_RESPONSE]: TResponse
|
||||
}
|
||||
|
||||
export type Issue = {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
estimate: number | null
|
||||
priority: number
|
||||
assignee: {
|
||||
id: string
|
||||
} | null
|
||||
state: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
labels: {
|
||||
nodes: {
|
||||
name: string
|
||||
parent: {
|
||||
name: string
|
||||
} | null
|
||||
}[]
|
||||
}
|
||||
inverseRelations: {
|
||||
nodes: {
|
||||
type: string
|
||||
}[]
|
||||
}
|
||||
project: {
|
||||
id: string
|
||||
name: string
|
||||
completedAt: string | null
|
||||
} | null
|
||||
comments: {
|
||||
nodes: {
|
||||
id: string
|
||||
resolvedAt: string | null
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
} | null
|
||||
parentId: string | null
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export type TeamStates = {
|
||||
id: string
|
||||
states: {
|
||||
nodes: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export type Pagination = {
|
||||
hasNextPage: boolean
|
||||
endCursor: string
|
||||
}
|
||||
|
||||
export const GRAPHQL_QUERIES = {
|
||||
listIssues: {
|
||||
query: `
|
||||
query FindIssue($filter: IssueFilter, $first: Int, $after: String, $orderBy: PaginationOrderBy) {
|
||||
issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
nodes {
|
||||
id,
|
||||
identifier,
|
||||
title,
|
||||
estimate,
|
||||
priority,
|
||||
assignee {
|
||||
id
|
||||
},
|
||||
state {
|
||||
id
|
||||
name
|
||||
},
|
||||
labels {
|
||||
nodes {
|
||||
name
|
||||
parent {
|
||||
name
|
||||
}
|
||||
}
|
||||
},
|
||||
inverseRelations {
|
||||
nodes {
|
||||
type
|
||||
}
|
||||
},
|
||||
project {
|
||||
id,
|
||||
name,
|
||||
completedAt
|
||||
}
|
||||
comments {
|
||||
nodes {
|
||||
id,
|
||||
user {
|
||||
id
|
||||
},
|
||||
parentId,
|
||||
resolvedAt,
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}`,
|
||||
[QUERY_INPUT]: {} as {
|
||||
filter: {
|
||||
team?: { key: { in: string[] } }
|
||||
number?: { eq: number }
|
||||
state?: {
|
||||
id: {
|
||||
nin?: string[]
|
||||
in?: string[]
|
||||
}
|
||||
}
|
||||
updatedAt?: {
|
||||
lt: types.ISO8601Duration
|
||||
}
|
||||
}
|
||||
after?: string
|
||||
first?: number
|
||||
orderBy?: 'createdAt' | 'updatedAt'
|
||||
},
|
||||
[QUERY_RESPONSE]: {} as {
|
||||
issues: {
|
||||
nodes: Issue[]
|
||||
pageInfo: Pagination
|
||||
}
|
||||
},
|
||||
},
|
||||
listStates: {
|
||||
query: `
|
||||
query ListStates($first: Int, $after: String) {
|
||||
workflowStates(first: $first, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
type
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}`,
|
||||
[QUERY_INPUT]: {} as {
|
||||
first?: number
|
||||
after?: string
|
||||
},
|
||||
[QUERY_RESPONSE]: {} as {
|
||||
workflowStates: {
|
||||
nodes: types.LinearState[]
|
||||
pageInfo: Pagination
|
||||
}
|
||||
},
|
||||
},
|
||||
findTeamStates: {
|
||||
query: `
|
||||
query GetAllTeams($filter: TeamFilter) {
|
||||
organization {
|
||||
teams(filter: $filter) {
|
||||
nodes {
|
||||
id
|
||||
key
|
||||
states {
|
||||
nodes {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
[QUERY_INPUT]: {} as {
|
||||
filter: {
|
||||
key?: { eq: string }
|
||||
}
|
||||
},
|
||||
[QUERY_RESPONSE]: {} as {
|
||||
organization: {
|
||||
teams: {
|
||||
nodes: {
|
||||
id: string
|
||||
states: {
|
||||
nodes: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
}[]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, GraphQLQuery<object, object>>
|
||||
|
||||
export type GRAPHQL_QUERIES = typeof GRAPHQL_QUERIES
|
||||
export type QUERY_INPUT = typeof QUERY_INPUT
|
||||
export type QUERY_RESPONSE = typeof QUERY_RESPONSE
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './client'
|
||||
export { Issue, Pagination } from './graphql-queries'
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import slack from 'bp_modules/slack'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
states: {
|
||||
metaApiVersions: {
|
||||
type: 'bot',
|
||||
schema: sdk.z.object({
|
||||
currentGraphApiVersion: sdk.z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The current Meta's Graph API version")
|
||||
.title('Current Graph API Version'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
events: {
|
||||
timeToCheckApi: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
},
|
||||
recurringEvents: {
|
||||
timeToCheckApi: {
|
||||
type: 'timeToCheckApi',
|
||||
schedule: { cron: '0 * * * *' },
|
||||
payload: sdk.z.object({}),
|
||||
},
|
||||
},
|
||||
}).addIntegration(slack, {
|
||||
enabled: true,
|
||||
configurationType: null,
|
||||
configuration: {
|
||||
typingIndicatorEmoji: false,
|
||||
botName: 'Clog',
|
||||
botAvatarUrl: 'https://files.bpcontent.cloud/2025/06/16/20/20250616204038-BRUW6C2R.svg',
|
||||
replyBehaviour: {
|
||||
location: 'channel',
|
||||
onlyOnBotMention: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@bp-bots/clog",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"cheerio": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/slack": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"slack": "../../integrations/slack"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const SLACK_CHANNEL_TO_PING = 'alert-squid'
|
||||
|
||||
const bot = new bp.Bot({
|
||||
actions: {},
|
||||
})
|
||||
|
||||
const _resolveSlackChannelId = async (client: bp.Client, channelName: string): Promise<string | undefined> => {
|
||||
const { output } = await client.callAction({
|
||||
type: 'slack:findTarget',
|
||||
input: { query: channelName, channel: 'channel' },
|
||||
})
|
||||
const exact = output.targets.find((t) => t.displayName === channelName)
|
||||
return (exact ?? output.targets[0])?.tags.id
|
||||
}
|
||||
|
||||
const _handleApiChange = async (
|
||||
message: string,
|
||||
newGraphApiVersion: string | undefined,
|
||||
props: bp.EventHandlerProps
|
||||
): Promise<void> => {
|
||||
const { client, logger } = props
|
||||
logger.info(message)
|
||||
const channelId = await _resolveSlackChannelId(client, SLACK_CHANNEL_TO_PING)
|
||||
if (!channelId) {
|
||||
logger.error(`Could not find Slack channel '${SLACK_CHANNEL_TO_PING}'`)
|
||||
return
|
||||
}
|
||||
const response = await client.callAction({
|
||||
type: 'slack:getOrCreateChannelConversation',
|
||||
input: { conversation: { channelId } },
|
||||
})
|
||||
await client.createMessage({
|
||||
type: 'text',
|
||||
conversationId: response.output.conversationId,
|
||||
tags: {},
|
||||
userId: props.ctx.botId,
|
||||
payload: {
|
||||
text: message,
|
||||
},
|
||||
})
|
||||
await client.setState({
|
||||
name: 'metaApiVersions',
|
||||
type: 'bot',
|
||||
id: props.ctx.botId,
|
||||
payload: { currentGraphApiVersion: newGraphApiVersion },
|
||||
})
|
||||
}
|
||||
|
||||
// Checks if starts with v, has a number of at least one digit, a dot and a single digit (e.g. v00.0)
|
||||
const versionRegexp: RegExp = /v\d+.\d/i
|
||||
const isVersionString = (s: string): boolean => versionRegexp.test(s)
|
||||
|
||||
bot.on.event('timeToCheckApi', async (props) => {
|
||||
const { client, ctx, logger } = props
|
||||
const { state } = await client.getOrSetState({
|
||||
name: 'metaApiVersions',
|
||||
type: 'bot',
|
||||
id: ctx.botId,
|
||||
payload: { currentGraphApiVersion: undefined },
|
||||
})
|
||||
const currentGraphApiVersion = state.payload.currentGraphApiVersion
|
||||
|
||||
const response = await fetch('https://developers.facebook.com/docs/graph-api/changelog/')
|
||||
const html = await response.text()
|
||||
const selector = cheerio.load(html)
|
||||
const newGraphApiVersion = selector('code').first().text().trim()
|
||||
|
||||
if (!isVersionString(newGraphApiVersion)) {
|
||||
await _handleApiChange(
|
||||
`I failed reading the Meta's API version, I received\n${newGraphApiVersion}`,
|
||||
undefined,
|
||||
props
|
||||
)
|
||||
} else if (currentGraphApiVersion === undefined) {
|
||||
await _handleApiChange(
|
||||
`I'll notify you when Meta's Graph API version will change.\nThe current version is ${newGraphApiVersion}`,
|
||||
newGraphApiVersion,
|
||||
props
|
||||
)
|
||||
} else if (currentGraphApiVersion !== newGraphApiVersion) {
|
||||
await _handleApiChange(`Meta's Graph API version changed to: ${newGraphApiVersion}`, newGraphApiVersion, props)
|
||||
} else {
|
||||
logger.info(`Meta's Graph API version is ${newGraphApiVersion}`)
|
||||
}
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import todoist from './bp_modules/todoist'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
configuration: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
states: {},
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
user: {},
|
||||
conversation: {},
|
||||
})
|
||||
.addIntegration(todoist, {
|
||||
alias: 'todoist-src',
|
||||
enabled: true,
|
||||
configurationType: 'apiToken',
|
||||
configuration: { apiToken: genenv.TODOIST_SRC_API_TOKEN },
|
||||
})
|
||||
.addIntegration(todoist, {
|
||||
alias: 'todoist-dst',
|
||||
enabled: true,
|
||||
configurationType: 'apiToken',
|
||||
configuration: { apiToken: genenv.TODOIST_DST_API_TOKEN },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@bp-bots/doppel-doer",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e TODOIST_SRC_API_TOKEN -e TODOIST_DST_API_TOKEN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/todoist": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"todoist": "../../integrations/todoist"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const bot = new bp.Bot({ actions: {} })
|
||||
|
||||
bot.on.afterIncomingEvent('todoist-src:taskAdded', async (props) => {
|
||||
props.logger.info(`Copying task "${props.data.payload.content}" from todoist-src to todoist-dst...`)
|
||||
|
||||
const newTask = await props.client.callAction({
|
||||
type: 'todoist-dst:createNewTask',
|
||||
input: {
|
||||
content: props.data.payload.content,
|
||||
description: props.data.payload.description,
|
||||
priority: props.data.payload.priority,
|
||||
},
|
||||
})
|
||||
|
||||
const newTaskId = newTask.output.taskId
|
||||
|
||||
props.logger.info(`Created new task in todoist-dst with ID ${newTaskId}`)
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import dropbox from './bp_modules/dropbox'
|
||||
import fileSynchronizer from './bp_modules/file-synchronizer'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
configuration: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
})
|
||||
.addIntegration(dropbox, {
|
||||
alias: 'dropbox-a',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
authorizationCode: genenv.DROPBOX_A_ACCESS_CODE,
|
||||
clientId: genenv.DROPBOX_A_APP_KEY,
|
||||
clientSecret: genenv.DROPBOX_A_APP_SECRET,
|
||||
},
|
||||
})
|
||||
.addIntegration(dropbox, {
|
||||
alias: 'dropbox-b',
|
||||
enabled: true,
|
||||
configuration: {
|
||||
authorizationCode: genenv.DROPBOX_B_ACCESS_CODE,
|
||||
clientId: genenv.DROPBOX_B_APP_KEY,
|
||||
clientSecret: genenv.DROPBOX_B_APP_SECRET,
|
||||
},
|
||||
})
|
||||
.addPlugin(fileSynchronizer, {
|
||||
alias: 'file-synchronizer-a',
|
||||
configuration: {
|
||||
includeFiles: [{ pathGlobPattern: '**' }],
|
||||
excludeFiles: [],
|
||||
enableRealTimeSync: true,
|
||||
},
|
||||
dependencies: {
|
||||
'files-readonly': {
|
||||
integrationAlias: 'dropbox-a',
|
||||
integrationInterfaceAlias: 'files-readonly',
|
||||
},
|
||||
},
|
||||
})
|
||||
.addPlugin(fileSynchronizer, {
|
||||
alias: 'file-synchronizer-b',
|
||||
configuration: {
|
||||
includeFiles: [{ pathGlobPattern: '**' }],
|
||||
excludeFiles: [],
|
||||
enableRealTimeSync: true,
|
||||
},
|
||||
dependencies: {
|
||||
'files-readonly': {
|
||||
integrationAlias: 'dropbox-b',
|
||||
integrationInterfaceAlias: 'files-readonly',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@bp-bots/drop-weaver",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e DROPBOX_A_APP_KEY -e DROPBOX_A_APP_SECRET -e DROPBOX_A_ACCESS_CODE -e DROPBOX_B_APP_KEY -e DROPBOX_B_APP_SECRET -e DROPBOX_B_ACCESS_CODE",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpresshub/dropbox": "workspace:*",
|
||||
"@botpresshub/file-synchronizer": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"file-synchronizer": "../../plugins/file-synchronizer",
|
||||
"dropbox": "../../integrations/dropbox"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const bot = new bp.Bot({
|
||||
actions: {},
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import chat from './bp_modules/chat'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
integrations: {},
|
||||
states: {},
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
}).addIntegration(chat, { enabled: true, configuration: {} })
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@bp-bots/echo",
|
||||
"description": "Echo bot used by the chat-e2e test suite",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/chat": "workspace:*"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"chat": "../../integrations/chat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
type CreateMessageArgs = bp.ClientInputs['createMessage']
|
||||
type RespondArgs = CreateMessageArgs['payload'] & {
|
||||
type: CreateMessageArgs['type']
|
||||
}
|
||||
|
||||
export type ApiProps = bp.MessageHandlerProps | bp.EventHandlerProps
|
||||
|
||||
export class Api {
|
||||
public static from = (args: ApiProps): Api => new Api(args)
|
||||
|
||||
private constructor(private _args: ApiProps) {}
|
||||
|
||||
public async respond(resp: RespondArgs) {
|
||||
const { type, ...payload } = resp
|
||||
|
||||
let conversationId: string
|
||||
if ('message' in this._args) {
|
||||
conversationId = this._args.message.conversationId
|
||||
} else {
|
||||
conversationId = this._args.event.payload.conversationId
|
||||
}
|
||||
|
||||
await this._args.client.createMessage({
|
||||
conversationId,
|
||||
userId: this._args.ctx.botId,
|
||||
tags: {},
|
||||
type,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
export const bot = new bp.Bot({
|
||||
actions: {},
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Api } from './api'
|
||||
import { bot } from './bot'
|
||||
import { markdown } from './markdown'
|
||||
|
||||
const STEAK = 'https://upload.wikimedia.org/wikipedia/commons/9/91/T-bone-raw-MCB.jpg'
|
||||
const LIZST = 'https://vmirror.imslp.org/files/imglnks/usimg/5/5c/IMSLP301563-PMLP02598-upload.mp3'
|
||||
|
||||
bot.on.event('*', async (args) => {
|
||||
console.info('received event', {
|
||||
conversationId: args.event.conversationId,
|
||||
userId: args.event.userId,
|
||||
})
|
||||
|
||||
if (args.event.type === 'chat:custom') {
|
||||
console.info('custom event received', args.event.payload)
|
||||
await args.client.callAction({
|
||||
type: 'chat:sendEvent',
|
||||
input: args.event.payload,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
bot.on.message('*', async (args) => {
|
||||
console.info('received message', args.message)
|
||||
|
||||
const api = Api.from(args)
|
||||
if (args.message.type !== 'text') {
|
||||
await api.respond({ type: 'text', text: 'I only understand text messages' })
|
||||
return
|
||||
}
|
||||
|
||||
const text = args.message.payload.text.toLowerCase()
|
||||
switch (text) {
|
||||
case 'text':
|
||||
await api.respond({ type: 'text', text: 'Hello, world!' })
|
||||
break
|
||||
|
||||
case 'markdown':
|
||||
await api.respond({
|
||||
type: 'markdown',
|
||||
markdown,
|
||||
})
|
||||
break
|
||||
case 'image':
|
||||
await api.respond({
|
||||
type: 'image',
|
||||
imageUrl: STEAK,
|
||||
})
|
||||
break
|
||||
case 'location':
|
||||
await api.respond({
|
||||
type: 'location',
|
||||
latitude: 40.748817,
|
||||
longitude: -73.985428,
|
||||
})
|
||||
break
|
||||
case 'pdf':
|
||||
await api.respond({
|
||||
type: 'file',
|
||||
fileUrl: 'https://cdn.botpress.dev/test.pdf',
|
||||
})
|
||||
break
|
||||
case 'file':
|
||||
await api.respond({
|
||||
type: 'file',
|
||||
fileUrl: 'https://spg-test-public-files.s3.amazonaws.com/cities.csv',
|
||||
})
|
||||
break
|
||||
case 'video':
|
||||
await api.respond({
|
||||
type: 'video',
|
||||
videoUrl: 'https://spg-test-public-files.s3.us-east-1.amazonaws.com/sample-mp4-file-small.mp4',
|
||||
})
|
||||
break
|
||||
case 'audio':
|
||||
await api.respond({
|
||||
type: 'audio',
|
||||
audioUrl: LIZST,
|
||||
})
|
||||
break
|
||||
case 'choice':
|
||||
await api.respond({
|
||||
type: 'choice',
|
||||
text: 'Choose an option',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
],
|
||||
})
|
||||
break
|
||||
case 'dropdown':
|
||||
await api.respond({
|
||||
type: 'dropdown',
|
||||
text: 'Choose an option',
|
||||
options: [
|
||||
{ label: 'Option 1', value: 'option1' },
|
||||
{ label: 'Option 2', value: 'option2' },
|
||||
{ label: 'Option 3', value: 'option3' },
|
||||
{ label: 'Option 4', value: 'option4' },
|
||||
{ label: 'Option 5', value: 'option5' },
|
||||
{ label: 'Option 6', value: 'option6' },
|
||||
{ label: 'Option 7', value: 'option7' },
|
||||
{ label: 'Option 8', value: 'option8' },
|
||||
],
|
||||
})
|
||||
break
|
||||
case 'card':
|
||||
await api.respond({
|
||||
type: 'card',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
imageUrl: STEAK,
|
||||
actions: [
|
||||
{ action: 'url', label: 'label1', value: 'https://google.com' },
|
||||
{ action: 'say', label: 'label2', value: 'text' },
|
||||
],
|
||||
})
|
||||
break
|
||||
case 'carousel':
|
||||
await api.respond({
|
||||
type: 'carousel',
|
||||
items: [
|
||||
{
|
||||
title: 'title 1',
|
||||
subtitle: 'subtitle 1',
|
||||
actions: [
|
||||
{
|
||||
action: 'url',
|
||||
label: 'label 1',
|
||||
value: 'https://google.com',
|
||||
},
|
||||
],
|
||||
imageUrl: STEAK,
|
||||
},
|
||||
{
|
||||
title: 'title 2',
|
||||
subtitle: 'subtitle 2',
|
||||
actions: [
|
||||
{
|
||||
action: 'say',
|
||||
label: 'label 2',
|
||||
value: 'text 2',
|
||||
},
|
||||
],
|
||||
imageUrl: 'https://miro.medium.com/v2/resize:fit:800/1*gGzjds6d329U8umqHI0nKQ.jpeg',
|
||||
},
|
||||
],
|
||||
})
|
||||
break
|
||||
case 'link':
|
||||
await api.respond({
|
||||
type: 'text',
|
||||
text: '[Click here](https://google.com) to go to Google',
|
||||
})
|
||||
break
|
||||
case 'empty_choice':
|
||||
await api.respond({
|
||||
type: 'choice',
|
||||
text: 'Choose an option',
|
||||
options: [],
|
||||
})
|
||||
break
|
||||
case 'empty_card':
|
||||
await api.respond({
|
||||
type: 'card',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
actions: [],
|
||||
})
|
||||
break
|
||||
case 'bloc':
|
||||
await api.respond({
|
||||
type: 'bloc',
|
||||
items: [
|
||||
{
|
||||
type: 'text',
|
||||
payload: { text: 'Hello, world!' },
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
payload: { imageUrl: STEAK },
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
payload: { audioUrl: LIZST },
|
||||
},
|
||||
],
|
||||
})
|
||||
break
|
||||
case 'loop':
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await sleep(1000)
|
||||
await api.respond({ type: 'text', text: `Message ${i}` })
|
||||
}
|
||||
break
|
||||
case 'metadata':
|
||||
const { payload } = args.message
|
||||
const metadata = 'metadata' in payload ? payload.metadata : {}
|
||||
await api.respond({ type: 'text', text: 'metadata', metadata })
|
||||
break
|
||||
default:
|
||||
const { name } = args.user
|
||||
const hiMsg = name ? `Hi, ${name}` : 'Hi'
|
||||
await api.respond({ type: 'text', text: `${hiMsg}! You said: "${text}"` })
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,34 @@
|
||||
export const markdown = `
|
||||
# Title 1
|
||||
## Title 2
|
||||
### Title 3
|
||||
#### Title 4
|
||||
|
||||
- unordered list item 1
|
||||
- unordered list item 2
|
||||
- unordered list item 3
|
||||
|
||||
1. ordered list item 1
|
||||
2. ordered list item 2
|
||||
3. ordered list item 3
|
||||
|
||||
this is **bold text**
|
||||
|
||||
this is ~~strikethrough text~~
|
||||
|
||||
this is _italic text_
|
||||
|
||||
this is [a link](https://www.google.com)
|
||||
|
||||
this is a code block
|
||||
\`\`\`javascript
|
||||
console.log('hello world')
|
||||
\`\`\`
|
||||
|
||||
this is a table
|
||||
| header 1 | header 2 |
|
||||
|-----------|-----------|
|
||||
| cell l1 | cell r1 |
|
||||
| cell l2 | cell r2 |
|
||||
| cell l3 | cell r3 |
|
||||
`
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.botpress
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as env from './.genenv'
|
||||
import chat from './bp_modules/chat'
|
||||
import telegram from './bp_modules/telegram'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
actions: {
|
||||
sayHello: {
|
||||
title: 'Say Hello',
|
||||
description: 'Says hello to the caller',
|
||||
input: {
|
||||
schema: sdk.z.object({ name: sdk.z.string().optional() }),
|
||||
},
|
||||
output: {
|
||||
schema: sdk.z.object({ message: sdk.z.string() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.addIntegration(telegram, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
botToken: env.HELLO_WORLD_TELEGRAM_BOT_TOKEN,
|
||||
typingIndicatorEmoji: true,
|
||||
},
|
||||
})
|
||||
.addIntegration(chat, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@bp-bots/hello-world",
|
||||
"description": "Hello-world bot",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e HELLO_WORLD_TELEGRAM_BOT_TOKEN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/chat": "workspace:*",
|
||||
"@botpresshub/telegram": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1",
|
||||
"@types/json-schema": "^7.0.12"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"telegram": "../../integrations/telegram",
|
||||
"chat": "../../integrations/chat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Hello World
|
||||
|
||||
## Description
|
||||
|
||||
This is a simple hello-world bot that always reply with text "Hello World!".
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new bot using the Botpress Dashboard or the Botpress CLI.
|
||||
2. Build the bot using command `bp build`
|
||||
3. Deploy the bot using command `bp deploy`
|
||||
|
||||
## Running locally
|
||||
|
||||
There are few different ways of running your bot locally:
|
||||
|
||||
```sh
|
||||
# 1. To run the bundle created by the build command:
|
||||
|
||||
bp serve --port 9999
|
||||
|
||||
# 2. To run with ts-node:
|
||||
|
||||
start_script="import bot from './src'; void bot.start()"
|
||||
ts-node -T -r @botpress/cli/init -e $start_script
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const bot = new bp.Bot({
|
||||
register: async (props) => {
|
||||
props.logger.info('Registering Hello World bot!')
|
||||
},
|
||||
actions: {
|
||||
sayHello: async ({ input }) => {
|
||||
const name = input?.name || 'World'
|
||||
return { message: `Hello, ${name}!` }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
const { message, client, ctx } = props
|
||||
|
||||
const { message: response } = await bot.actionHandlers.sayHello({ ...props, input: {} })
|
||||
await client.createMessage({
|
||||
conversationId: message.conversationId,
|
||||
userId: ctx.botId,
|
||||
tags: {},
|
||||
type: 'text',
|
||||
payload: {
|
||||
text: response,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import chat from './bp_modules/chat'
|
||||
import hitl from './bp_modules/hitl'
|
||||
import zendesk from './bp_modules/zendesk'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
configuration: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
states: {},
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
user: {
|
||||
tags: {
|
||||
email: {
|
||||
title: 'Email',
|
||||
description: 'The email of the user',
|
||||
},
|
||||
},
|
||||
},
|
||||
conversation: {},
|
||||
})
|
||||
.addIntegration(chat, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
.addIntegration(zendesk, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
apiToken: genenv.HITLOOPER_ZENDESK_API_TOKEN,
|
||||
email: genenv.HITLOOPER_ZENDESK_EMAIL,
|
||||
organizationSubdomain: genenv.HITLOOPER_ZENDESK_ORGANIZATION_SUBDOMAIN,
|
||||
},
|
||||
})
|
||||
.addPlugin(hitl, {
|
||||
configuration: {
|
||||
flowOnHitlStopped: false,
|
||||
useHumanAgentInfo: false,
|
||||
},
|
||||
dependencies: {
|
||||
hitl: {
|
||||
integrationAlias: 'zendesk',
|
||||
integrationInterfaceAlias: 'hitl<hitlTicket>',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@bp-bots/hit-looper",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e HITLOOPER_ZENDESK_API_TOKEN -e HITLOOPER_ZENDESK_EMAIL -e HITLOOPER_ZENDESK_ORGANIZATION_SUBDOMAIN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/chat": "workspace:*",
|
||||
"@botpresshub/hitl-plugin": "workspace:*",
|
||||
"@botpresshub/zendesk": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"chat": "../../integrations/chat",
|
||||
"zendesk": "../../integrations/zendesk",
|
||||
"hitl": "../../plugins/hitl"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
# HIT Looper
|
||||
|
||||
## Description
|
||||
|
||||
This is a bot that implements the Human In The Loop (HITL) pattern. Talk to this bot as a customer in Telegram. When sending the message `/start_hitl`, the bot will connect you to an agent in Zendesk. The agent will be able to reply to you and send you back to the bot when the conversation is over.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new bot using the Botpress Dashboard or the Botpress CLI.
|
||||
2. Install the Telegram and Zendesk integrations using the command `bp add telegram && bp add zendesk`.
|
||||
3. Build the bot using command `bp build`.
|
||||
4. Deploy the bot using command `bp deploy`.
|
||||
5. In the botpress Dashboard, enable and configure the integrations Telegram and Zendesk.
|
||||
6. Talk to the bot as a customer in Telegram and ask to start a HITL session by sending the message `/start_hitl`.
|
||||
7. Login to Zendesk and reply back to yourself as an agent.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MessageHandlerProps, EventHandlerProps } from '.botpress'
|
||||
|
||||
export class Responder {
|
||||
public constructor(private _props: MessageHandlerProps | EventHandlerProps) {}
|
||||
|
||||
public static from(props: MessageHandlerProps | EventHandlerProps) {
|
||||
return new Responder(props)
|
||||
}
|
||||
|
||||
public async respond({ conversationId, text, userId }: { conversationId: string; text: string; userId?: string }) {
|
||||
await this._props.client.createMessage({
|
||||
conversationId,
|
||||
userId: this._props.ctx.botId,
|
||||
tags: {},
|
||||
type: 'text',
|
||||
payload: {
|
||||
text,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Conversation } from '@botpress/client'
|
||||
import { Responder } from './api-utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const BOT_MESSAGE = [
|
||||
'Hi, I am a bot.',
|
||||
'I cannot answer your questions.',
|
||||
'Type `/start_hitl` to talk to a human agent.',
|
||||
'Have fun :)',
|
||||
].join('\n')
|
||||
|
||||
type MessageSource = 'from_patient' | 'from_agent'
|
||||
const getMessageSource = (conversation: Conversation): MessageSource => {
|
||||
if (conversation.integration === 'zendesk') {
|
||||
return 'from_agent'
|
||||
}
|
||||
return 'from_patient'
|
||||
}
|
||||
|
||||
const bot = new bp.Bot({ actions: {} })
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
const source = getMessageSource(props.conversation)
|
||||
if (source !== 'from_agent') {
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation: downstreamConversation } = props
|
||||
await Responder.from(props).respond({
|
||||
conversationId: downstreamConversation.id,
|
||||
text: 'HITL is currently disabled.',
|
||||
})
|
||||
})
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
const source = getMessageSource(props.conversation)
|
||||
if (source !== 'from_patient') {
|
||||
return
|
||||
}
|
||||
|
||||
const { conversation: upstreamConversation, user: upstreamUser } = props
|
||||
|
||||
const _randFrom = <TValueType extends unknown>(...values: TValueType[]): TValueType =>
|
||||
values[Math.floor(Math.random() * values.length)]!
|
||||
|
||||
if (props.message.type === 'text' && props.message.payload.text.trim() === '/start_hitl') {
|
||||
await props.client.updateUser({
|
||||
id: upstreamUser.id,
|
||||
tags: {
|
||||
email: upstreamUser.tags.email ?? 'john.doe@botpress.com',
|
||||
},
|
||||
name: 'John Doe',
|
||||
pictureUrl: 'https://upload.wikimedia.org/wikipedia/en/e/e7/Steve_%28Minecraft%29.png',
|
||||
})
|
||||
|
||||
await bot.actionHandlers['hitl#startHitl']({
|
||||
...props,
|
||||
input: {
|
||||
title: `Hitl request ${Date.now()}`,
|
||||
description: 'I have a problem',
|
||||
hitlSession: { priority: _randFrom('low', 'high', 'urgent') },
|
||||
conversationId: upstreamConversation.id,
|
||||
userId: upstreamUser.id,
|
||||
},
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (props.message.type === 'text' && props.message.payload.text.trim() === '/stop_hitl') {
|
||||
await bot.actionHandlers['hitl#stopHitl']({
|
||||
...props,
|
||||
input: {
|
||||
conversationId: upstreamConversation.id,
|
||||
},
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await Responder.from(props).respond({
|
||||
conversationId: upstreamConversation.id,
|
||||
text: BOT_MESSAGE,
|
||||
})
|
||||
})
|
||||
|
||||
bot.on.event('*', async (props) => {
|
||||
const payload = props.event.payload as Record<string, unknown>
|
||||
|
||||
let conversationId: string | undefined = undefined
|
||||
if (props.event.conversationId) {
|
||||
conversationId = props.event.conversationId
|
||||
} else if ('conversationId' in payload && typeof payload.conversationId === 'string') {
|
||||
conversationId = payload.conversationId
|
||||
}
|
||||
|
||||
if (!conversationId) {
|
||||
return
|
||||
}
|
||||
|
||||
await Responder.from(props).respond({
|
||||
conversationId,
|
||||
text: BOT_MESSAGE,
|
||||
})
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as env from './.genenv'
|
||||
import knowledge from './bp_modules/knowledge'
|
||||
import openai from './bp_modules/openai'
|
||||
import personality from './bp_modules/personality'
|
||||
import telegram from './bp_modules/telegram'
|
||||
|
||||
type OpenAiModel = sdk.z.infer<typeof openai.definition.entities.modelRef.schema>
|
||||
|
||||
export default new sdk.BotDefinition({})
|
||||
.addIntegration(telegram, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
botToken: env.KNOWLEDGIANI_TELEGRAM_BOT_TOKEN,
|
||||
typingIndicatorEmoji: true,
|
||||
},
|
||||
})
|
||||
.addIntegration(openai, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
.addPlugin(personality, {
|
||||
configuration: {
|
||||
model: 'gpt-3.5-turbo-0125' satisfies OpenAiModel['id'],
|
||||
personality: 'Respond as if you were Mario the famous video game character of Nintendo',
|
||||
},
|
||||
dependencies: {
|
||||
llm: {
|
||||
integrationAlias: 'openai',
|
||||
integrationInterfaceAlias: 'llm<modelRef>',
|
||||
},
|
||||
},
|
||||
})
|
||||
.addPlugin(knowledge, {
|
||||
configuration: {},
|
||||
dependencies: {
|
||||
llm: {
|
||||
integrationAlias: 'openai',
|
||||
integrationInterfaceAlias: 'llm<modelRef>',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@bp-bots/knowledgiani",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e KNOWLEDGIANI_TELEGRAM_BOT_TOKEN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/knowledge": "workspace:*",
|
||||
"@botpresshub/openai": "workspace:*",
|
||||
"@botpresshub/personality": "workspace:*",
|
||||
"@botpresshub/telegram": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1",
|
||||
"@types/json-schema": "^7.0.12",
|
||||
"@types/qs": "^6.9.7"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"telegram": "../../integrations/telegram",
|
||||
"openai": "../../integrations/openai",
|
||||
"personality": "../../plugins/personality",
|
||||
"knowledge": "../../plugins/knowledge"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Knowledgiani
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const bot = new bp.Bot({
|
||||
actions: {},
|
||||
})
|
||||
|
||||
bot.on.message('text', async (props) => {
|
||||
console.info('Received text message:', props.message.payload.text)
|
||||
await props.client.createMessage({
|
||||
conversationId: props.message.conversationId,
|
||||
userId: props.ctx.botId,
|
||||
type: 'text',
|
||||
payload: {
|
||||
text: 'I dont know how to respond to that',
|
||||
},
|
||||
tags: {},
|
||||
})
|
||||
})
|
||||
|
||||
const fileKey = (url: string) => {
|
||||
const fileName = url.split('/').pop()
|
||||
if (!fileName) {
|
||||
return url
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
bot.on.message('file', async (props) => {
|
||||
console.info('Received file message:', props.message.payload.fileUrl)
|
||||
|
||||
const { fileUrl } = props.message.payload
|
||||
const key = fileKey(fileUrl)
|
||||
await props.client.uploadFile({
|
||||
key,
|
||||
url: fileUrl,
|
||||
index: true,
|
||||
})
|
||||
|
||||
console.info('File uploaded:', key)
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import chat from './bp_modules/chat'
|
||||
import fileSynchronizer from './bp_modules/file-synchronizer'
|
||||
import notion from './bp_modules/notion'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
configuration: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
states: {},
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
user: {},
|
||||
conversation: {},
|
||||
})
|
||||
.addIntegration(chat, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
.addIntegration(notion, {
|
||||
enabled: true,
|
||||
configurationType: 'customApp',
|
||||
configuration: { authToken: genenv.NOTION_AUTH_TOKEN },
|
||||
})
|
||||
.addPlugin(fileSynchronizer, {
|
||||
configuration: {
|
||||
enableRealTimeSync: true,
|
||||
includeFiles: [
|
||||
{
|
||||
pathGlobPattern: '**',
|
||||
},
|
||||
],
|
||||
excludeFiles: [],
|
||||
},
|
||||
dependencies: {
|
||||
'files-readonly': {
|
||||
integrationAlias: 'notion',
|
||||
integrationInterfaceAlias: 'files-readonly',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@bp-bots/notionaut",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e NOTION_AUTH_TOKEN",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/chat": "workspace:*",
|
||||
"@botpresshub/file-synchronizer": "workspace:*",
|
||||
"@botpresshub/notion": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"chat": "../../integrations/chat",
|
||||
"notion": "../../integrations/notion",
|
||||
"fileSynchronizer": "../../plugins/file-synchronizer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MessageHandlerProps, EventHandlerProps } from '.botpress'
|
||||
|
||||
export class Responder {
|
||||
public constructor(private _props: MessageHandlerProps | EventHandlerProps) {}
|
||||
|
||||
public static from(props: MessageHandlerProps | EventHandlerProps) {
|
||||
return new Responder(props)
|
||||
}
|
||||
|
||||
public async respond({ conversationId, text }: { conversationId: string; text: string }) {
|
||||
await this._props.client.createMessage({
|
||||
conversationId,
|
||||
userId: this._props.ctx.botId,
|
||||
tags: {},
|
||||
type: 'text',
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Responder } from './api-utils'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const BOT_MESSAGE = [
|
||||
'Greetings, Mission Control! This is NOTIONAUT reporting for duty.',
|
||||
"I'm your dedicated space explorer navigating the vast universe of your Notion workspace.",
|
||||
'My mission: to safely transport your files across the digital cosmos.',
|
||||
'',
|
||||
'Type `/full_sync` to initiate launch sequence and begin orbital file transfer.',
|
||||
'',
|
||||
'Be advised: full synchronization requires completing all mission checkpoints.',
|
||||
'This cosmic journey may take some time as we traverse the information galaxy.',
|
||||
'',
|
||||
'Please maintain communication silence during critical transfer operations.',
|
||||
'Prepare for synchronization countdown. T-minus 10, 9, 8... awaiting your command!',
|
||||
].join('\n')
|
||||
const bot = new bp.Bot({ actions: {} })
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
if (props.message.type === 'text' && props.message.payload.text.trim() === '/full_sync') {
|
||||
await bot.actionHandlers['file-synchronizer#syncFilesToBotpess']({ ...props, input: {} })
|
||||
return
|
||||
}
|
||||
|
||||
await Responder.from(props).respond({
|
||||
conversationId: props.conversation.id,
|
||||
text: BOT_MESSAGE,
|
||||
})
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import gsheets from './bp_modules/gsheets'
|
||||
import telegram from './bp_modules/telegram'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
conversation: {
|
||||
tags: {
|
||||
downstream: {
|
||||
title: 'Downstream Conversation ID',
|
||||
description: 'ID of the downstream conversation binded to the upstream one',
|
||||
},
|
||||
upstream: {
|
||||
title: 'Upstream Conversation ID',
|
||||
description: 'ID of the upstream conversation binded to the downstream one',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
.addIntegration(gsheets, {
|
||||
enabled: true,
|
||||
configurationType: 'serviceAccountKey',
|
||||
configuration: {
|
||||
clientEmail: genenv.SHEETZY_GSHEETS_CLIENT_EMAIL,
|
||||
privateKey: genenv.SHEETZY_GSHEETS_PRIVATE_KEY,
|
||||
spreadsheetId: genenv.SHEETZY_GSHEETS_SPREADSHEET_ID,
|
||||
},
|
||||
})
|
||||
.addIntegration(telegram, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
botToken: genenv.SHEETZY_TELEGRAM_BOT_TOKEN,
|
||||
typingIndicatorEmoji: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@bp-bots/sheetzy",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e SHEETZY_TELEGRAM_BOT_TOKEN -e SHEETZY_GSHEETS_CLIENT_EMAIL -e SHEETZY_GSHEETS_PRIVATE_KEY -e SHEETZY_GSHEETS_SPREADSHEET_ID",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/gsheets": "workspace:*",
|
||||
"@botpresshub/telegram": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"telegram": "../../integrations/telegram",
|
||||
"gsheets": "../../integrations/gsheets"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Sheetzy
|
||||
|
||||
## Description
|
||||
|
||||
This is a bot that allows you to read and write data to a Google Sheet while chatting on Telegram.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Create a new bot using the Botpress Dashboard or the Botpress CLI.
|
||||
2. Install the Telegram and GSheets integrations using the command `bp add telegram && bp add gsheets`.
|
||||
3. Build the bot using command `bp build`.
|
||||
4. Deploy the bot using command `bp deploy`.
|
||||
5. In the botpress Dashboard, enable and configure the integrations Telegram and Google Sheets.
|
||||
6. Talk to the bot in Telegram and send the message `/help`. You should receive a message with the list of available commands.
|
||||
@@ -0,0 +1,2 @@
|
||||
import * as bp from '.botpress'
|
||||
export const bot = new bp.Bot({ actions: {} })
|
||||
@@ -0,0 +1,177 @@
|
||||
import { z } from '@botpress/sdk'
|
||||
import { MessageHandlerProps } from './types'
|
||||
import { ApiUtils } from './utils'
|
||||
|
||||
const valuesSchema = z.array(z.array(z.union([z.string(), z.number()])))
|
||||
|
||||
export class CommandError extends Error {}
|
||||
export type Command = (props: MessageHandlerProps, args: string[]) => Promise<void>
|
||||
|
||||
const help: Command = async (props) => {
|
||||
const utils = new ApiUtils(props)
|
||||
const commandNames = Object.keys(commands)
|
||||
await utils.respond(`Available commands\n${commandNames.map((name) => `- ${name}`).join('\n')}`)
|
||||
}
|
||||
|
||||
const info: Command = async (props) => {
|
||||
const utils = new ApiUtils(props)
|
||||
const { output } = await props.client.callAction({
|
||||
type: 'gsheets:getInfoSpreadsheet',
|
||||
input: {
|
||||
fields: [
|
||||
'sheets.properties.sheetId',
|
||||
'sheets.properties.title',
|
||||
'sheets.properties.gridProperties.rowCount',
|
||||
'sheets.properties.gridProperties.columnCount',
|
||||
],
|
||||
},
|
||||
})
|
||||
await utils.respond(JSON.stringify(output, null, 2))
|
||||
}
|
||||
|
||||
const get: Command = async (props, args) => {
|
||||
const utils = new ApiUtils(props)
|
||||
|
||||
const [range] = args
|
||||
if (!range) {
|
||||
throw new CommandError('Missing range')
|
||||
}
|
||||
|
||||
const {
|
||||
output: { values },
|
||||
} = await props.client.callAction({
|
||||
type: 'gsheets:getValues',
|
||||
input: {
|
||||
range,
|
||||
},
|
||||
})
|
||||
|
||||
await utils.respond(JSON.stringify(values, null, 2))
|
||||
}
|
||||
|
||||
const set: Command = async (props, args) => {
|
||||
const utils = new ApiUtils(props)
|
||||
|
||||
const [range, ...body] = args
|
||||
const jsonValues = body.join(' ')
|
||||
|
||||
if (!range) {
|
||||
throw new CommandError('Missing range')
|
||||
}
|
||||
|
||||
const parseResult = valuesSchema.safeParse(JSON.parse(jsonValues))
|
||||
if (!parseResult.success) {
|
||||
throw new CommandError('Invalid values')
|
||||
}
|
||||
|
||||
const values = parseResult.data
|
||||
|
||||
await props.client.callAction({
|
||||
type: 'gsheets:setValues',
|
||||
input: {
|
||||
range,
|
||||
values: _stringifyValues(values),
|
||||
},
|
||||
})
|
||||
|
||||
await utils.respond('Done')
|
||||
}
|
||||
|
||||
const append: Command = async (props, args) => {
|
||||
const utils = new ApiUtils(props)
|
||||
|
||||
const [firstArg, secondArg, ...body] = args
|
||||
const jsonValues = body.join(' ')
|
||||
|
||||
if (!firstArg) {
|
||||
throw new CommandError('Missing startColumn')
|
||||
}
|
||||
|
||||
let sheetName: string | undefined
|
||||
let startColumn: string
|
||||
|
||||
if (secondArg && !secondArg.startsWith('[') && !secondArg.startsWith('{')) {
|
||||
sheetName = firstArg
|
||||
startColumn = secondArg
|
||||
} else {
|
||||
startColumn = firstArg
|
||||
if (firstArg.includes('!')) {
|
||||
const parts = firstArg.split('!')
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
sheetName = parts[0]
|
||||
startColumn = parts[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!startColumn) {
|
||||
throw new CommandError('Missing startColumn')
|
||||
}
|
||||
|
||||
const parseResult = valuesSchema.safeParse(JSON.parse(jsonValues))
|
||||
if (!parseResult.success) {
|
||||
throw new CommandError('Invalid values')
|
||||
}
|
||||
|
||||
const values = parseResult.data
|
||||
|
||||
await props.client.callAction({
|
||||
type: 'gsheets:appendValues',
|
||||
input: {
|
||||
sheetName,
|
||||
startColumn,
|
||||
values: _stringifyValues(values),
|
||||
},
|
||||
})
|
||||
|
||||
await utils.respond('Done')
|
||||
}
|
||||
|
||||
const clear: Command = async (props, args) => {
|
||||
const utils = new ApiUtils(props)
|
||||
const [range] = args
|
||||
if (!range) {
|
||||
throw new CommandError('Missing range')
|
||||
}
|
||||
|
||||
await props.client.callAction({
|
||||
type: 'gsheets:clearValues',
|
||||
input: {
|
||||
range,
|
||||
},
|
||||
})
|
||||
|
||||
await utils.respond('Done')
|
||||
}
|
||||
|
||||
const addSheet: Command = async (props, args) => {
|
||||
const utils = new ApiUtils(props)
|
||||
|
||||
const [title] = args
|
||||
|
||||
if (!title) {
|
||||
throw new CommandError('Missing title')
|
||||
}
|
||||
|
||||
await props.client.callAction({
|
||||
type: 'gsheets:addSheet',
|
||||
input: {
|
||||
title,
|
||||
},
|
||||
})
|
||||
|
||||
await utils.respond('Done')
|
||||
}
|
||||
|
||||
const _stringifyValues = (values: any[][]): string[][] =>
|
||||
values.map((majorDimension) => majorDimension.map((cell) => cell.toString()))
|
||||
|
||||
export const commands = {
|
||||
'/help': help,
|
||||
'/info': info,
|
||||
'/get': get,
|
||||
'/set': set,
|
||||
'/append': append,
|
||||
'/clear': clear,
|
||||
'/addSheet': addSheet,
|
||||
} satisfies Record<`/${string}`, Command>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { bot } from './bot'
|
||||
import { CommandError, commands } from './commands'
|
||||
import { ApiUtils } from './utils'
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
const utils = new ApiUtils(props)
|
||||
|
||||
if (props.message.type !== 'text') {
|
||||
await utils.respond('I only understand text messages')
|
||||
return
|
||||
}
|
||||
|
||||
const text = props.message.payload.text as string
|
||||
const [command, ...args] = text.split(' ')
|
||||
if (!command) {
|
||||
await utils.respond('Please provide a command')
|
||||
await commands['/help'](props, [])
|
||||
return
|
||||
}
|
||||
|
||||
const commandHandler = commands[command as keyof typeof commands]
|
||||
if (!commandHandler) {
|
||||
await utils.respond('Unknown command')
|
||||
await commands['/help'](props, [])
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await commandHandler(props, args)
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
await utils.respond(error.message)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1 @@
|
||||
export * from '.botpress'
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MessageHandlerProps } from './types'
|
||||
|
||||
export class ApiUtils {
|
||||
public constructor(private readonly _props: MessageHandlerProps) {}
|
||||
public readonly respond = async (text: string) => {
|
||||
await this._props.client.createMessage({
|
||||
type: 'text',
|
||||
conversationId: this._props.message.conversationId,
|
||||
userId: this._props.ctx.botId,
|
||||
tags: {},
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import chat from './bp_modules/chat'
|
||||
import linear from './bp_modules/linear'
|
||||
import synchronizer from './bp_modules/synchronizer'
|
||||
|
||||
export default new sdk.BotDefinition({})
|
||||
.addIntegration(linear, {
|
||||
enabled: true,
|
||||
configurationType: 'apiKey',
|
||||
configuration: {
|
||||
apiKey: genenv.SINLIN_LINEAR_API_KEY,
|
||||
webhookSigningSecret: genenv.SINLIN_LINEAR_WEBHOOK_SIGNING_SECRET,
|
||||
},
|
||||
})
|
||||
.addIntegration(chat, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
.addPlugin(synchronizer, {
|
||||
alias: 'linear',
|
||||
configuration: {
|
||||
tableName: 'linearIssuesTable',
|
||||
},
|
||||
dependencies: {
|
||||
listable: {
|
||||
integrationAlias: 'linear',
|
||||
integrationInterfaceAlias: 'listable<issue>',
|
||||
},
|
||||
deletable: {
|
||||
integrationAlias: 'linear',
|
||||
integrationInterfaceAlias: 'deletable<issue>',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@bp-bots/sinlin",
|
||||
"description": "A bot that syncs issues from Linear to Botpress tables",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -e SINLIN_LINEAR_API_KEY -e SINLIN_LINEAR_WEBHOOK_SIGNING_SECRET",
|
||||
"check:type": "tsc --noEmit",
|
||||
"check:bplint": "bp lint",
|
||||
"build": "bp add -y && bp build"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/chat": "workspace:*",
|
||||
"@botpresshub/linear": "workspace:*",
|
||||
"@botpresshub/synchronizer": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1",
|
||||
"@types/lodash": "^4.14.191"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"chat": "../../integrations/chat",
|
||||
"linear": "../../integrations/linear",
|
||||
"synchronizer": "../../plugins/synchronizer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Client } from '@botpress/client'
|
||||
import * as bp from '.botpress'
|
||||
|
||||
const { tableName } = bp.linear.configuration
|
||||
|
||||
const getVanillaClient = (props: bp.EventHandlerProps | bp.MessageHandlerProps): Client => props.client._inner
|
||||
|
||||
const summarize = (str: string, maxLength: number = 1000): string =>
|
||||
str.length > maxLength ? str.substring(0, maxLength) + '...' : str
|
||||
|
||||
const reply = async (props: bp.MessageHandlerProps, text: string) => {
|
||||
await props.client.createMessage({
|
||||
type: 'text',
|
||||
payload: {
|
||||
text,
|
||||
},
|
||||
conversationId: props.message.conversationId,
|
||||
userId: props.ctx.botId,
|
||||
tags: {},
|
||||
})
|
||||
}
|
||||
|
||||
const bot = new bp.Bot({ actions: {} })
|
||||
|
||||
type Command = {
|
||||
description: string
|
||||
handler: bp.MessageHandlers['*']
|
||||
}
|
||||
const commands: Record<string, Command> = {
|
||||
'/sync': {
|
||||
description: 'Sync issues',
|
||||
handler: async (props: bp.MessageHandlerProps) => {
|
||||
await bot.actionHandlers['linear#clear']({ ...props, input: {} })
|
||||
await reply(props, 'Issues synced')
|
||||
},
|
||||
},
|
||||
'/list': {
|
||||
description: 'List issues',
|
||||
handler: async (props: bp.MessageHandlerProps) => {
|
||||
const tableState = await props.client
|
||||
.getOrSetState({ type: 'bot', id: props.ctx.botId, name: 'linear#table', payload: { tableCreated: false } })
|
||||
.then((r) => r.state.payload)
|
||||
|
||||
if (!tableState.tableCreated) {
|
||||
await reply(props, 'Table does not exist')
|
||||
return
|
||||
}
|
||||
|
||||
const { rows } = await getVanillaClient(props).findTableRows({
|
||||
table: tableName,
|
||||
filter: {},
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
const issues: string[] = rows
|
||||
.map(({ computed: _computed, stale: _stale, similarity: _similarity, ...r }) =>
|
||||
Object.entries(r)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join('; ')
|
||||
)
|
||||
.map((r) => `- ${r}`)
|
||||
|
||||
const response = issues.join('\n\n') || 'No issues found'
|
||||
await reply(props, summarize(response))
|
||||
},
|
||||
},
|
||||
'/clear': {
|
||||
description: 'Clear issues',
|
||||
handler: async (props: bp.MessageHandlerProps) => {
|
||||
await bot.actionHandlers['linear#clear']({ ...props, input: {} })
|
||||
await reply(props, 'Table cleared')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bot.on.message('*', async (props) => {
|
||||
const { message } = props
|
||||
|
||||
if (message.type !== 'text') {
|
||||
await reply(props, 'I only understand text messages')
|
||||
return
|
||||
}
|
||||
|
||||
const query = message.payload.text.trim()
|
||||
const command = commands[query]
|
||||
if (command) {
|
||||
const now = Date.now()
|
||||
props.logger.info(`[${now}:START] command "${query}"`)
|
||||
await command.handler(props)
|
||||
props.logger.info(`[${now}:STOP] command "${query}"`)
|
||||
return
|
||||
}
|
||||
|
||||
const helpMessage = Object.entries(commands)
|
||||
.map(([cmd, { description }]) => `${cmd}: ${description}`)
|
||||
.join('\n')
|
||||
await reply(props, 'Please use one of the following commands:\n' + helpMessage)
|
||||
})
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import gmail from './bp_modules/gmail'
|
||||
import slack from './bp_modules/slack'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
states: {
|
||||
slackConversationId: {
|
||||
type: 'bot',
|
||||
schema: sdk.z.object({
|
||||
conversationId: sdk.z
|
||||
.string()
|
||||
.title('Slack Conversation ID')
|
||||
.describe('The ID of the Slack conversation to send email notifications to')
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
.addIntegration(gmail, {
|
||||
enabled: true,
|
||||
configurationType: 'customApp',
|
||||
configuration: {
|
||||
oauthClientId: genenv.SLACKBOX_GMAIL_OAUTH_CLIENT_ID,
|
||||
oauthClientSecret: genenv.SLACKBOX_GMAIL_OAUTH_CLIENT_SECRET,
|
||||
oauthAuthorizationCode: genenv.SLACKBOX_GMAIL_OAUTH_AUTHORIZATION_CODE,
|
||||
pubsubTopicName: genenv.SLACKBOX_GMAIL_PUBSUB_TOPIC_NAME,
|
||||
pubsubWebhookSharedSecret: genenv.SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SHARED_SECRET,
|
||||
pubsubWebhookServiceAccount: genenv.SLACKBOX_GMAIL_PUBSUB_WEBHOOK_SERVICE_ACCOUNT,
|
||||
},
|
||||
})
|
||||
.addIntegration(slack, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
typingIndicatorEmoji: false,
|
||||
replyBehaviour: {
|
||||
location: 'channel',
|
||||
onlyOnBotMention: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import rootConfig from '../../eslint.config.mjs'
|
||||
|
||||
export default [
|
||||
...rootConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@bp-bots/slackbox",
|
||||
"scripts": {
|
||||
"postinstall": "genenv -o ./.genenv/index.ts -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"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@botpress/client": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@botpress/cli": "workspace:*",
|
||||
"@botpress/common": "workspace:*",
|
||||
"@botpress/sdk": "workspace:*",
|
||||
"@botpresshub/gmail": "workspace:*",
|
||||
"@botpresshub/slack": "workspace:*",
|
||||
"@bpinternal/genenv": "0.0.1"
|
||||
},
|
||||
"bpDependencies": {
|
||||
"slack": "../../integrations/slack",
|
||||
"gmail": "../../integrations/gmail"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Conversation } from '@botpress/client'
|
||||
import { AnyIncomingMessage } from '@botpress/sdk/dist/bot'
|
||||
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
|
||||
|
||||
const cachedSlackConversationIds: Record<string, string> = {}
|
||||
|
||||
const bot = new bp.Bot({
|
||||
actions: {},
|
||||
})
|
||||
|
||||
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 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({
|
||||
type: 'text',
|
||||
conversationId: slackConversationId,
|
||||
tags: {},
|
||||
userId: ctx.botId,
|
||||
payload: {
|
||||
text: notificationMessage,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to send email notification: ${error}`)
|
||||
}
|
||||
})
|
||||
|
||||
const _shouldForwardEmail = async (
|
||||
client: bp.Client,
|
||||
conversation: Conversation,
|
||||
logger: bp.MessageHandlerProps['logger']
|
||||
): Promise<boolean> => {
|
||||
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<string>()
|
||||
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<string, { type?: string; name?: string }>()
|
||||
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 _resolveSlackChannelId = async (client: bp.Client, channelName: string): Promise<string | undefined> => {
|
||||
const { output } = await client.callAction({
|
||||
type: 'slack:findTarget',
|
||||
input: { query: channelName, channel: 'channel' },
|
||||
})
|
||||
const exact = output.targets.find((t) => t.displayName === channelName)
|
||||
return (exact ?? output.targets[0])?.tags.id
|
||||
}
|
||||
|
||||
const _getSlackConversationId = async (
|
||||
client: bp.Client,
|
||||
logger: bp.MessageHandlerProps['logger'],
|
||||
channelName: string
|
||||
): Promise<string> => {
|
||||
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 channelId = await _resolveSlackChannelId(client, channelName)
|
||||
if (!channelId) {
|
||||
throw new Error(`Could not find Slack channel '${channelName}'`)
|
||||
}
|
||||
const response = await client.callAction({
|
||||
type: 'slack:getOrCreateChannelConversation',
|
||||
input: {
|
||||
conversation: {
|
||||
channelId,
|
||||
},
|
||||
},
|
||||
})
|
||||
cachedSlackConversationIds[channelName] = response.output.conversationId
|
||||
return cachedSlackConversationIds[channelName] as string
|
||||
} 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<bp.TBot>) => {
|
||||
const subject = (conversation.tags['gmail:subject'] || conversation.tags.subject) as string | undefined
|
||||
const fromEmail = (conversation.tags['gmail:email'] || conversation.tags.email) as string | undefined
|
||||
const messageText =
|
||||
message.type === 'text' ? (message.payload as { text?: string }).text || 'No content' : 'New email received'
|
||||
|
||||
const preview = messageText.length > 200 ? messageText.substring(0, 200) + '...' : messageText
|
||||
const notificationMessage =
|
||||
'📦 *New email received!*\n\n' +
|
||||
`*Subject:* ${subject || '(No subject)'}\n` +
|
||||
`*From:* ${fromEmail || 'Unknown'}\n` +
|
||||
`*Body:*\n${preview || 'Preview unavailable'}`
|
||||
|
||||
return notificationMessage
|
||||
}
|
||||
|
||||
export default bot
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": { "*": ["./*"] },
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [".botpress/**/*", "src/**/*", "*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as sdk from '@botpress/sdk'
|
||||
import * as genenv from './.genenv'
|
||||
import chat from './bp_modules/chat'
|
||||
import dropbox from './bp_modules/dropbox'
|
||||
import fileSynchronizer from './bp_modules/file-synchronizer'
|
||||
|
||||
export default new sdk.BotDefinition({
|
||||
configuration: {
|
||||
schema: sdk.z.object({}),
|
||||
},
|
||||
states: {},
|
||||
events: {},
|
||||
recurringEvents: {},
|
||||
user: {},
|
||||
conversation: {},
|
||||
})
|
||||
.addIntegration(chat, {
|
||||
enabled: true,
|
||||
configuration: {},
|
||||
})
|
||||
.addIntegration(dropbox, {
|
||||
enabled: true,
|
||||
configuration: {
|
||||
clientId: genenv.FILESYNC_DROPBOX_CLIENT_ID,
|
||||
clientSecret: genenv.FILESYNC_DROPBOX_CLIENT_SECRET,
|
||||
authorizationCode: genenv.FILESYNC_DROPBOX_AUTHORIZATION_CODE,
|
||||
},
|
||||
})
|
||||
.addPlugin(fileSynchronizer, {
|
||||
configuration: {
|
||||
enableRealTimeSync: true,
|
||||
includeFiles: [
|
||||
{
|
||||
pathGlobPattern: '**',
|
||||
},
|
||||
],
|
||||
excludeFiles: [],
|
||||
},
|
||||
dependencies: {
|
||||
'files-readonly': {
|
||||
integrationAlias: 'dropbox',
|
||||
integrationInterfaceAlias: 'files-readonly',
|
||||
},
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user