// ENVIRONMENT VARIABLES const clientId = process.env.TWITCH_APP_ID const clientSecret = process.env.TWITCH_APP_SECRET if (!clientId || !clientSecret) { logConsole('twitch', 'missing_credentials') process.exit(1) } // PACKAGES import { AppTokenAuthProvider } from "@twurple/auth" import { ApiClient } from "@twurple/api" import { ReverseProxyAdapter, EventSubHttpListener } from "@twurple/eventsub-http" import { NgrokAdapter } from "@twurple/eventsub-ngrok" import type { EventSubStreamOnlineEvent, EventSubStreamOfflineEvent } from "@twurple/eventsub-base" import { EmbedBuilder, ChannelType, ComponentType, ButtonBuilder, ButtonStyle, Locale } from "discord.js" import type { Client, Guild } from "discord.js" import discordClient from "@/index" import type { GuildTwitch } from "@/types/schemas" import dbGuild from "@/schemas/guild" import { t, getGuildLocale } from "@/utils/i18n" import { logConsole, logConsoleError } from "@/utils/console" // Twurple API client setup const authProvider = new AppTokenAuthProvider(clientId, clientSecret) export const twitchClient = new ApiClient({ authProvider }) let adapter if (process.env.NODE_ENV === "development") { const authtoken = process.env.NGROK_AUTHTOKEN logConsole('twitch', 'starting_listener_ngrok') adapter = new NgrokAdapter({ ngrokConfig: { authtoken } }) } else { const hostName = process.env.TWURPLE_HOSTNAME ?? "localhost" const port = process.env.TWURPLE_PORT ?? "3000" logConsole('twitch', 'starting_listener_port', { port }) adapter = new ReverseProxyAdapter({ hostName, port: parseInt(port) }) } const secret = process.env.TWURPLE_SECRET ?? "VeryUnsecureSecretPleaseChangeMe" export const listener = new EventSubHttpListener({ apiClient: twitchClient, adapter, secret }) listener.start() // Twurple subscriptions callback functions export const onlineSub = async (event: EventSubStreamOnlineEvent) => { logConsole('twitch', 'stream_online', { streamer: event.broadcasterName, id: event.broadcasterId }) const results = await Promise.allSettled(discordClient.guilds.cache.map(async guild => { const processingKey = `${guild.id}-${event.broadcasterId}` try { if (processingStreamers.has(processingKey)) { logConsole('twitch', 'streamer_already_processing', { guildName: guild.name, broadcasterName: event.broadcasterName }); return } processingStreamers.add(processingKey) logConsole('twitch', 'processing_guild', { name: guild.name, id: guild.id, streamer: event.broadcasterName }) const notification = await generateNotification(guild, event.broadcasterId, event.broadcasterName) if (notification.status !== "ok") { logConsole('twitch', 'notification_failed', { guild: guild.name, status: notification.status }); return } const { guildProfile, dbData, channel, content, embed } = notification if (!dbData) { logConsole('twitch', 'no_db_data', { guild: guild.name }); return } const streamerIndex = dbData.streamers.findIndex(s => s.twitchUserId === event.broadcasterId) if (streamerIndex === -1) { logConsole('twitch', 'streamer_not_found', { guild: guild.name, streamer: event.broadcasterName }); return } if (dbData.streamers[streamerIndex].messageId) { logConsole('twitch', 'message_exists', { guild: guild.name, streamer: event.broadcasterName }); return } logConsole('twitch', 'sending_notification', { guild: guild.name, streamer: event.broadcasterName }) const message = await channel.send({ content, embeds: [embed] }) logConsole('twitch', 'message_sent', { guild: guild.name, id: message.id }) dbData.streamers[streamerIndex].messageId = message.id guildProfile.set("guildTwitch", dbData) guildProfile.markModified("guildTwitch") await guildProfile.save().catch(console.error) startStreamWatching(guild.id, event.broadcasterId, event.broadcasterName, message.id) } catch (error) { processingStreamers.delete(processingKey) logConsoleError('twitch', 'error_processing_guild', { name: guild.name }, error as Error) } finally { processingStreamers.delete(processingKey) } })) results.forEach((result, index) => { if (result.status === "rejected") logConsoleError('twitch', 'guild_failed', { index: index.toString() }, result.reason instanceof Error ? result.reason : new Error(String(result.reason))) }) } export const offlineSub = async (event: EventSubStreamOfflineEvent) => { logConsole('twitch', 'stream_offline', { streamer: event.broadcasterName, id: event.broadcasterId }) await Promise.all(discordClient.guilds.cache.map(async guild => { await stopStreamWatching(guild.id, event.broadcasterId, event.broadcasterName) })) } // Stream upadting intervals const streamIntervals = new Map() // Tracking des streamers en cours de traitement pour éviter les doublons const processingStreamers = new Set() export function startStreamWatching(guildId: string, streamerId: string, streamerName: string, messageId: string) { logConsole('twitch', 'start_watching', { streamer: streamerName, id: streamerId, guildId }) const key = `${guildId}-${streamerId}` if (streamIntervals.has(key)) { clearInterval(streamIntervals.get(key)) streamIntervals.delete(key) } // eslint-disable-next-line @typescript-eslint/no-misused-promises const interval = setInterval(async () => { try { const guild = await discordClient.guilds.fetch(guildId) const notification = await generateNotification(guild, streamerId, streamerName) if (notification.status !== "ok") { logConsole('twitch', 'notification_failed', { guild: guild.name, status: notification.status }); return } const { channel, content, embed } = notification if (!embed) { logConsole('twitch', 'embed_missing', { guild: guild.name }); return } try { const message = await channel.messages.fetch(messageId) await message.edit({ content, embeds: [embed] }) } catch (error) { logConsoleError('twitch', 'error_editing_message', { guild: guild.name, streamer: streamerName, id: streamerId }, error as Error) } } catch (error) { logConsoleError('twitch', 'error_watching', { streamer: streamerName, id: streamerId, guildId }, error as Error) await stopStreamWatching(guildId, streamerId, streamerName) } }, 60000) streamIntervals.set(key, interval) } export async function stopStreamWatching(guildId: string, streamerId: string, streamerName: string) { logConsole('twitch', 'stop_watching', { streamer: streamerName, id: streamerId }) const key = `${guildId}-${streamerId}` if (streamIntervals.has(key)) { clearInterval(streamIntervals.get(key)) streamIntervals.delete(key) } try { const guild = await discordClient.guilds.fetch(guildId) const guildProfile = await dbGuild.findOne({ guildId: guild.id }) if (!guildProfile) { logConsole('twitch', 'database_not_exist', { guild: guild.name }); return } const dbData = guildProfile.get("guildTwitch") as GuildTwitch if (!dbData.enabled || !dbData.channelId) { logConsole('twitch', 'database_not_exist', { guild: guild.name }); return } const channel = await guild.channels.fetch(dbData.channelId) if (!channel || (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement)) { logConsole('twitch', 'channel_not_found', { guild: guild.name, channelId: dbData.channelId }) return } const streamer = dbData.streamers.find(s => s.twitchUserId === streamerId) if (!streamer) { logConsole('twitch', 'streamer_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }); return } const messageId = streamer.messageId if (!messageId) { logConsole('twitch', 'message_id_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }); return } const user = await twitchClient.users.getUserById(streamerId) if (!user) logConsole('twitch', 'user_data_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }) const guildLocale = await getGuildLocale(guild.id) let duration_string = "" const stream = await user?.getStream() if (!stream) { logConsole('twitch', 'stream_data_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }) duration_string = t(guildLocale, "twitch.notification.offline.duration_unknown") } else { const duration = new Date().getTime() - stream.startDate.getTime() const seconds = Math.floor(duration / 1000) const minutes = Math.floor(seconds / 60) const hours = Math.floor(minutes / 60) // eslint-disable-next-line @typescript-eslint/restrict-plus-operands duration_string = `${hours ? hours + "H " : ""}${minutes % 60 ? (minutes % 60) + "M " : ""}${seconds % 60 ? (seconds % 60) + "S" : ""}` } let content = "" if (!streamer.discordUserId) content = t(guildLocale, "twitch.notification.offline.everyone", { streamer: streamerName }) else content = t(guildLocale, "twitch.notification.offline.everyone_with_mention", { discordId: streamer.discordUserId }) const embed = new EmbedBuilder() .setColor("#6441a5") .setAuthor({ name: t(guildLocale, "twitch.notification.offline.author", { duration: duration_string }), iconURL: user?.profilePictureUrl ?? "https://static-cdn.jtvnw.net/emoticons/v2/58765/static/light/3.0" }) .setTimestamp() try { const message = await channel.messages.fetch(messageId) await message.edit({ content, embeds: [embed] }) } catch (error) { logConsoleError('twitch', 'error_editing_message', { guild: guild.name, streamer: streamerName, id: streamerId }, error as Error) } const streamerIndex = dbData.streamers.findIndex(s => s.twitchUserId === streamerId) if (streamerIndex === -1) return dbData.streamers[streamerIndex].messageId = "" guildProfile.set("guildTwitch", dbData) guildProfile.markModified("guildTwitch") await guildProfile.save().catch(console.error) } catch (error) { logConsoleError('twitch', 'stop_watching_error', { streamer: streamerName, id: streamerId, guildId }, error as Error) } } async function generateNotification(guild: Guild, streamerId: string, streamerName: string) { const guildProfile = await dbGuild.findOne({ guildId: guild.id }) if (!guildProfile) { logConsole('twitch', 'database_not_exist', { guild: guild.name }) return { status: "noProfile" } } const dbData = guildProfile.get("guildTwitch") as GuildTwitch if (!dbData.enabled || !dbData.channelId) { logConsole('twitch', 'module_disabled', { guild: guild.name }) return { status: "disabled" } } const channel = await guild.channels.fetch(dbData.channelId) if ((channel?.type !== ChannelType.GuildText && channel?.type !== ChannelType.GuildAnnouncement)) { logConsole('twitch', 'channel_not_found', { guild: guild.name, channelId: dbData.channelId }) return { status: "noChannel" } } const streamer = dbData.streamers.find(s => s.twitchUserId === streamerId) if (!streamer) { logConsole('twitch', 'streamer_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }) return { status: "noStreamer" } } const user = await twitchClient.users.getUserById(streamerId) if (!user) logConsole('twitch', 'user_data_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }) const stream = await user?.getStream() if (!stream) logConsole('twitch', 'stream_data_not_found', { guild: guild.name, streamer: streamerName, id: streamerId }) const guildLocale = await getGuildLocale(guild.id) let content = "" if (!streamer.discordUserId) content = t(guildLocale, "twitch.notification.online.everyone", { streamer: user?.displayName ?? streamerName }) else content = t(guildLocale, "twitch.notification.online.everyone_with_mention", { discordId: streamer.discordUserId }) const embed = new EmbedBuilder() .setColor("#6441a5") .setTitle(stream?.title ?? t(guildLocale, "twitch.notification.online.title_unknown")) .setURL(`https://twitch.tv/${streamerName}`) .setAuthor({ name: t(guildLocale, "twitch.notification.online.author", { streamer: (user?.displayName ?? streamerName).toUpperCase() }), iconURL: user?.profilePictureUrl ?? "https://static-cdn.jtvnw.net/emoticons/v2/58765/static/light/3.0" }) .setDescription(t(guildLocale, "twitch.notification.online.description", { game: stream?.gameName ?? "?", viewers: stream?.viewers.toString() ?? "?" })) .setImage(stream?.thumbnailUrl.replace("{width}", "1920").replace("{height}", "1080") ?? "https://assets.help.twitch.tv/article/img/000002222-01a.png") .setTimestamp() return { status: "ok", guildProfile, dbData, channel, content, embed } } export function generateTwitchEmbed(dbData: GuildTwitch, client: Client, guildId: string, locale: Locale) { // Récupérer les informations du canal let channelInfo = t(locale, "twitch.channel_not_configured") if (dbData.channelId) { const guild = client.guilds.cache.get(guildId) const channel = guild?.channels.cache.get(dbData.channelId) channelInfo = channel ? `<#${channel.id}>` : t(locale, "twitch.common.channel_not_found") } // Créer l'embed principal const embed = new EmbedBuilder() .setTitle(t(locale, "twitch.title")) .setColor(dbData.enabled ? 0x9146FF : 0x808080) .addFields( { name: t(locale, "common.status"), value: dbData.enabled ? t(locale, "twitch.common.enabled") : t(locale, "twitch.common.disabled"), inline: true }, { name: t(locale, "common.channel"), value: channelInfo, inline: true }, { name: "👥 Streamers", value: t(locale, "twitch.streamers_count", { count: dbData.streamers.length.toString() }), inline: true } ) .setFooter({ text: t(locale, "twitch.managed_by", { bot: client.user?.displayName ?? "Bot" }) }) .setTimestamp() // Boutons première ligne - Toggle et configuration const toggleButton = new ButtonBuilder() .setCustomId(dbData.enabled ? "twitch_disable" : "twitch_enable") .setLabel(dbData.enabled ? t(locale, "common.disable") : t(locale, "common.enable")) .setStyle(dbData.enabled ? ButtonStyle.Danger : ButtonStyle.Success) .setEmoji(dbData.enabled ? "❌" : "✅") const channelButton = new ButtonBuilder() .setCustomId("twitch_channel") .setLabel(t(locale, "twitch.common.configure_channel")) .setStyle(ButtonStyle.Secondary) .setEmoji("📺") // Boutons seconde ligne - Gestion des streamers const listButton = new ButtonBuilder() .setCustomId("twitch_streamer_list") .setLabel(t(locale, "common.list")) .setStyle(ButtonStyle.Secondary) .setEmoji("📋") const addButton = new ButtonBuilder() .setCustomId("twitch_streamer_add") .setLabel(t(locale, "common.add")) .setStyle(ButtonStyle.Primary) .setEmoji("➕") const removeButton = new ButtonBuilder() .setCustomId("twitch_streamer_remove") .setLabel(t(locale, "common.remove")) .setStyle(ButtonStyle.Danger) .setEmoji("🗑️") const components = [ { type: ComponentType.ActionRow, components: [toggleButton, channelButton] }, { type: ComponentType.ActionRow, components: [listButton, addButton, removeButton] } ] return { embed, components } }