chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:34:48 +08:00
commit 77bb5bf71f
3762 changed files with 353249 additions and 0 deletions
+149
View File
@@ -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

+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+30
View File
@@ -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"
}
}
+20
View File
@@ -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.
+32
View File
@@ -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'))
}
+7
View File
@@ -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'))
}
+152
View File
@@ -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) => `&lt;${arg}&gt;`).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"))
}
+17
View File
@@ -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,
},
})
}
}
+59
View File
@@ -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)
}
}
+2
View File
@@ -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'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+41
View File
@@ -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,
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+22
View File
@@ -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"
}
}
+90
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+26
View File
@@ -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 },
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+24
View File
@@ -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"
}
}
+24
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+56
View File
@@ -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',
},
},
})
+25
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
import * as bp from '.botpress'
const bot = new bp.Bot({
actions: {},
})
export default bot
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+9
View File
@@ -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: {} })
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+19
View File
@@ -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"
}
}
+33
View File
@@ -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,
})
}
}
+5
View File
@@ -0,0 +1,5 @@
import * as bp from '.botpress'
export const bot = new bp.Bot({
actions: {},
})
+212
View File
@@ -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
+34
View File
@@ -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 |
`
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+1
View File
@@ -0,0 +1 @@
.botpress
+30
View File
@@ -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: {},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+29
View File
@@ -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"
}
}
+26
View File
@@ -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
```
+30
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+47
View File
@@ -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>',
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+28
View File
@@ -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"
}
}
+15
View File
@@ -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.
+22
View File
@@ -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,
},
})
}
}
+107
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+42
View File
@@ -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>',
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+33
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
# Knowledgiani
+42
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+42
View File
@@ -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',
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+28
View File
@@ -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"
}
}
+21
View File
@@ -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,
},
})
}
}
+31
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+37
View File
@@ -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,
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+26
View File
@@ -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"
}
}
+14
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
import * as bp from '.botpress'
export const bot = new bp.Bot({ actions: {} })
+177
View File
@@ -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>
+39
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export * from '.botpress'
+16
View File
@@ -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,
},
})
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+35
View File
@@ -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>',
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+30
View File
@@ -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"
}
}
+100
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+41
View File
@@ -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,
},
},
})
+13
View File
@@ -0,0 +1,13 @@
import rootConfig from '../../eslint.config.mjs'
export default [
...rootConfig,
{
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
]
+26
View File
@@ -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"
}
}
+186
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": { "*": ["./*"] },
"outDir": "dist"
},
"include": [".botpress/**/*", "src/**/*", "*.ts"]
}
+45
View File
@@ -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