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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ REDIS_DB=0
# TWITCH_ACCESS_TOKEN= # User OAuth token (required for EventSub)
# TWITCH_REFRESH_TOKEN= # User OAuth refresh token (optional, for auto-refresh)

# Criativaria Twitch Live Notifications (Optional)
# Set these to enable live stream notifications for a specific Twitch account.
# CRIATIVARIA_LIVES_CHANNEL_ID=
# CRIATIVARIA_TWITCH_USER_LOGIN=criativaria

# Last.fm API (Optional - for scrobbling and now playing)
# Get API key and secret at https://www.last.fm/api/account/create
# Users link their own accounts via /lastfm link; optional fallback for global scrobbling (see docs/LASTFM_SETUP.md).
Expand Down
11 changes: 11 additions & 0 deletions packages/bot/src/bot/start/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { birthdayScheduler } from '../../utils/general/birthdayScheduler'
import { modDigestSchedulerService } from '../../utils/moderation/modDigestScheduler'
import { aiDevToolkitService } from '../../services/AiDevToolkitService'
import { dependencyCheckService } from '../../services/DependencyCheckService'
import { criativariaLiveNotificationService } from '../../services/CriativariaLiveNotificationService'
import { stopTwitchService } from '../../twitch'
import { stopBatchJobWorker } from '../../workers/batchJobWorker'
import { setClient } from '../clientStore'
Expand Down Expand Up @@ -228,6 +229,16 @@ export class BotInitializer {
})
}

try {
criativariaLiveNotificationService.stop()
} catch (error) {
errorLog({
message:
'Error stopping Criativaria live notification service:',
error,
})
}

try {
stopTwitchService()
} catch (error) {
Expand Down
11 changes: 11 additions & 0 deletions packages/bot/src/handlers/clientHandler/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type ContextMenuCommand from '../../models/ContextMenuCommand'
import { startPresenceRotation } from './presence'
import { modDigestSchedulerService } from '../../utils/moderation/modDigestScheduler'
import { birthdayScheduler } from '../../utils/general/birthdayScheduler'
import { criativariaLiveNotificationService } from '../../services/CriativariaLiveNotificationService'

let presenceControls: {
stop: () => void
Expand Down Expand Up @@ -121,6 +122,16 @@ export async function startClient({
})
}

try {
criativariaLiveNotificationService.start(client)
} catch (error) {
errorLog({
message:
'Failed to start Criativaria live notification service',
error,
})
}

resolve()
})
})
Expand Down
225 changes: 225 additions & 0 deletions packages/bot/src/services/CriativariaLiveNotificationService.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import {
describe,
test,
expect,
jest,
beforeEach,
afterEach,
} from '@jest/globals'
import type { Client, TextChannel } from 'discord.js'

jest.mock('@lucky/shared/utils', () => ({
infoLog: jest.fn(),
errorLog: jest.fn(),
debugLog: jest.fn(),
warnLog: jest.fn(),
successLog: jest.fn(),
}))

jest.mock('../twitch/token')

import { CriativariaLiveNotificationService } from './CriativariaLiveNotificationService'
import { getTwitchUserAccessToken } from '../twitch/token'

const mockGetToken = getTwitchUserAccessToken as jest.MockedFunction<
typeof getTwitchUserAccessToken
>

