All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 3m43s
339 lines
15 KiB
TypeScript
339 lines
15 KiB
TypeScript
// 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 }
|
||
}
|