Files
bot_Tamiseur/src/utils/twitch.ts
Zachary Guénot e714e94f85
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 3m43s
Fix duplicate streamWatching, locale guild et console log/error
2025-06-11 02:50:58 +02:00

339 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<string, NodeJS.Timeout>()
// Tracking des streamers en cours de traitement pour éviter les doublons
const processingStreamers = new Set<string>()
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 }
}