Skip to content
Open
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
14 changes: 14 additions & 0 deletions .hintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"extends": [
"development"
],
"hints": {
"axe/structure": [
"default",
{
"list": "off",
"listitem": "off"
}
]
}
}
1 change: 1 addition & 0 deletions lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"packages/database-mysql",
"packages/database-json",
"packages/database-postgres",
"packages/database-redis",
"packages/eslint-plugin-builderbot",
"packages/manager",
"packages/provider-meta",
Expand Down
21 changes: 21 additions & 0 deletions packages/bot/__tests__/e2e/0.0.1-flow-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,25 @@ testSuite(`Responder a "EVENTS.VOICE_NOTE"`, async (context) => {
assert.is(history[1], undefined)
})

testSuite(`Responder a "EVENTS.CONTACTS"`, async (context) => {
const { database, provider } = context
const contactFlow = addKeyword(EVENTS.CONTACTS).addAnswer('gracias por el contacto!')

await createBot({
database,
provider,
flow: createFlow([contactFlow]),
})

await provider.delaySendMessage(0, 'message', {
from: '000',
body: '_event_contacts__f405d946-cf07-uutt-l7e0-b6d475bc7f81',
})

await delay(200)
const history = database.listHistory.map((item) => item.answer)
assert.is(history[0], 'gracias por el contacto!')
assert.is(history[1], undefined)
})

testSuite.run()
18 changes: 11 additions & 7 deletions packages/bot/__tests__/units/coreClass.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import RedisMock from 'ioredis-mock'
import * as sinon from 'sinon'
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import * as sinon from 'sinon'

import { CoreClass } from '../../src/core/coreClass'
import FlowClass from '../../src/io/flowClass'
Expand Down Expand Up @@ -32,6 +33,12 @@ const createMockDeps = (flows = [addKeyword('hello').addAnswer('Hi there!')]) =>
globalState: {},
extensions: undefined,
queue: { timeout: 20000, concurrencyLimit: 15 },
RedisOptions: {
connection: new RedisMock(),
options: {
prefix: 'test',
},
},
}

return { flowClass, database, provider, args }
Expand Down Expand Up @@ -267,10 +274,7 @@ test('[CoreClass] sendProviderAndSave should skip internal answers', async () =>
from: 'user123',
refSerialize: 'ser1',
})
assert.not.ok(
provider.sendMessage.called,
`Provider sendMessage should NOT be called for "${answer}"`
)
assert.not.ok(provider.sendMessage.called, `Provider sendMessage should NOT be called for "${answer}"`)
}
})

