Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion integrations/linear/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { actions, channels, events, configuration, configurations, user, states,

export default new IntegrationDefinition({
name: 'linear',
version: '1.1.1',
version: '1.1.2',
title: 'Linear',
description:
'Manage your projects autonomously. Have your bot participate in discussions, manage issues and teams, and track progress.',
Expand Down
8 changes: 7 additions & 1 deletion integrations/linear/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ export const handler: bp.IntegrationProps['handler'] = async ({ req, ctx, client
return
}

if (linearEvent.type === 'comment' && linearEvent.action === 'create') {
if (
linearEvent.type === 'comment' &&
linearEvent.action === 'create' &&
// Comment can be added in projects which are not issues. Therefore they don't have issueId or
// issue.id. Comments in projects are currently ignored.
(linearEvent.data.issue || linearEvent.data.issueId)
) {
const linearCommentId = linearEvent.data.id
const issueConversationId = linearEvent.data.issueId || linearEvent.data.issue.id
const content = linearEvent.data.body
Expand Down
2 changes: 1 addition & 1 deletion integrations/whatsapp/integration.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const defaultBotPhoneNumberId = {

export default new IntegrationDefinition({
name: INTEGRATION_NAME,
version: '4.2.5',
version: '4.2.6',
title: 'WhatsApp',
description: 'Send and receive messages through WhatsApp.',
icon: 'icon.svg',
Expand Down
39 changes: 38 additions & 1 deletion integrations/whatsapp/src/channels/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getAuthenticatedWhatsappClient } from '../auth'
import { WHATSAPP } from '../misc/constants'
import { convertMarkdownToWhatsApp } from '../misc/markdown-to-whatsapp-rtf'
import { sleep } from '../misc/util'
import { repeat } from '../repeat'
import * as card from './message-types/card'
import * as carousel from './message-types/carousel'
import * as choice from './message-types/choice'
Expand Down Expand Up @@ -128,6 +129,24 @@ type SendMessageProps = {
message?: OutgoingMessage
}

// From https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes#throttling-errors
// Only contains codes for errors that can be recovered from by waiting
const THROTTLING_CODES = new Set([
80007, // WABA/app rate limit
130429, // Cloud API throughput reached
131056, // Pair rate limit (same sender↔recipient)
])

function backoffDelayMs(attempt: number) {
// Helper function for backoff delay with jitter. Uses Meta recommendation for exponential curve
// https://developers.facebook.com/docs/whatsapp/cloud-api/overview/?locale=en_US#pair-rate-limits
const baseMs = Math.pow(4, attempt) * 1000
const jitter = 0.75 + Math.random() * 0.5
return Math.floor(baseMs * jitter)
}

const MAX_ATTEMPT = 3

async function _send({ client, ctx, conversation, message, ack, logger }: SendMessageProps) {
if (!message) {
logger.forBot().debug('No message to send')
Expand All @@ -153,7 +172,25 @@ async function _send({ client, ctx, conversation, message, ack, logger }: SendMe
return
}

const feedback = await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, message)
const feedback = await repeat(
async (i) => {
if (i > 0) {
logger.forBot().info(`Retrying to send ${messageType} message to WhatsApp (attempt ${i + 1}/${MAX_ATTEMPT})...`)
}

const result = await whatsapp.sendMessage(botPhoneNumberId, userPhoneNumber, message)
const repeat = 'error' in result && THROTTLING_CODES.has(result.error?.code ?? 0)
return {
repeat,
result,
}
},
{
maxIterations: MAX_ATTEMPT,
backoff: backoffDelayMs,
}
)

if ('error' in feedback) {
logger
.forBot()
Expand Down
68 changes: 68 additions & 0 deletions integrations/whatsapp/src/repeat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test, expect, vi } from 'vitest'
import { repeat } from './repeat'

test('repeat calls the function the specified number of times', async () => {
const callback = vi
.fn()
.mockResolvedValueOnce({ repeat: true, result: 1 })
.mockResolvedValueOnce({ repeat: true, result: 2 })
.mockResolvedValueOnce({ repeat: false, result: 3 })

const backoff = vi.fn().mockReturnValue(0)

const result = await repeat<number>(callback, { maxIterations: 5, backoff })

expect(result).toBe(3)
expect(callback).toHaveBeenCalledTimes(3)
expect(backoff).toHaveBeenCalledTimes(2)
expect(backoff).toHaveBeenNthCalledWith(1, 1, 1)
expect(backoff).toHaveBeenNthCalledWith(2, 2, 2)
})

test('repeat stops after maxIterations', async () => {
const callback = vi
.fn()
.mockResolvedValueOnce({ repeat: true, result: 1 })
.mockResolvedValueOnce({ repeat: true, result: 2 })
.mockResolvedValueOnce({ repeat: false, result: 3 })

const backoff = vi.fn().mockReturnValue(0)

const result = await repeat<number>(callback, { maxIterations: 2, backoff })

expect(result).toBe(2)
expect(callback).toHaveBeenCalledTimes(2)
expect(backoff).toHaveBeenCalledTimes(1)
expect(backoff).toHaveBeenNthCalledWith(1, 1, 1)
})

test('repeat uses the backoff function to determine delay', async () => {
const timestamps: number[] = []

let t0 = Date.now()
let iteration = 0
let cb = async () => {
timestamps.push(Date.now() - t0)
t0 = Date.now()

iteration++
if (iteration < 3) {
return { repeat: true, result: 'failure' }
}
return { repeat: false, result: 'success' }
}

const backoff = (iteration: number) => iteration * 100

const result = await repeat<string>(cb, { maxIterations: 5, backoff })

expect(result).toBe('success')
expect(timestamps.length).toBe(3)
expect(timestamps[0]).toBeLessThan(50) // First call, no delay

expect(timestamps[1]).toBeGreaterThanOrEqual(100)
expect(timestamps[1]).toBeLessThan(150)

expect(timestamps[2]).toBeGreaterThanOrEqual(200)
expect(timestamps[2]).toBeLessThan(250)
})
36 changes: 36 additions & 0 deletions integrations/whatsapp/src/repeat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { sleep } from './misc/util'

export type RepeatResult<T> = {
repeat: boolean
result: T
}

export type RepeatCallback<T> = (iteration: number) => Promise<RepeatResult<T>>
export type BackoffCallback<T> = (iteration: number, result: T) => number

export type RepeatOptions<T> = {
maxIterations: number
backoff: BackoffCallback<T>
}

/**
* Like a retry function, but with a slightly different semantics.
* The callback function is repeated based on its return value, not on whether it throws or not.
*/
export const repeat = async <T>(callback: RepeatCallback<T>, options: RepeatOptions<T>): Promise<T> => {
let iteration = 0
for (;;) {
const res = await callback(iteration)
if (!res.repeat) {
return res.result
}

iteration++
if (iteration >= options.maxIterations) {
return res.result
}

const delay = options.backoff(iteration, res.result)
await sleep(delay)
}
}
Loading