The Streamer Alerts Bot uses a polling-based alert system with intelligent caching to send beautiful, rich Discord embeds when streamers go live.
System Overview
Polling Loop
Every 60 seconds, the bot checks all tracked streamers across all servers
Status Check
Each streamer’s platform is queried via the appropriate checker function
Duplicate Detection
The bot compares the new status with cached data to prevent duplicate alerts
Alert Dispatch
If criteria are met, a rich embed with “Watch” button is sent to the configured channel
Polling Mechanism
Configuration
// From constants.ts:50-52
export const POLL_INTERVAL = 60 * 1000 ; // 60 seconds
Stream Poller Service
The StreamPoller class manages the polling loop:
// From StreamPoller.ts:20-48
export class StreamPoller {
private client : Client ;
private intervalId : NodeJS . Timeout | null = null ;
private isRunning = false ;
constructor ( client : Client ) {
this . client = client ;
}
start () : void {
if ( this . isRunning ) {
logger . warn ( "Stream poller is already running" );
return ;
}
this . isRunning = true ;
logger . info ( `Starting stream poller (interval: ${ POLL_INTERVAL / 1000 } s)` );
// Run immediately on start
this . poll ();
// Then run on interval
this . intervalId = setInterval (() => {
this . poll ();
}, POLL_INTERVAL );
}
stop () : void {
if ( this . intervalId ) {
clearInterval ( this . intervalId );
this . intervalId = null ;
}
this . isRunning = false ;
logger . info ( "Stream poller stopped" );
}
}
Poll Cycle Flow
Poll Function
Guild Check
Single Streamer Check
// From StreamPoller.ts:65-78
private async poll (): Promise < void > {
const guilds = getAllGuildsWithStreamers ();
if (guilds.length === 0) {
logger . debug ( "No guilds with streamers to check" );
return ;
}
logger.debug( `Polling ${ guilds . length } guilds` );
for (const { guildId, streamers } of guilds) {
await this.checkGuildStreamers(guildId, streamers);
}
}
Duplicate Detection
The bot uses a smart caching system to prevent duplicate alerts:
Alert Logic
// From StreamPoller.ts:119-141
private async processStatus (
guildId : string ,
streamer : Streamer ,
status : LiveStatus ,
): Promise < Streamer > {
const cacheKey = ` ${ guildId } - ${ streamer . id } ` ;
const lastData = lastLiveData . get ( cacheKey );
// Determine if we should send an alert
// Alert if: now live AND (wasn't live before OR title changed)
const shouldAlert =
status.isLive &&
(!lastData?.isLive || (status.title && lastData.title !== status.title));
if ( shouldAlert ) {
await sendLiveAlert ( this . client , streamer . channelId , status );
// Update cache
lastLiveData . set ( cacheKey , {
title: status . title ,
isLive: true ,
});
}
// Clean up cache if went offline
if (!status.isLive && lastData ? .isLive) {
lastLiveData . delete ( cacheKey );
}
return updatedStreamer;
}
Alert Criteria
An alert is sent when ALL of the following are true:
The streamer must currently be live.
Previous Status was Offline OR Title Changed
! lastData ?. isLive || ( status . title && lastData . title !== status . title )
Either:
The streamer was offline in the previous check
OR the stream title has changed (indicates a new stream)
Why check title changes? Some streamers keep their stream “live” 24/7 with offline screens. Title changes indicate actual new content.
Cache Structure
// From StreamPoller.ts:14-15
const lastLiveData = new Map < string , { title ?: string ; isLive : boolean }>();
// Cache key format: "guildId-platform:username"
const cacheKey = ` ${ guildId } - ${ streamer . id } ` ;
// Example: "123456789-twitch:xqc"
Alert Service
The AlertService handles sending alerts to Discord channels:
// From AlertService.ts:10-39
export async function sendLiveAlert (
client : Client ,
channelId : string ,
status : LiveStatus ,
) : Promise < boolean > {
try {
const channel = await client . channels . fetch ( channelId );
if ( ! channel || ! channel . isTextBased ()) {
logger . warn ( `Channel ${ channelId } not found or not text-based` );
return false ;
}
const embed = createLiveEmbed ( status );
const row = createWatchButtonRow ( status . url , status . platform );
await ( channel as TextChannel ). send ({
embeds: [ embed ],
components: [ row ],
});
logger . info (
`Sent live alert for ${ status . username } ( ${ status . platform } ) to channel ${ channelId } ` ,
);
return true ;
} catch ( error ) {
logger . error ( `Failed to send alert to channel ${ channelId } :` , error );
return false ;
}
}
Alert Embed Structure
The live alert embed is rich and platform-specific:
Embed Components
Author Section
Title
Description
Fields
Images
Shows streamer info with profile picture: const verifiedBadge = status . verified ? " ✓" : "" ;
embed . setAuthor ({
name: ` ${ status . username }${ verifiedBadge } is LIVE` ,
iconURL: status . profileImage ,
url: status . url ,
});
Stream title (truncated to 256 chars): if ( status . title ) {
embed . setTitle ( truncate ( status . title , 256 ));
}
Key stream information: const descParts : string [] = [];
if ( status . category ) {
descParts . push ( `**Playing:** ${ status . category } ` );
}
if ( status . viewers !== undefined ) {
descParts . push ( `**Viewers:** ${ formatNumber ( status . viewers ) } ` );
}
if ( status . followers !== undefined ) {
const label = status . platform === "youtube" ? "Subscribers" : "Followers" ;
descParts . push ( `** ${ label } :** ${ formatNumber ( status . followers ) } ` );
}
if ( descParts . length > 0 ) {
embed . setDescription ( descParts . join ( " • " ));
}
Additional metadata: // Started timestamp
if ( status . startedAt ) {
fields . push ({
name: "Started" ,
value: discordTimestamp ( status . startedAt , "R" ), // Relative time
inline: true ,
});
}
// Language
if ( status . language ) {
fields . push ({
name: "Language" ,
value: status . language . toUpperCase (),
inline: true ,
});
}
// Mature content
if ( status . isMature ) {
fields . push ({
name: "Mature" ,
value: "18+" ,
inline: true ,
});
}
// Tags
if ( status . tags && status . tags . length > 0 ) {
const displayTags = status . tags
. slice ( 0 , 6 )
. map (( t ) => ` \` ${ t } \` ` )
. join ( " " );
fields . push ({
name: "Tags" ,
value: displayTags ,
inline: false ,
});
}
Visual elements: // Stream preview as main image
if ( status . thumbnail ) {
embed . setImage ( status . thumbnail );
}
// Category icon as thumbnail (small, on the right)
if ( status . categoryIcon ) {
embed . setThumbnail ( status . categoryIcon );
} else if ( status . profileImage && ! status . thumbnail ) {
embed . setThumbnail ( status . profileImage );
}
Full Embed Implementation
// From embeds.ts:18-123
export function createLiveEmbed ( status : LiveStatus ) : EmbedBuilder {
const platform = PLATFORMS [ status . platform ];
const embed = new EmbedBuilder ()
. setColor ( platform . color )
. setURL ( status . url )
. setTimestamp ();
// Author section - streamer info with profile pic
const verifiedBadge = status . verified ? " ✓" : "" ;
embed . setAuthor ({
name: ` ${ status . username }${ verifiedBadge } is LIVE` ,
iconURL: status . profileImage ,
url: status . url ,
});
// Title - stream title
if ( status . title ) {
embed . setTitle ( truncate ( status . title , 256 ));
}
// Build description with key info
const descParts : string [] = [];
if ( status . category ) {
descParts . push ( `**Playing:** ${ status . category } ` );
}
if ( status . viewers !== undefined ) {
descParts . push ( `**Viewers:** ${ formatNumber ( status . viewers ) } ` );
}
if ( status . followers !== undefined ) {
const label = status . platform === "youtube" ? "Subscribers" : "Followers" ;
descParts . push ( `** ${ label } :** ${ formatNumber ( status . followers ) } ` );
}
if ( descParts . length > 0 ) {
embed . setDescription ( descParts . join ( " • " ));
}
// Fields for additional data
const fields : Array <{ name : string ; value : string ; inline : boolean }> = [];
// Row 1: Started, Language, Mature
if ( status . startedAt ) {
fields . push ({
name: "Started" ,
value: discordTimestamp ( status . startedAt , "R" ),
inline: true ,
});
}
if ( status . language ) {
fields . push ({
name: "Language" ,
value: status . language . toUpperCase (),
inline: true ,
});
}
if ( status . isMature ) {
fields . push ({
name: "Mature" ,
value: "18+" ,
inline: true ,
});
}
// Tags row
if ( status . tags && status . tags . length > 0 ) {
const displayTags = status . tags
. slice ( 0 , 6 )
. map (( t ) => ` \` ${ t } \` ` )
. join ( " " );
fields . push ({
name: "Tags" ,
value: displayTags ,
inline: false ,
});
}
if ( fields . length > 0 ) {
embed . addFields ( fields );
}
// Stream preview as main image
if ( status . thumbnail ) {
embed . setImage ( status . thumbnail );
}
// Category icon as thumbnail (small, on the right)
if ( status . categoryIcon ) {
embed . setThumbnail ( status . categoryIcon );
} else if ( status . profileImage && ! status . thumbnail ) {
embed . setThumbnail ( status . profileImage );
}
// Footer with platform name
embed . setFooter ({
text: platform . name ,
iconURL: FOOTER . iconURL ,
});
return embed ;
}
Every alert includes a clickable button to watch the stream:
export function createWatchButtonRow (
url : string ,
platform : Platform ,
) : ActionRowBuilder < ButtonBuilder > {
const platformConfig = PLATFORMS [ platform ];
const button = new ButtonBuilder ()
. setLabel ( `Watch on ${ platformConfig . name } ` )
. setStyle ( ButtonStyle . Link )
. setURL ( url )
. setEmoji ( platformConfig . emoji );
return new ActionRowBuilder < ButtonBuilder >(). addComponents ( button );
}
Startup Sequence
The polling system starts automatically when the bot is ready:
// From ready.ts:8-20
export function handleReady ( client : StreamerBot ) : void {
logger . info ( `Logged in as ${ client . user ?. tag } ` );
logger . info ( `Serving ${ client . guilds . cache . size } guilds` );
// Start activity rotation
client . startActivityRotation ();
// Start stream polling
const poller = createStreamPoller ( client );
poller . start ();
logger . info ( "Bot is ready!" );
}
Data Persistence
After each poll cycle, updated streamer data is saved:
// From StreamPoller.ts:149-163
return {
... streamer ,
isLive: status . isLive ,
lastLiveAt: status . isLive
? new Date (). toISOString ()
: streamer . lastLiveAt ,
title: status . title ?? streamer . title ,
viewers: status . viewers ?? streamer . viewers ,
followers: status . followers ?? streamer . followers ,
thumbnail: status . thumbnail ?? streamer . thumbnail ,
profileImage: status . profileImage ?? streamer . profileImage ,
startedAt: status . startedAt ?? streamer . startedAt ,
verified: status . verified ?? streamer . verified ,
bio: status . bio ?? streamer . bio ,
};
This data is persisted to the Enmap database automatically.
Error Handling
The polling system includes comprehensive error handling:
for ( const streamer of streamers ) {
try {
const status = await this . checkStreamer ( streamer );
const updated = await this . processStatus ( guildId , streamer , status );
updatedStreamers . push ( updated );
} catch ( error ) {
logger . error ( `Error checking ${ streamer . id } :` , error );
// Keep the old streamer data on error
updatedStreamers . push ( streamer );
}
}
If a platform check fails:
Error is logged
Previous streamer data is retained
Polling continues for other streamers
export const POLL_INTERVAL = 60 * 1000 ; // 60 seconds
Balances:
Timeliness: Alerts within 1 minute of going live
Rate limits: Avoids overwhelming platform APIs
Resource usage: Reasonable CPU/network load
Streamers are checked sequentially (not in parallel) to:
Avoid rate limiting
Prevent memory spikes
Ensure stable operation
const lastLiveData = new Map < string , { title ?: string ; isLive : boolean }>();
Only stores minimal data (title + live status)
Automatically cleaned when streamers go offline
Scales with active streams, not total tracked streamers
Logging
Comprehensive logging tracks all polling activity:
logger . info ( `Starting stream poller (interval: ${ POLL_INTERVAL / 1000 } s)` );
logger . debug ( `Polling ${ guilds . length } guilds` );
logger . platform ( streamer . platform , streamer . username , status . isLive );
logger . info ( `Sent live alert for ${ status . username } ( ${ status . platform } )` );
logger . error ( `Error checking ${ streamer . id } :` , error );
Log levels:
info: Major events (startup, alerts sent)
debug: Detailed polling info
error: Failures and exceptions
Example Alert Flow
Check Streamer
[PLATFORM] twitch:xqc - LIVE
Compare with Cache
cacheKey = "123456789-twitch:xqc"
lastData = { title: "Old Stream" , isLive: true }
newStatus = { title: "New Stream" , isLive: true }
shouldAlert = true // Title changed!
Send Alert
[INFO] Sent live alert for xqc (twitch) to channel 987654321
Discord receives:
Rich embed with stream info
Preview thumbnail
“Watch on Twitch” button
Update Cache
lastLiveData . set ( "123456789-twitch:xqc" , {
title: "New Stream" ,
isLive: true ,
});