Expand Down Expand Up @@ -456,8 +460,8 @@ test('[CoreClass] stateHandler should be isolated per user', async () => {
await core.stateHandler.updateState({ from: 'user1' })({ name: 'Alice' })
await core.stateHandler.updateState({ from: 'user2' })({ name: 'Bob' })

const state1 = core.stateHandler.getMyState('user1')()
const state2 = core.stateHandler.getMyState('user2')()
const state1 = await core.stateHandler.getMyState('user1')()
const state2 = await core.stateHandler.getMyState('user2')()

assert.equal(state1.name, 'Alice')
assert.equal(state2.name, 'Bob')
Expand Down
43 changes: 33 additions & 10 deletions packages/bot/__tests__/units/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { test } from 'uvu'
import * as assert from 'uvu/assert'

import { eventMedia, REGEX_EVENT_MEDIA } from '../../src/io/events/eventMedia'
import { eventLocation, REGEX_EVENT_LOCATION } from '../../src/io/events/eventLocation'
import { eventAction } from '../../src/io/events/eventAction'
import { eventCall, REGEX_EVENT_CALL } from '../../src/io/events/eventCall'
import { eventContacts, REGEX_EVENT_CONTACTS } from '../../src/io/events/eventContacts'
import { eventCustom, REGEX_EVENT_CUSTOM } from '../../src/io/events/eventCustom'
import { eventDocument, REGEX_EVENT_DOCUMENT } from '../../src/io/events/eventDocument'
import { eventVoiceNote, REGEX_EVENT_VOICE_NOTE } from '../../src/io/events/eventVoiceNote'
import { eventLocation, REGEX_EVENT_LOCATION } from '../../src/io/events/eventLocation'
import { eventMedia, REGEX_EVENT_MEDIA } from '../../src/io/events/eventMedia'
import { eventOrder, REGEX_EVENT_ORDER } from '../../src/io/events/eventOrder'
import { eventTemplate, REGEX_EVENT_TEMPLATE } from '../../src/io/events/eventTemplate'
import { eventCall, REGEX_EVENT_CALL } from '../../src/io/events/eventCall'
import { eventAction } from '../../src/io/events/eventAction'
import { eventVoiceNote, REGEX_EVENT_VOICE_NOTE } from '../../src/io/events/eventVoiceNote'
import { eventWelcome } from '../../src/io/events/eventWelcome'
import { eventCustom, REGEX_EVENT_CUSTOM } from '../../src/io/events/eventCustom'
import { LIST_ALL, LIST_REGEX } from '../../src/io/events/index'

// ===== eventMedia =====
Expand Down Expand Up @@ -38,6 +39,31 @@ test('[REGEX_EVENT_MEDIA] should not match arbitrary strings', () => {
assert.not.ok(REGEX_EVENT_MEDIA.test('_event_location__abc'))
})

// ===== eventContacts =====

test('[eventContacts] should return a string with correct prefix', () => {
const ref = eventContacts()
assert.type(ref, 'string')
assert.ok(ref.startsWith('_event_contacts__'), `Expected prefix _event_contacts__, got: ${ref}`)
})

test('[eventContacts] should return unique values on each call', () => {
const ref1 = eventContacts()
const ref2 = eventContacts()
assert.is.not(ref1, ref2, 'Each call should return a unique ref')
})

test('[REGEX_EVENT_CONTACTS] should match valid media event refs', () => {
const ref = eventContacts()
assert.ok(REGEX_EVENT_CONTACTS.test(ref), `Regex should match generated ref: ${ref}`)
})

test('[REGEX_EVENT_CONTACTS] should not match arbitrary strings', () => {
assert.not.ok(REGEX_EVENT_CONTACTS.test('hello'))
assert.not.ok(REGEX_EVENT_CONTACTS.test('_event_media_'))
assert.not.ok(REGEX_EVENT_CONTACTS.test('_event_location__abc'))
})

// ===== eventLocation =====

test('[eventLocation] should return a string with correct prefix', () => {
Expand Down Expand Up @@ -261,10 +287,7 @@ test('[Cross-event] each regex should only match its own event type', () => {
// Should NOT match other regexes
for (const other of eventPairs) {
if (other.name !== pair.name) {
assert.not.ok(
other.regex.test(ref),
`${pair.name} ref should NOT match ${other.name} regex`
)
assert.not.ok(other.regex.test(ref), `${pair.name} ref should NOT match ${other.name} regex`)
}
}
}
Expand Down
74 changes: 74 additions & 0 deletions packages/bot/__tests__/units/redisState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import RedisMock from 'ioredis-mock'
import { test } from 'uvu'
import * as assert from 'uvu/assert'

import { RedisState } from '../../src/context/redisStateClass'

const redisClient = new RedisMock()
const prefix = 'test-bot'
const stateManager = new RedisState(redisClient as any, { prefix })

test.before.each(async () => {
await redisClient.flushall()
})

test('RedisState - debe instanciarse', () => {
assert.instance(stateManager, RedisState)
})

test('RedisState - updateState debe hacer merge de valores', async () => {
const ctx = { from: 'user_1' }
const update = stateManager.updateState(ctx)
const getMyState = stateManager.getMyState(ctx.from)

await update({ name: 'Leifer' })
let state = await getMyState()
assert.equal(state, { name: 'Leifer' })

await update({ age: 30 })
state = await getMyState()
assert.equal(state, { name: 'Leifer', age: 30 })
})

test('RedisState - get debe obtener propiedades anidadas (dot notation)', async () => {
const ctx = { from: 'user_2' }
const update = stateManager.updateState(ctx)
const getProp = stateManager.get(ctx.from)

await update({
profile: {
skills: ['js', 'ts'],
meta: { id: 100 },
},
})

assert.equal(await getProp('profile.skills'), ['js', 'ts'])
assert.is(await getProp('profile.meta.id'), 100)
assert.is(await getProp('profile.nonexistent'), undefined)
})

test('RedisState - clear debe borrar solo el estado del usuario', async () => {
const userA = { from: 'A' }
const userB = { from: 'B' }

await stateManager.updateState(userA)({ data: 'A' })
await stateManager.updateState(userB)({ data: 'B' })

const clearA = stateManager.clear(userA.from)
await clearA()

assert.is(await stateManager.getMyState(userA.from)(), undefined)
assert.not.equal(await stateManager.getMyState(userB.from)(), undefined)
})

test('RedisState - clearAll debe limpiar todas las llaves del bot', async () => {
await stateManager.updateState({ from: 'user1' })({ v: 1 })
await stateManager.updateState({ from: 'user2' })({ v: 2 })

await stateManager.clearAll()

const keys = await redisClient.keys('bot_state*')
assert.is(keys.length, 0)
})

test.run()
3 changes: 3 additions & 0 deletions packages/bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@types/cors": "^2.8.19",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/follow-redirects": "^1.14.4",
"@types/ioredis-mock": "^8.2.6",
"@types/mime-types": "^2.1.4",
"@types/node": "^24.10.2",
"@types/polka": "^0.5.8",
Expand All @@ -55,6 +56,8 @@
"cors": "^2.8.5",
"fluent-ffmpeg": "^2.1.3",
"follow-redirects": "^1.15.11",
"ioredis": "^5.9.3",
"ioredis-mock": "^8.13.1",
"mime-types": "^3.0.2",
"picocolors": "^1.1.1",
"polka": "^0.5.2"
Expand Down
1 change: 1 addition & 0 deletions packages/bot/src/context/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './globalstateClass'
export * from './stateClass'
export * from './idlestateClass'
export * from './redisStateClass'
88 changes: 88 additions & 0 deletions packages/bot/src/context/redisStateClass.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Redis from 'ioredis'

type Context = {
from: string
}

type StateValue = Record<string, any>

class RedisState {
private readonly client: Redis
private readonly PREFIX = null

constructor(_client: Redis, options: { prefix: string | null }) {
//Se requiere una instancia de redis ya que si se usa el database-redis se puede reutilizar la misma conexion a redis enviandola desde el main
this.client = _client
if (options.prefix) this.PREFIX = options.prefix
}

getKey = (ctx: Context) => `bot_state:${this.PREFIX ? this.PREFIX + ':' : ''}${ctx.from}`

/**
* Actualiza el estado en Redis (Merge de objetos)
*/
updateState = (ctx: Context): ((keyValue: StateValue) => Promise<void>) => {
return async (keyValue: StateValue) => {
const key = this.getKey(ctx)
const currentData = await this.client.get(key)
const currentState = currentData ? JSON.parse(currentData) : {}

const updatedState = { ...currentState, ...keyValue }
// Guardamos con un TTL opcional (ej. 24h) para no llenar el Redis para siempre
await this.client.set(key, JSON.stringify(updatedState), 'EX', 60 * 60 * 24)
}
}

/**
* Obtiene el estado completo
*/
getMyState = (from: string): (() => Promise<StateValue | undefined>) => {
return async () => {
const key = this.getKey({ from })
const data = await this.client.get(key)
return data ? JSON.parse(data) : undefined
}
}

/**
* Obtiene una propiedad específica (soporta punto como 'user.name')
*/
get = (from: string): ((prop: string) => Promise<any>) => {
return async (prop: string) => {
const key = this.getKey({ from })
const data = await this.client.get(key)
if (!data) return undefined

const state = JSON.parse(data)
const properties = prop.split('.')
let result = state

for (const property of properties) {
result = result?.[property]
if (result === undefined) return undefined
}

return result
}
}

/**
* Borra el estado de un usuario
*/
clear = (from: string): (() => Promise<number>) => {
const key = this.getKey({ from })
return () => this.client.del(key)
}

/**
* Borra todos los estados que empiecen con el prefijo
*/
clearAll = async (): Promise<void> => {
const keys = await this.client.keys('bot_state*')
if (keys.length > 0) {
await this.client.del(...keys)
}
}
}

export { RedisState }
Loading