describe('CriativariaLiveNotificationService', () => {
let service: CriativariaLiveNotificationService
let mockClient: Partial<Client>
let mockChannel: Partial<TextChannel>

beforeEach(() => {
mockChannel = {
send: jest.fn(),
}

mockClient = {
channels: {
fetch: jest.fn(async () => mockChannel),
},
}

service = new CriativariaLiveNotificationService(() => Date.now(), 1000)
process.env.CRIATIVARIA_LIVES_CHANNEL_ID = 'test-channel-id'
process.env.CRIATIVARIA_TWITCH_USER_LOGIN = 'criativaria'
process.env.TWITCH_CLIENT_ID = 'test-client-id'
})

afterEach(() => {
jest.clearAllMocks()
service.stop()
})

test('should not start when env vars are missing', async () => {
delete process.env.CRIATIVARIA_LIVES_CHANNEL_ID
const service2 = new CriativariaLiveNotificationService(
() => Date.now(),
1000,
)
service2.start(mockClient as Client)
expect(mockClient.channels.fetch).not.toHaveBeenCalled()
service2.stop()
})

test('should not notify when offline (no stream returned)', async () => {
jest.spyOn(service, 'fetchStream').mockResolvedValue(null)

await service.checkAndNotify(mockClient as Client)

expect(mockChannel.send).not.toHaveBeenCalled()
})

test('should notify when stream is live with new stream ID', async () => {
jest.spyOn(service, 'fetchStream').mockResolvedValue({
id: 'stream-123',
user_login: 'criativaria',
title: 'Criativaria ao Vivo',
viewer_count: 1500,
game_name: 'Creative',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
})

await service.checkAndNotify(mockClient as Client)

expect(mockChannel.send).toHaveBeenCalledTimes(1)
})

test('should not re-notify for same stream ID', async () => {
jest.spyOn(service, 'fetchStream').mockResolvedValue({
id: 'stream-123',
user_login: 'criativaria',
title: 'Criativaria ao Vivo',
viewer_count: 1500,
game_name: 'Creative',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
})

await service.checkAndNotify(mockClient as Client)
expect(mockChannel.send).toHaveBeenCalledTimes(1)

await service.checkAndNotify(mockClient as Client)
expect(mockChannel.send).toHaveBeenCalledTimes(1)
})

test('should clear lastNotifiedStreamId when stream goes offline', async () => {
jest.spyOn(service, 'fetchStream').mockResolvedValue({
id: 'stream-123',
user_login: 'criativaria',
title: 'Criativaria ao Vivo',
viewer_count: 1500,
game_name: 'Creative',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
})

await service.checkAndNotify(mockClient as Client)
expect(mockChannel.send).toHaveBeenCalledTimes(1)

jest.spyOn(service, 'fetchStream').mockResolvedValue(null)
await service.checkAndNotify(mockClient as Client)

jest.spyOn(service, 'fetchStream').mockResolvedValue({
id: 'stream-456',
user_login: 'criativaria',
title: 'Criativaria ao Vivo 2',
viewer_count: 2000,
game_name: 'Creative',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
})
await service.checkAndNotify(mockClient as Client)

expect(mockChannel.send).toHaveBeenCalledTimes(2)
})

test('should handle send errors gracefully', async () => {
jest.spyOn(service, 'fetchStream').mockResolvedValue({
id: 'stream-123',
user_login: 'criativaria',
title: 'Criativaria ao Vivo',
viewer_count: 1500,
game_name: 'Creative',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
})

jest.mocked(mockChannel.send).mockRejectedValue(
new Error('Send failed'),
)

await expect(
service.checkAndNotify(mockClient as Client),
).resolves.toBeUndefined()
})
Comment thread
LucasSantana-Dev marked this conversation as resolved.

test('should stop the interval when stop() is called', async () => {
service.start(mockClient as Client)

service.stop()

expect((service as any).intervalHandle).toBeNull()
})

describe('fetchStream', () => {
beforeEach(() => {
mockGetToken.mockResolvedValue('test-token')
})

test('returns null when no token', async () => {
mockGetToken.mockResolvedValue(null)
const result = await service.fetchStream('criativaria')
expect(result).toBeNull()
})

test('returns null when TWITCH_CLIENT_ID not set', async () => {
delete process.env.TWITCH_CLIENT_ID
const result = await service.fetchStream('criativaria')
expect(result).toBeNull()
})

test('returns null when fetch returns non-ok status', async () => {
global.fetch = jest.fn(async () => ({ ok: false })) as any
const result = await service.fetchStream('criativaria')
expect(result).toBeNull()
})

test('returns null when fetch throws', async () => {
global.fetch = jest.fn(async () => {
throw new Error('network error')
}) as any
const result = await service.fetchStream('criativaria')
expect(result).toBeNull()
})

test('returns null when data array is empty', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ data: [] }),
})) as any
const result = await service.fetchStream('criativaria')
expect(result).toBeNull()
})

test('returns stream when fetch succeeds', async () => {
const stream = {
id: 'stream-1',
user_login: 'criativaria',
title: 'Live Test',
viewer_count: 100,
game_name: 'Gaming',
thumbnail_url: 'https://example.com/{width}x{height}.jpg',
started_at: new Date().toISOString(),
}
global.fetch = jest.fn(async () => ({
ok: true,
json: async () => ({ data: [stream] }),
})) as any
const result = await service.fetchStream('criativaria')
expect(result).toEqual(stream)
})
})
})
Loading
Loading