From 4bc95c52b19eef2303c961d6c30ab5fd53b59e6a Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Fri, 19 Jun 2026 07:18:00 +0200 Subject: [PATCH] first commit --- .env.example | 19 + .gitignore | 2 + README.md | 179 ++++++++ commands/meteo.js | 244 +++++++++++ index.js | 118 ++++++ package-lock.json | 945 ++++++++++++++++++++++++++++++++++++++++++ package.json | 20 + services/astronomy.js | 232 +++++++++++ services/charts.js | 255 ++++++++++++ services/composite.js | 316 ++++++++++++++ services/weather.js | 254 ++++++++++++ 11 files changed, 2584 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 commands/meteo.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 services/astronomy.js create mode 100644 services/charts.js create mode 100644 services/composite.js create mode 100644 services/weather.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa635b8 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# ─── Bot Discord ────────────────────────────────────────────── +DISCORD_TOKEN=your_discord_bot_token_here +CHANNEL_ID=your_channel_id_here + +# ─── Heure d'envoi du message quotidien (Europe/Paris) ──────── +SEND_HOUR=7 +SEND_MINUTE=30 + +# ─── Localisation (lat/lon) ─────────────────────────────────── +LAT=44.6310 +LON=-1.0215 +LOCATION_NAME=Lacanau-Océan + +# ─── Options avancées ───────────────────────────────────────── +# Activer le mode debug (affiche les données brutes de l'API) +DEBUG_METEO=false + +# Durée du cache en minutes (défaut : 10) +CACHE_MINUTES=10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..307beae --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.env +/node_modules \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9189a45 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# 🌦️ Discord Météo Bot + +Bot Discord ultra-complet avec météo temps réel, graphiques et données marines. **Aucune API payante.** + +## ✨ Fonctionnalités + +| Fonctionnalité | Détails | +|---|---| +| `/meteo` | Rapport complet à la demande | +| ⏰ Message quotidien | Heure configurable via `.env` | +| 📊 5 graphiques | Température, vent, pluie, pression, nuages | +| 🌊 Données marines | Vagues, houle, direction (si côtier) | +| 🌙 Phase lunaire | Calcul local, illumination | +| 🌅 Astronomie | Lever/coucher soleil, durée du jour | +| ⚠️ Alertes météo | Vent fort, pluie intense, orage, brouillard | +| 📊 Comparaison J-1 | Température, pluie, vent vs hier | +| 🔧 Mode debug | Données brutes + diagnostics | +| 💾 Cache 10 min | Évite le spam API | + +## 🚀 Installation + +### 1. Prérequis +- **Node.js >= 18** — [télécharger](https://nodejs.org) +- Un bot Discord (token + permissions) + +### 2. Cloner / télécharger +```bash +# Placer tous les fichiers dans un dossier +cd discord-meteo-bot +npm install +``` + +### 3. Configurer le bot Discord + +1. Aller sur [discord.com/developers/applications](https://discord.com/developers/applications) +2. Créer une nouvelle application → **Bot** → **Reset Token** → copier le token +3. Dans **OAuth2 → URL Generator** : + - Scopes : `bot`, `applications.commands` + - Bot Permissions : `Send Messages`, `Embed Links`, `Attach Files` +4. Utiliser le lien généré pour inviter le bot sur votre serveur + +### 4. Configurer le `.env` + +```bash +cp .env.example .env +# Puis éditer .env avec vos valeurs +``` + +```env +DISCORD_TOKEN=MTUx... # Token du bot Discord +CHANNEL_ID=1234567890123456789 # ID du channel (clic droit → Copier l'ID) + +SEND_HOUR=7 # Heure d'envoi du message quotidien +SEND_MINUTE=30 # Minute d'envoi + +LAT=44.6310 # Latitude +LON=-1.0215 # Longitude +LOCATION_NAME=Lacanau-Océan # Nom affiché dans Discord + +DEBUG_METEO=false # true pour le mode debug +CACHE_MINUTES=10 # Durée du cache API +``` + +> **Activer l'ID Discord** : Paramètres Discord → Avancé → Mode développeur ✅ + +### 5. Lancer le bot + +```bash +# Production +npm start + +# Développement (rechargement auto) +npm run dev +``` + +## 📦 APIs utilisées + +| API | Usage | Clé requise | +|---|---|---| +| [Open-Meteo](https://open-meteo.com/) | Météo complète (temp, vent, pluie…) | ❌ Gratuit | +| [Open-Meteo Marine](https://open-meteo.com/en/docs/marine-weather-api) | Vagues, houle | ❌ Gratuit | +| [Sunrise-Sunset.org](https://sunrise-sunset.org/api) | Lever/coucher soleil | ❌ Gratuit | +| [QuickChart.io](https://quickchart.io/) | Génération des graphiques | ❌ Gratuit | +| Phase lunaire | Calcul mathématique local | ❌ Aucune | + +## 🏗️ Architecture + +``` +discord-meteo-bot/ +├── index.js # Bot principal, cron, routing +├── commands/ +│ └── meteo.js # Commande /meteo + buildEmbed + sendReport +├── services/ +│ ├── weather.js # Open-Meteo API, cache, helpers +│ ├── astronomy.js # Sunrise-Sunset API, phase lunaire +│ └── charts.js # Génération graphiques via QuickChart +├── .env # Configuration (ne pas committer!) +├── .env.example # Template +└── package.json +``` + +## 🎨 Aperçu de l'embed + +``` +⛅ Partiellement nuageux — Lacanau-Océan +22.4°C · Ressenti 20.1°C · 🌞 Jour + +🌡️ Températures 💨 Vent 🌧️ Précipitations +Actuelle : 22.4°C Vitesse : 28 km/h Actuelle : 0.2 mm/h +Ressentie : 20.1°C Rafales : 41 km/h Proba. : 30% +Max / Min : 26°C/14°C Dir. : OSO (255°) Pluie : 0.2 mm/h +Humidité : 72% Max : 52 km/h Total : 3.4 mm + +🔵 Atmosphère ☁️ Nuages 👁️ Visibilité +Pression : 1012 hPa ↓ Total : 45% 18.0 km +UV Index : 6 — 🟠 Élevé Bas : 30% 👍 Bonne +UV max : 8 Moyen : 10% + Haut : 5% + +🌅 Astronomie +🌅 Lever : 07:24 🌇 Coucher : 20:48 ☀️ Zénith : 14:06 ⏱️ Durée : 13h24min + +🌙 Phase lunaire 🌊 Données marines +🌒 Croissant montant Hauteur : 1.4 m +Illumination : 38% Période : 8.5 s +Jour J+4.3 Dir. : NO (325°) + +📊 Comparaison avec hier +Temp max : +1.8°C ↑ Pluie : -2.1 mm ↓ Vent max : +8 km/h ↑ +``` + +Suivi des **5 graphiques** (image séparée) : +- 🌡️ Températures sur 24h +- 💨 Vent sur 24h (vitesse + rafales) +- 🌧️ Précipitations sur 24h (mm + probabilité) +- 🔵 Pression atmosphérique sur 24h +- ☁️ Couverture nuageuse sur 24h (total + bas/moyen/haut) + +## ⚙️ Mode Debug + +Mettre `DEBUG_METEO=true` dans le `.env` pour ajouter un champ debug à l'embed : +- Heure de mise en cache +- Index horaires courants +- Source des données astronomiques +- Nombre de graphiques générés + +## 🔧 Personnalisation rapide + +**Changer la fréquence du cache :** +```env +CACHE_MINUTES=15 +``` + +**Forcer deux messages par jour :** +Dupliquer le bloc `cron.schedule()` dans `index.js` avec un deuxième horaire. + +**Ajouter une autre localisation :** +Créer un second `.env` ou passer les coordonnées en argument. + +## 🐛 Dépannage + +| Problème | Solution | +|---|---| +| `Missing Access` | Vérifier les permissions du bot dans le channel | +| Commandes non visibles | Les commandes globales prennent jusqu'à 1h. Réinviter le bot. | +| Graphiques manquants | QuickChart peut être temporairement indisponible — réessayez | +| Marine non disponible | Normal pour les localisations éloignées des côtes | +| `Unknown Channel` | Vérifier le CHANNEL_ID dans .env | + +## 📝 Dépendances + +```json +{ + "discord.js": "^14.14.1", + "axios": "^1.6.8", + "node-cron": "^3.0.3", + "dotenv": "^16.4.5" +} +``` diff --git a/commands/meteo.js b/commands/meteo.js new file mode 100644 index 0000000..f129c53 --- /dev/null +++ b/commands/meteo.js @@ -0,0 +1,244 @@ +'use strict'; + +const { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } = require('discord.js'); +const { + fetchWeatherData, getCurrentHourIndex, getNextHoursWindow, getTodayDailyIndex, + celsiusToKelvin, getWindDirection, getWeatherDescription, getEmbedColor, + getPressureTendency, getVisibilityLabel, getUvLabel, checkAlerts, knotsFromKmh, +} = require('../services/weather'); +const { fetchAstronomyData } = require('../services/astronomy'); +const { generateAllCharts } = require('../services/charts'); +const { generateCompositeImage } = require('../services/composite'); + +// ─── Embed principal ────────────────────────────────────────────────────────── +function buildWeatherEmbed(weather, marine, astro, charts) { + const c = weather.current; + const h = weather.hourly; + const d = weather.daily; + + const todayIdx = getTodayDailyIndex(d.time); + const hourIdx = getCurrentHourIndex(h.time); + const location = process.env.LOCATION_NAME || `${process.env.LAT}°N ${process.env.LON}°E`; + const pressTrend = getPressureTendency(h.pressure_msl, hourIdx); + const isDebug = process.env.DEBUG_METEO === 'true'; + + // Comparaison J-1 + const yIdx = todayIdx - 1; + const hasY = yIdx >= 0 && d.time[yIdx]; + const sign = n => (+n > 0 ? `+${n}` : `${n}`); + const dTmax = hasY ? (d.temperature_2m_max[todayIdx] - d.temperature_2m_max[yIdx]).toFixed(1) : null; + const dRain = hasY ? (d.precipitation_sum[todayIdx] - d.precipitation_sum[yIdx]).toFixed(1) : null; + const dWind = hasY ? (d.wind_speed_10m_max[todayIdx] - d.wind_speed_10m_max[yIdx]).toFixed(1) : null; + + const alerts = checkAlerts(c, d); + const color = getEmbedColor(c.weather_code); + const upd = new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit', second:'2-digit', timeZone:'Europe/Paris' }); + + const embed = new EmbedBuilder() + .setTitle(`${getWeatherDescription(c.weather_code)} — ${location}`) + .setColor(color) + .setDescription( + `**${c.temperature_2m?.toFixed(1)}°C** · Ressenti **${c.apparent_temperature?.toFixed(1)}°C** · ${c.is_day ? '🌞 Jour' : '🌙 Nuit'}` + ) + // ── Températures ────────────────────────────────────────────────────────── + .addFields({ + name: '🌡️ Températures', + value: [ + `Actuelle : **${c.temperature_2m?.toFixed(1)}°C** (${celsiusToKelvin(c.temperature_2m)} K)`, + `Ressentie : **${c.apparent_temperature?.toFixed(1)}°C**`, + `Max / Min : **${d.temperature_2m_max[todayIdx]}°C** / **${d.temperature_2m_min[todayIdx]}°C**`, + `Humidité : **${c.relative_humidity_2m}%**`, + ].join('\n'), + inline: true, + }) + // ── Vent (noeuds + km/h) ────────────────────────────────────────────────── + .addFields({ + name: '💨 Vent', + value: [ + `Vitesse : **${knotsFromKmh(c.wind_speed_10m)} kt** (${c.wind_speed_10m?.toFixed(0)} km/h)`, + `Rafales : **${knotsFromKmh(c.wind_gusts_10m)} kt** (${c.wind_gusts_10m?.toFixed(0)} km/h)`, + `Direction : **${getWindDirection(c.wind_direction_10m)}** (${c.wind_direction_10m?.toFixed(0)}°)`, + `Max jour : **${knotsFromKmh(d.wind_speed_10m_max[todayIdx])} kt** (${d.wind_speed_10m_max[todayIdx]} km/h)`, + ].join('\n'), + inline: true, + }) + // ── Précipitations ──────────────────────────────────────────────────────── + .addFields({ + name: '🌧️ Précipitations', + value: [ + `Actuelle : **${c.precipitation?.toFixed(1)} mm/h**`, + `Probabilité: **${h.precipitation_probability[hourIdx] ?? 'N/A'}%**`, + `Total jour : **${d.precipitation_sum[todayIdx]?.toFixed(1)} mm**`, + `Neige : ${c.snowfall?.toFixed(1)} cm/h`, + ].join('\n'), + inline: true, + }) + // ── Atmosphère ──────────────────────────────────────────────────────────── + .addFields({ + name: '🔵 Atmosphère', + value: [ + `Pression : **${c.pressure_msl?.toFixed(1)} hPa** ${pressTrend}`, + `UV actuel : **${c.uv_index?.toFixed(1)}** — ${getUvLabel(c.uv_index)}`, + `UV max j. : **${d.uv_index_max[todayIdx]}**`, + ].join('\n'), + inline: true, + }) + // ── Nuages ──────────────────────────────────────────────────────────────── + .addFields({ + name: '☁️ Nuages', + value: [ + `Total : **${c.cloud_cover}%**`, + `Bas : ${h.cloud_cover_low[hourIdx]}%`, + `Moyen : ${h.cloud_cover_mid[hourIdx]}%`, + `Haut : ${h.cloud_cover_high[hourIdx]}%`, + ].join('\n'), + inline: true, + }) + // ── Visibilité ──────────────────────────────────────────────────────────── + .addFields({ + name: '👁️ Visibilité', + value: [ + `**${(c.visibility / 1000).toFixed(1)} km**`, + getVisibilityLabel(c.visibility), + ].join('\n'), + inline: true, + }) + // ── Astronomie ──────────────────────────────────────────────────────────── + .addFields({ + name: '🌅 Astronomie', + value: [ + `🌅 **${astro.sunrise}** · 🌇 **${astro.sunset}** · ☀️ Zénith **${astro.solar_noon}** · ⏱️ **${astro.day_length}**`, + ].join('\n'), + inline: false, + }) + // ── Lune ────────────────────────────────────────────────────────────────── + .addFields({ + name: '🌙 Phase lunaire', + value: [ + `${astro.moon.phaseEmoji} **${astro.moon.phaseName}**`, + `Illumination : **${astro.moon.illumination}%** · J+${astro.moon.daysInCycle}`, + ].join('\n'), + inline: true, + }); + + // ── Marine ──────────────────────────────────────────────────────────────── + if (marine) { + const mh = marine.hourly; + const { start } = getNextHoursWindow(mh.time); + const wH = mh.wave_height?.[start], wP = mh.wave_period?.[start]; + const wD = mh.wave_direction?.[start], sH = mh.swell_wave_height?.[start]; + if (wH != null) { + embed.addFields({ + name: '🌊 Marine', + value: [ + `Vagues : **${wH?.toFixed(2)} m** · Période : **${wP?.toFixed(1)} s**`, + `Direction : **${getWindDirection(wD)}** (${wD?.toFixed(0)}°)${sH != null ? ` · Houle : **${sH?.toFixed(2)} m**` : ''}`, + ].join('\n'), + inline: true, + }); + } + } + + // ── Comparaison J-1 ─────────────────────────────────────────────────────── + if (hasY && dTmax !== null) { + embed.addFields({ + name: '📊 Vs hier', + value: `Tmax ${sign(dTmax)}°C ${+dTmax>0?'↑':'↓'} · Pluie ${sign(dRain)} mm ${+dRain>0?'↑':'↓'} · Vent max ${sign(dWind)} km/h ${+dWind>0?'↑':'↓'}`, + inline: false, + }); + } + + // ── Alertes ─────────────────────────────────────────────────────────────── + if (alerts.length > 0) { + embed.addFields({ + name: '⚠️ Alertes', + value: alerts.map(a => `${a.lvl} ${a.msg}`).join('\n'), + inline: false, + }); + } + + // ── Debug ───────────────────────────────────────────────────────────────── + if (isDebug) { + embed.addFields({ + name: '🔧 Debug', + value: [ + `Cache : ${weather.fetchedAt?.toLocaleTimeString('fr-FR', { timeZone:'Europe/Paris' })}`, + `hourIdx : ${hourIdx} | todayIdx : ${todayIdx}`, + `Astro src : ${astro.source}`, + `Charts OK : ${Object.keys(charts).filter(k => Buffer.isBuffer(charts[k])).length}/8`, + ].join('\n'), + inline: false, + }); + } + + if (charts.errors?.length) { + embed.addFields({ name: '⚙️ Graphiques partiels', value: `${charts.errors.length} erreur(s)`, inline: false }); + } + + embed.setFooter({ text: `📡 Open-Meteo · Sunrise-Sunset.org · QuickChart.io | 🕐 ${upd}` }); + return embed; +} + +// ─── Envoi du rapport complet ───────────────────────────────────────────────── +async function sendWeatherReport(target, isInteraction = false) { + const { weather, marine, fetchedAt } = await fetchWeatherData(); + const astro = await fetchAstronomyData(process.env.LAT, process.env.LON, weather.daily); + const charts = await generateAllCharts(weather.hourly); + + const embed = buildWeatherEmbed({ ...weather, fetchedAt }, marine, astro, charts); + + const chartDefs = [ + { key:'temperature', name:'temperature.png' }, + { key:'wind', name:'wind.png' }, + { key:'rain', name:'precipitation.png'}, + { key:'pressure', name:'pressure.png' }, + { key:'clouds', name:'clouds.png' }, + { key:'uv', name:'uv.png' }, + { key:'solar', name:'solar.png' }, + { key:'tide', name:'tide.png' }, + ]; + const files = chartDefs + .filter(d => Buffer.isBuffer(charts[d.key])) + .map(d => new AttachmentBuilder(charts[d.key], { name: d.name })); + + const send = o => isInteraction ? target.editReply(o) : target.send(o); + const followUp = o => isInteraction ? target.followUp(o) : target.send(o); + + await send({ embeds: [embed] }); + + if (files.length > 0) { + await followUp({ content: `📊 **Graphiques sur 24h** (${files.length}/8)`, files }); + } + + // ── Image composite récapitulative ──────────────────────────────────────── + try { + const compositeBuffer = await generateCompositeImage({ weather: { weather, fetchedAt }, marine, astro, charts }); + await followUp({ + content: '🖼️ **Rapport récapitulatif — image partageable**', + files: [new AttachmentBuilder(compositeBuffer, { name: 'meteo_recap.png' })], + }); + } catch (err) { + console.warn('[composite] Génération échouée :', err.message); + // Pas d'erreur fatale si le composite rate + } +} + +// ─── Commande slash ─────────────────────────────────────────────────────────── +module.exports = { + data: new SlashCommandBuilder() + .setName('meteo') + .setDescription('Météo ultra-détaillée avec graphiques et image récapitulative'), + + async execute(interaction) { + await interaction.deferReply(); + try { + await sendWeatherReport(interaction, true); + } catch (err) { + console.error('[/meteo]', err); + const msg = `❌ **Erreur**\n\`\`\`${err.message}\`\`\``; + await (interaction.deferred ? interaction.editReply({ content: msg }) : interaction.reply({ content: msg, ephemeral: true })); + } + }, + + sendWeatherReport, +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..b94d858 --- /dev/null +++ b/index.js @@ -0,0 +1,118 @@ +'use strict'; + +require('dotenv').config(); + +const { Client, GatewayIntentBits, Collection, REST, Routes } = require('discord.js'); +const cron = require('node-cron'); +const path = require('path'); +const fs = require('fs'); + +// ─── Validation des variables d'environnement ───────────────────────────────── +const REQUIRED_ENV = ['DISCORD_TOKEN', 'CHANNEL_ID', 'LAT', 'LON']; +for (const key of REQUIRED_ENV) { + if (!process.env[key]) { + console.error(`❌ Variable d'environnement manquante : ${key}`); + process.exit(1); + } +} + +// ─── Client Discord ─────────────────────────────────────────────────────────── +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); +client.commands = new Collection(); + +// ─── Chargement des commandes ───────────────────────────────────────────────── +const commandsDir = path.join(__dirname, 'commands'); +for (const file of fs.readdirSync(commandsDir).filter(f => f.endsWith('.js'))) { + const cmd = require(path.join(commandsDir, file)); + if (!cmd.data || !cmd.execute) { + console.warn(`⚠️ Commande ignorée (structure invalide) : ${file}`); + continue; + } + client.commands.set(cmd.data.name, cmd); + console.log(`✅ Commande chargée : /${cmd.data.name}`); +} + +// ─── Prêt ───────────────────────────────────────────────────────────────────── +client.once('ready', async () => { + console.log(`\n🤖 Connecté en tant que ${client.user.tag}`); + console.log(`📍 Localisation : ${process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`}`); + console.log(`⏰ Message quotidien : ${process.env.SEND_HOUR}h${String(process.env.SEND_MINUTE || 0).padStart(2,'0')}\n`); + + // ── Enregistrement global des commandes slash ────────────────────────────── + const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN); + const payload = [...client.commands.values()].map(c => c.data.toJSON()); + + try { + console.log('📡 Enregistrement des commandes slash (global)…'); + await rest.put(Routes.applicationCommands(client.user.id), { body: payload }); + console.log(`✅ ${payload.length} commande(s) enregistrée(s)\n`); + } catch (err) { + console.error('❌ Erreur enregistrement commandes :', err.message); + } + + // ── Cron : message météo quotidien ──────────────────────────────────────── + const hour = parseInt(process.env.SEND_HOUR, 10) || 7; + const minute = parseInt(process.env.SEND_MINUTE, 10) || 30; + const cronExpr = `${minute} ${hour} * * *`; + + cron.schedule(cronExpr, async () => { + console.log(`\n⏰ [${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}] Envoi du rapport météo quotidien…`); + + // Invalider le cache pour forcer un rafraîchissement + require('./services/weather').invalidateCache(); + require('./services/astronomy').invalidateAstroCache(); + + try { + const channel = await client.channels.fetch(process.env.CHANNEL_ID); + if (!channel?.isTextBased()) { + console.error('❌ Channel introuvable ou non textuel :', process.env.CHANNEL_ID); + return; + } + const { sendWeatherReport } = require('./commands/meteo'); + await sendWeatherReport(channel, false); + console.log('✅ Rapport météo quotidien envoyé.'); + } catch (err) { + console.error('❌ Erreur envoi quotidien :', err.message); + } + }, { timezone: 'Europe/Paris' }); + + console.log(`⏰ Cron programmé : "${cronExpr}" (Europe/Paris)`); +}); + +// ─── Gestion des interactions ───────────────────────────────────────────────── +client.on('interactionCreate', async interaction => { + if (!interaction.isChatInputCommand()) return; + + const command = client.commands.get(interaction.commandName); + if (!command) { + console.warn(`⚠️ Commande inconnue reçue : ${interaction.commandName}`); + return; + } + + try { + await command.execute(interaction); + } catch (err) { + console.error(`❌ Erreur commande /${interaction.commandName} :`, err); + const errMsg = { content: `❌ Une erreur inattendue est survenue.`, ephemeral: true }; + if (interaction.replied || interaction.deferred) { + await interaction.followUp(errMsg).catch(() => {}); + } else { + await interaction.reply(errMsg).catch(() => {}); + } + } +}); + +// ─── Erreurs non gérées ─────────────────────────────────────────────────────── +process.on('unhandledRejection', (reason, promise) => { + console.error('⚠️ UnhandledRejection :', reason); +}); + +process.on('uncaughtException', (err) => { + console.error('💥 UncaughtException :', err); +}); + +// ─── Connexion ──────────────────────────────────────────────────────────────── +client.login(process.env.DISCORD_TOKEN).catch(err => { + console.error('❌ Connexion Discord échouée :', err.message); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6a83eb3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,945 @@ +{ + "name": "discord-meteo-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-meteo-bot", + "version": "1.0.0", + "dependencies": { + "@napi-rs/canvas": "^0.1.53", + "axios": "^1.6.8", + "discord.js": "^14.14.1", + "dotenv": "^16.4.5", + "node-cron": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.49", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz", + "integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.4", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz", + "integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b9516ab --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "discord-meteo-bot", + "version": "1.0.0", + "description": "Bot Discord météo ultra-détaillé avec graphiques — Open-Meteo API", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js" + }, + "dependencies": { + "@napi-rs/canvas": "^0.1.53", + "axios": "^1.6.8", + "discord.js": "^14.14.1", + "dotenv": "^16.4.5", + "node-cron": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/services/astronomy.js b/services/astronomy.js new file mode 100644 index 0000000..ae187e5 --- /dev/null +++ b/services/astronomy.js @@ -0,0 +1,232 @@ +'use strict'; + +const axios = require('axios'); + +// ─── Cache journalier ───────────────────────────────────────────────────────── +let astroCache = { data: null, date: null }; + +// ─── Calcul de la phase lunaire (algorithme Julian Date) ───────────────────── +function getMoonPhase(date = new Date()) { + // Conversion en Jour Julien (JD) + let y = date.getFullYear(); + let m = date.getMonth() + 1; + const d = date.getDate(); + + if (m <= 2) { y -= 1; m += 12; } + const A = Math.floor(y / 100); + const B = 2 - A + Math.floor(A / 4); + const jd = Math.floor(365.25 * (y + 4716)) + + Math.floor(30.6001 * (m + 1)) + + d + B - 1524.5; + + // Nouvelle lune de référence : 6 janvier 2000 à 18h14 UTC → JD 2451549.759 + const REF_NEW_MOON = 2451549.759; + const SYNODIC_PERIOD = 29.53058867; // jours + + let daysSinceNew = (jd - REF_NEW_MOON) % SYNODIC_PERIOD; + if (daysSinceNew < 0) daysSinceNew += SYNODIC_PERIOD; + + const fraction = daysSinceNew / SYNODIC_PERIOD; + + // Illumination (0–100 %) + const illumination = Math.round((1 - Math.cos(2 * Math.PI * fraction)) / 2 * 100); + + // Nom & emoji de la phase + let phaseName, phaseEmoji; + if (fraction < 0.0625) { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; } + else if (fraction < 0.1875) { phaseName = 'Croissant montant'; phaseEmoji = '🌒'; } + else if (fraction < 0.3125) { phaseName = 'Premier quartier'; phaseEmoji = '🌓'; } + else if (fraction < 0.4375) { phaseName = 'Gibbeuse croissante'; phaseEmoji = '🌔'; } + else if (fraction < 0.5625) { phaseName = 'Pleine lune'; phaseEmoji = '🌕'; } + else if (fraction < 0.6875) { phaseName = 'Gibbeuse décroissante'; phaseEmoji = '🌖'; } + else if (fraction < 0.8125) { phaseName = 'Dernier quartier'; phaseEmoji = '🌗'; } + else if (fraction < 0.9375) { phaseName = 'Croissant décroissant'; phaseEmoji = '🌘'; } + else { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; } + + return { phaseName, phaseEmoji, illumination, daysInCycle: daysSinceNew.toFixed(1) }; +} + +// ─── Formatage d'une heure UTC ISO → heure Paris HH:MM ────────────────────── +function fmtTime(isoString) { + if (!isoString) return 'N/A'; + return new Date(isoString).toLocaleTimeString('fr-FR', { + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Paris', + }); +} + +// ─── Durée en secondes → "Xh YYmin" ───────────────────────────────────────── +function fmtDuration(seconds) { + if (!seconds) return 'N/A'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${String(m).padStart(2, '0')}min`; +} + +// ─── Fetch astronomie (Sunrise-Sunset API + fallback Open-Meteo) ────────────── +async function fetchAstronomyData(lat, lon, fallbackDaily = null) { + const todayStr = new Date().toLocaleDateString('sv', { timeZone: 'Europe/Paris' }); + + if (astroCache.data && astroCache.date === todayStr) { + return astroCache.data; + } + + const moon = getMoonPhase(new Date()); + let result; + + try { + const res = await axios.get('https://api.sunrise-sunset.org/json', { + params: { lat, lng: lon, date: todayStr, formatted: 0 }, + timeout: 6000, + }); + + const r = res.data.results; + result = { + sunrise: fmtTime(r.sunrise), + sunset: fmtTime(r.sunset), + solar_noon: fmtTime(r.solar_noon), + day_length: fmtDuration(r.day_length), + moon, + source: 'Sunrise-Sunset.org', + }; + } catch (err) { + console.warn(`[astro] Sunrise-Sunset API indisponible (${err.message}), fallback Open-Meteo`); + + // Fallback : Open-Meteo daily contient sunrise/sunset (en heure locale ISO) + if (fallbackDaily) { + const todayIdx = fallbackDaily.time.findIndex(d => d === todayStr); + const i = todayIdx >= 0 ? todayIdx : 0; + const sr = fallbackDaily.sunrise?.[i]; + const ss = fallbackDaily.sunset?.[i]; + + // Open-Meteo renvoie "2024-06-10T06:24" (heure locale), pas UTC + const fmtLocal = (s) => s ? s.substring(11, 16) : 'N/A'; + + let dayLengthSec = null; + if (sr && ss) { + const [sh, sm] = fmtLocal(sr).split(':').map(Number); + const [eh, em] = fmtLocal(ss).split(':').map(Number); + dayLengthSec = (eh * 60 + em - sh * 60 - sm) * 60; + } + + result = { + sunrise: fmtLocal(sr), + sunset: fmtLocal(ss), + solar_noon: 'N/A', + day_length: fmtDuration(dayLengthSec), + moon, + source: 'Open-Meteo (fallback)', + }; + } else { + result = { + sunrise: 'N/A', sunset: 'N/A', solar_noon: 'N/A', day_length: 'N/A', + moon, + source: 'Indisponible', + }; + } + } + + astroCache = { data: result, date: todayStr }; + return result; +} + +// ─── Invalidation du cache astronomie ──────────────────────────────────────── +function invalidateAstroCache() { + astroCache = { data: null, date: null }; +} + +// ─── Calcul de l'élévation solaire (algorithme NOAA simplifié) ─────────────── +function getSolarElevation(lat, lon, dateUTC) { + const toRad = d => d * Math.PI / 180; + const toDeg = r => r * 180 / Math.PI; + + const y = dateUTC.getUTCFullYear(); + const m = dateUTC.getUTCMonth() + 1; + const dd = dateUTC.getUTCDate(); + const hh = dateUTC.getUTCHours() + dateUTC.getUTCMinutes() / 60; + + // Julian Day + let yj = y, mj = m; + if (mj <= 2) { yj -= 1; mj += 12; } + const A = Math.floor(yj / 100); + const B = 2 - A + Math.floor(A / 4); + const JD = Math.floor(365.25 * (yj + 4716)) + Math.floor(30.6001 * (mj + 1)) + dd + B - 1524.5 + hh / 24; + const n = JD - 2451545.0; // jours depuis J2000 + + // Longitude du Soleil & anomalie moyenne + const L = ((280.460 + 0.9856474 * n) % 360 + 360) % 360; + const g = toRad(((357.528 + 0.9856003 * n) % 360 + 360) % 360); + + // Longitude écliptique + const lambda = toRad(L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g)); + + // Obliquité de l'écliptique + const eps = toRad(23.439 - 0.0000004 * n); + + // Déclinaison & ascension droite + const sinDec = Math.sin(eps) * Math.sin(lambda); + const dec = Math.asin(sinDec); + const RA_h = toDeg(Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda))) / 15; + + // Temps sidéral de Greenwich (heures) + const GMST = ((6.697375 + 0.0657098242 * n + hh) % 24 + 24) % 24; + + // Angle horaire local + const LST = (GMST + lon / 15 + 24) % 24; + const H = toRad((LST - RA_h) * 15); + + // Altitude (élévation) + const latR = toRad(parseFloat(lat)); + const sinAlt = Math.sin(latR) * Math.sin(dec) + Math.cos(latR) * Math.cos(dec) * Math.cos(H); + return toDeg(Math.asin(Math.max(-1, Math.min(1, sinAlt)))); +} + +// Tableau 25 valeurs horaires (élévation, peut être négatif = nuit) +function getSolarElevationForDay(lat, lon, startTime, count = 25) { + const out = []; + for (let i = 0; i < count; i++) { + const t = new Date(startTime.getTime() + i * 3600_000); + out.push(parseFloat(getSolarElevation(lat, lon, t).toFixed(1))); + } + return out; +} + +// ─── Marées harmoniques (côte atlantique française — approximation) ─────────── +// Constituants calibrés sur la zone Lacanau-Arcachon (SHOM approx.) +const TIDAL_CONSTITUENTS = [ + // [amplitude_m, vitesse_°/h, phase_°] + [1.42, 28.9841042, 288], // M2 - Lunaire semi-diurne principal + [0.50, 30.0000000, 330], // S2 - Solaire semi-diurne principal + [0.28, 28.4397295, 270], // N2 - Lunaire elliptique majeure + [0.10, 15.0410686, 48 ], // K1 - Luni-solaire diurne + [0.09, 13.9430356, 358], // O1 - Lunaire diurne principal + [0.05, 30.0821373, 335], // K2 - Luni-solaire semi-diurne + [0.04, 28.5125831, 272], // L2 - Lunaire elliptique mineure +]; +const J2000_MS = 946727935816; // 1er jan 2000 12h00 UTC en ms + +function calculateTideHeight(timestampMs) { + const t = (timestampMs - J2000_MS) / 3_600_000; // heures depuis J2000 + const toRad = d => d * Math.PI / 180; + return TIDAL_CONSTITUENTS.reduce((h, [amp, speed, phase]) => { + return h + amp * Math.cos(toRad(speed * t - phase)); + }, 0); +} + +function getTideForDay(startTime, count = 25) { + return Array.from({ length: count }, (_, i) => { + const t = new Date(startTime.getTime() + i * 3_600_000); + return parseFloat(calculateTideHeight(t.getTime()).toFixed(2)); + }); +} + +module.exports = { + fetchAstronomyData, + getMoonPhase, + invalidateAstroCache, + getSolarElevation, + getSolarElevationForDay, + calculateTideHeight, + getTideForDay, +}; diff --git a/services/charts.js b/services/charts.js new file mode 100644 index 0000000..a0441e2 --- /dev/null +++ b/services/charts.js @@ -0,0 +1,255 @@ +'use strict'; + +const axios = require('axios'); +const { getSolarElevationForDay, getTideForDay } = require('./astronomy'); + +const QC_URL = 'https://quickchart.io/chart'; +const BG = 'rgb(20,20,32)'; +const GRID = 'rgba(255,255,255,0.07)'; +const TICK = '#BBBBCC'; +const TITLE = '#FFFFFF'; +const LEG = '#CCCCDD'; + +// ─── Heure courante Paris → préfixe "YYYY-MM-DDTHH" ───────────────────────── +function nowParisPrefix() { + return new Date() + .toLocaleString('sv', { timeZone: 'Europe/Paris' }) + .substring(0, 13) + .replace(' ', 'T'); +} + +// ─── Fenêtre de N heures à partir de maintenant ────────────────────────────── +function getWindow(hourlyTimes, count = 25) { + const prefix = nowParisPrefix(); + let start = 0; + for (let i = 0; i < hourlyTimes.length; i++) { + if (hourlyTimes[i].startsWith(prefix)) { start = i; break; } + if (hourlyTimes[i] < prefix) start = i; + } + return { start, end: Math.min(start + count, hourlyTimes.length), startIdx: start }; +} + +// ─── Heure depuis "YYYY-MM-DDTHH:MM" ──────────────────────────────────────── +const hl = t => t.substring(11, 16); + +// ─── POST QuickChart (config JS string = supporte les callbacks) ────────────── +async function postChart(chartJsStr) { + const res = await axios.post(QC_URL, { + chart: chartJsStr, + width: 760, height: 295, + backgroundColor: BG, + format: 'png', version: '3', + }, { responseType: 'arraybuffer', timeout: 20000 }); + return Buffer.from(res.data); +} + +// ─── Options communes ───────────────────────────────────────────────────────── +function opts({ title, yUnit = '', y1 = null, yMin = null, yMax = null }) { + const yMinStr = yMin !== null ? `,min:${yMin}` : ''; + const yMaxStr = yMax !== null ? `,max:${yMax}` : ''; + const y1block = y1 + ? `,y1:{position:'right',min:0,max:100,grid:{drawOnChartArea:false},ticks:{color:'${y1.color}',callback:function(v){return v+'${y1.unit}';}}}` + : ''; + return `{ + plugins:{ + title:{display:true,text:'${title}',color:'${TITLE}',font:{size:14,weight:'bold'}}, + legend:{labels:{color:'${LEG}',font:{size:11}}} + }, + scales:{ + x:{ticks:{color:'${TICK}',maxRotation:45,font:{size:10}},grid:{color:'${GRID}'}}, + y:{ticks:{color:'${TICK}',callback:function(v){return v+'${yUnit}';}},grid:{color:'${GRID}'},position:'left'${yMinStr}${yMaxStr}} + ${y1block} + } + }`; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 1. TEMPÉRATURES +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateTemperatureChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + const temps = JSON.stringify(hourly.temperature_2m.slice(start, end).map(v => v?.toFixed(1) ?? null)); + const feels = JSON.stringify(hourly.apparent_temperature.slice(start, end).map(v => v?.toFixed(1) ?? null)); + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Température',data:${temps},borderColor:'rgb(255,107,53)',backgroundColor:'rgba(255,107,53,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}, + {label:'Ressenti',data:${feels},borderColor:'rgb(255,200,70)',backgroundColor:'rgba(255,200,70,0.05)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[5,4]} + ]},options:${opts({ title: '🌡️ Températures sur 24h', yUnit: '°C' })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 2. VENT (noeuds + km/h en tooltip) +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateWindChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + + // Convertir en noeuds pour l'axe Y, garder km/h en label dataset + const windKt = JSON.stringify(hourly.wind_speed_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null)); + const gustsKt = JSON.stringify(hourly.wind_gusts_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null)); + + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Vent moy. (kt)',data:${windKt},borderColor:'rgb(0,191,255)',backgroundColor:'rgba(0,191,255,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}, + {label:'Rafales (kt)',data:${gustsKt},borderColor:'rgb(120,140,255)',backgroundColor:'rgba(120,140,255,0.08)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[6,3]} + ]},options:${opts({ title: '💨 Vent sur 24h', yUnit: ' kt' })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 3. PRÉCIPITATIONS +// ═══════════════════════════════════════════════════════════════════════════════ +async function generatePrecipitationChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + const precip = JSON.stringify(hourly.precipitation.slice(start, end).map(v => v?.toFixed(2) ?? 0)); + const prob = JSON.stringify(hourly.precipitation_probability.slice(start, end)); + return postChart(`{type:'bar',data:{labels:${labels},datasets:[ + {label:'Précip. (mm)',data:${precip},backgroundColor:'rgba(30,144,255,0.75)',borderColor:'rgb(30,144,255)',borderWidth:1,yAxisID:'y'}, + {label:'Probabilité (%)',data:${prob},type:'line',borderColor:'rgb(0,210,210)',backgroundColor:'rgba(0,210,210,0.1)',fill:false,tension:0.4,borderWidth:2,pointRadius:2,yAxisID:'y1'} + ]},options:${opts({ title: '🌧️ Précipitations sur 24h', yUnit: ' mm', yMin: 0, y1: { color: '#00D2D2', unit: '%' } })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 4. PRESSION +// ═══════════════════════════════════════════════════════════════════════════════ +async function generatePressureChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + const pressure = JSON.stringify(hourly.pressure_msl.slice(start, end).map(v => v?.toFixed(1) ?? null)); + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Pression (hPa)',data:${pressure},borderColor:'rgb(147,112,219)',backgroundColor:'rgba(147,112,219,0.22)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5} + ]},options:${opts({ title: '🔵 Pression atmosphérique sur 24h', yUnit: ' hPa' })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 5. NUAGES +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateCloudChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + const total = JSON.stringify(hourly.cloud_cover.slice(start, end)); + const low = JSON.stringify(hourly.cloud_cover_low.slice(start, end)); + const mid = JSON.stringify(hourly.cloud_cover_mid.slice(start, end)); + const high = JSON.stringify(hourly.cloud_cover_high.slice(start, end)); + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Total',data:${total},borderColor:'rgb(160,160,160)',backgroundColor:'rgba(160,160,160,0.2)',fill:true,tension:0.4,pointRadius:2,borderWidth:2.5}, + {label:'Bas',data:${low},borderColor:'rgb(176,196,222)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[4,3]}, + {label:'Moyen',data:${mid},borderColor:'rgb(100,180,220)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[6,3]}, + {label:'Haut',data:${high},borderColor:'rgb(200,230,255)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[8,4]} + ]},options:${opts({ title: '☁️ Couverture nuageuse sur 24h', yUnit: '%', yMin: 0, yMax: 100 })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 6. UV INDEX +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateUvChart(hourly) { + const { start, end } = getWindow(hourly.time); + const labels = JSON.stringify(hourly.time.slice(start, end).map(hl)); + const uv = JSON.stringify(hourly.uv_index.slice(start, end).map(v => v?.toFixed(1) ?? 0)); + + // Zones de danger (annotations via dataset fill + background colors par zone) + return postChart(`{type:'bar',data:{labels:${labels},datasets:[ + {label:'UV Index',data:${uv},backgroundColor:function(ctx){ + const v=ctx.dataset.data[ctx.dataIndex]; + if(v>=11)return 'rgba(148,0,211,0.85)'; + if(v>=8)return 'rgba(255,0,0,0.80)'; + if(v>=6)return 'rgba(255,140,0,0.82)'; + if(v>=3)return 'rgba(255,215,0,0.82)'; + return 'rgba(76,175,80,0.82)'; + },borderColor:'rgba(0,0,0,0)',borderWidth:0,borderRadius:3} + ]},options:${opts({ title: '☀️ UV Index sur 24h', yUnit: '', yMin: 0 })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 7. ÉLÉVATION SOLAIRE +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateSolarElevationChart(hourlyTimes) { + const lat = parseFloat(process.env.LAT); + const lon = parseFloat(process.env.LON); + const { start, end, startIdx } = getWindow(hourlyTimes); + const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl)); + + // Reconstruire les dates UTC depuis les heures Paris + // Open-Meteo renvoie l'heure locale Paris sans offset → on applique l'offset manuellement + const parisOffsetH = getParisOffsetHours(); + const startTimeUTC = new Date( + new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000 + ); + + const elevations = JSON.stringify(getSolarElevationForDay(lat, lon, startTimeUTC, end - start)); + + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Élévation (°)',data:${elevations},borderColor:'rgb(255,220,50)',backgroundColor:function(ctx){ + const v=ctx.dataset.data[ctx.dataIndex]; + if(v<=0)return 'rgba(0,0,50,0.4)'; + if(v<15)return 'rgba(255,120,30,0.3)'; + return 'rgba(255,220,50,0.25)'; + },fill:true,tension:0.4,pointRadius:2,borderWidth:2.5} + ]},options:${opts({ title: '⬆️ Elevation solaire sur 24h', yUnit: '°', yMin: -10 })}}`); +} + +// Offset UTC de Paris en heures (gère l'heure d'été) +function getParisOffsetHours() { + const now = new Date(); + const paris = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Paris' })); + return (paris - new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))) / 3_600_000; +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// 8. MARÉES (calcul harmonique approximatif) +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateTideChart(hourlyTimes) { + const { start, end } = getWindow(hourlyTimes); + const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl)); + + // Heure de départ (heure locale Paris → UTC) + const parisOffsetH = getParisOffsetHours(); + const startTimeUTC = new Date( + new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000 + ); + + const tides = JSON.stringify(getTideForDay(startTimeUTC, end - start)); + + return postChart(`{type:'line',data:{labels:${labels},datasets:[ + {label:'Hauteur marée (m)',data:${tides},borderColor:'rgb(0,180,216)',backgroundColor:'rgba(0,180,216,0.20)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5} + ]},options:${opts({ title: '🌊 Marees sur 24h (estimation harmonique)', yUnit: ' m' })}}`); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// GÉNÉRATION PARALLÈLE DES 8 GRAPHIQUES +// ═══════════════════════════════════════════════════════════════════════════════ +async function generateAllCharts(hourly) { + const keys = ['temperature','wind','rain','pressure','clouds','uv','solar','tide']; + const jobs = [ + generateTemperatureChart(hourly), + generateWindChart(hourly), + generatePrecipitationChart(hourly), + generatePressureChart(hourly), + generateCloudChart(hourly), + generateUvChart(hourly), + generateSolarElevationChart(hourly.time), + generateTideChart(hourly.time), + ]; + + const results = await Promise.allSettled(jobs); + const out = { errors: [] }; + results.forEach((r, i) => { + if (r.status === 'fulfilled') out[keys[i]] = r.value; + else { + out[keys[i]] = null; + out.errors.push(`${keys[i]}: ${r.reason?.message}`); + } + }); + return out; +} + +module.exports = { + generateAllCharts, + generateTemperatureChart, + generateWindChart, + generatePrecipitationChart, + generatePressureChart, + generateCloudChart, + generateUvChart, + generateSolarElevationChart, + generateTideChart, +}; diff --git a/services/composite.js b/services/composite.js new file mode 100644 index 0000000..e576d57 --- /dev/null +++ b/services/composite.js @@ -0,0 +1,316 @@ +'use strict'; + +// Chargement dynamique pour éviter un crash si @napi-rs/canvas n'est pas installé +let createCanvas, loadImage, GlobalFonts; +try { + ({ createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas')); + try { GlobalFonts.loadSystemFonts(); } catch (_) {} +} catch (e) { + console.warn('[composite] @napi-rs/canvas non disponible — image composite désactivée'); +} + +const { + getWeatherDescription, getWindDirection, getVisibilityLabel, + getUvLabel, knotsFromKmh, getNextHoursWindow, getTodayDailyIndex, +} = require('./weather'); + +// ─── Palette ────────────────────────────────────────────────────────────────── +const C = { + bg: '#0f172a', + section: '#1e293b', + card: '#243449', + border: '#334155', + text: '#e2e8f0', + dim: '#94a3b8', + bright: '#f8fafc', + accent: '#38bdf8', + orange: '#fb923c', + green: '#4ade80', + yellow: '#fbbf24', + marine: '#1e3a5c', + marineB: '#2a5a8c', + marineT: '#93c5fd', +}; + +const W = 1860; +const PAD = 22; +const GUTTER = 12; + +// ─── Helpers canvas ─────────────────────────────────────────────────────────── +function roundRect(ctx, x, y, w, h, r = 10) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +function fillCard(ctx, x, y, w, h, color = C.card, borderColor = C.border, r = 10) { + roundRect(ctx, x, y, w, h, r); + ctx.fillStyle = color; + ctx.fill(); + roundRect(ctx, x, y, w, h, r); + ctx.strokeStyle = borderColor; + ctx.lineWidth = 1; + ctx.stroke(); +} + +function txt(ctx, str, x, y, { size = 16, weight = 'normal', color = C.text, align = 'left', max } = {}) { + ctx.save(); + ctx.font = `${weight} ${size}px sans-serif`; + ctx.fillStyle = color; + ctx.textAlign = align; + if (max) ctx.fillText(String(str ?? ''), x, y, max); + else ctx.fillText(String(str ?? ''), x, y); + ctx.restore(); +} + +function hline(ctx, y, opacity = 0.5) { + ctx.save(); + ctx.strokeStyle = C.border; + ctx.globalAlpha = opacity; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(PAD, y); + ctx.lineTo(W - PAD, y); + ctx.stroke(); + ctx.restore(); +} + +function row(ctx, label, value, x, y, valColor = C.accent) { + txt(ctx, label, x, y, { size: 14, color: C.dim }); + txt(ctx, value ?? 'N/A', x + 175, y, { size: 14, weight: 'bold', color: valColor, max: 240 }); +} + +// ─── Génération de l'image composite ───────────────────────────────────────── +async function generateCompositeImage({ weather: wd, marine, astro, charts }) { + if (!createCanvas) throw new Error('@napi-rs/canvas non installé'); + + const w = wd.weather; + const c = w.current; + const h = w.hourly; + const d = w.daily; + const loc = process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`; + + const todayIdx = getTodayDailyIndex(d.time); + const { start: hIdx } = getNextHoursWindow(h.time); + + // ── Layout dynamique ──────────────────────────────────────────────────────── + const HEADER_H = 130; + const SEP = 14; + const DATA_H = 310; // 2 lignes × 3 cartes + const ASTRO_H = 76; + const MARINE_H = marine ? 58 : 0; + const ALERT_H = 0; // géré dans DATA section si besoin + const CHART_H = 280; + const CHART_ROWS = 4; // 8 graphiques / 2 colonnes + const FOOTER_H = 52; + + const TOTAL_H = PAD + HEADER_H + SEP + DATA_H + SEP + ASTRO_H + (MARINE_H ? MARINE_H + GUTTER : 0) + + SEP + CHART_ROWS * (CHART_H + GUTTER) + SEP + FOOTER_H + PAD; + + const canvas = createCanvas(W, TOTAL_H); + const ctx = canvas.getContext('2d'); + + // ── Fond ──────────────────────────────────────────────────────────────────── + ctx.fillStyle = C.bg; + ctx.fillRect(0, 0, W, TOTAL_H); + + // Gradient haut + const grd = ctx.createLinearGradient(0, 0, 0, 220); + grd.addColorStop(0, 'rgba(56,189,248,0.07)'); + grd.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, W, 220); + + let Y = PAD; + + // ── HEADER ────────────────────────────────────────────────────────────────── + // Condition météo + txt(ctx, getWeatherDescription(c.weather_code), PAD, Y + 36, { size: 26, weight: 'bold', color: C.bright }); + txt(ctx, loc, PAD, Y + 64, { size: 17, color: C.dim }); + + // Température (droite) + txt(ctx, `${c.temperature_2m?.toFixed(1)}°C`, W - PAD, Y + 44, { size: 52, weight: 'bold', color: C.accent, align: 'right' }); + txt(ctx, `Ressenti ${c.apparent_temperature?.toFixed(1)}°C`, W - PAD, Y + 74, { size: 16, color: C.dim, align: 'right' }); + + // Date / heure + const now = new Date(); + const dateStr = now.toLocaleDateString('fr-FR', { weekday:'long', year:'numeric', month:'long', day:'numeric', timeZone:'Europe/Paris' }); + const timeStr = now.toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit', timeZone:'Europe/Paris' }); + txt(ctx, `${dateStr} — ${timeStr}`, PAD, Y + 100, { size: 14, color: C.dim }); + + // Ligne sous header + Y += HEADER_H; + hline(ctx, Y); + Y += SEP; + + // ── DATA CARDS (2 lignes × 3 colonnes) ───────────────────────────────────── + const CW = (W - PAD * 2 - GUTTER * 2) / 3; + const CH = (DATA_H - GUTTER) / 2; + const R2 = Y + CH + GUTTER; + + const cells = [ + // Ligne 1 + { + title: 'Temperatures', + items: [ + ['Actuelle', `${c.temperature_2m?.toFixed(1)}C (${(c.temperature_2m + 273.15).toFixed(0)} K)`], + ['Ressentie', `${c.apparent_temperature?.toFixed(1)}C`], + ['Max / Min', `${d.temperature_2m_max[todayIdx]}C / ${d.temperature_2m_min[todayIdx]}C`], + ['Humidite', `${c.relative_humidity_2m}%`], + ] + }, + { + title: 'Vent', + items: [ + ['Vitesse', `${knotsFromKmh(c.wind_speed_10m)} kt (${c.wind_speed_10m?.toFixed(0)} km/h)`], + ['Rafales', `${knotsFromKmh(c.wind_gusts_10m)} kt (${c.wind_gusts_10m?.toFixed(0)} km/h)`], + ['Direction', `${getWindDirection(c.wind_direction_10m)} (${c.wind_direction_10m?.toFixed(0)}deg)`], + ['Max jour', `${knotsFromKmh(d.wind_speed_10m_max[todayIdx])} kt`], + ] + }, + { + title: 'Precipitations', + items: [ + ['Actuelle', `${c.precipitation?.toFixed(1)} mm/h`], + ['Probabilite', `${h.precipitation_probability[hIdx] ?? 'N/A'}%`], + ['Total jour', `${d.precipitation_sum[todayIdx]?.toFixed(1)} mm`], + ['Neige', `${c.snowfall?.toFixed(1)} cm/h`], + ] + }, + // Ligne 2 + { + title: 'Atmosphere', + items: [ + ['Pression', `${c.pressure_msl?.toFixed(1)} hPa`], + ['UV actuel', `${c.uv_index?.toFixed(1)} ${getUvLabel(c.uv_index)}`], + ['UV max', `${d.uv_index_max[todayIdx]}`], + ] + }, + { + title: 'Couverture nuageuse', + items: [ + ['Total', `${c.cloud_cover}%`], + ['Bas / Moy', `${h.cloud_cover_low[hIdx]}% / ${h.cloud_cover_mid[hIdx]}%`], + ['Haut', `${h.cloud_cover_high[hIdx]}%`], + ] + }, + { + title: 'Visibilite & UV', + items: [ + ['Distance', `${(c.visibility / 1000).toFixed(1)} km`], + ['Qualite', getVisibilityLabel(c.visibility)], + ['UV max j.', `${d.uv_index_max[todayIdx]}`], + ] + }, + ]; + + for (let i = 0; i < 6; i++) { + const col = i % 3; + const ligne = Math.floor(i / 3); + const cx = PAD + col * (CW + GUTTER); + const cy = Y + ligne * (CH + GUTTER); + + fillCard(ctx, cx, cy, CW, CH); + + // Titre carte + txt(ctx, cells[i].title, cx + 14, cy + 24, { size: 14, weight: 'bold', color: C.bright }); + + // Séparateur titre + ctx.save(); + ctx.strokeStyle = C.border; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cx + 14, cy + 32); + ctx.lineTo(cx + CW - 14, cy + 32); + ctx.stroke(); + ctx.restore(); + + // Données + cells[i].items.forEach((item, j) => { + row(ctx, item[0], item[1], cx + 14, cy + 54 + j * 26); + }); + } + + Y += DATA_H; + hline(ctx, Y); + Y += SEP; + + // ── BANDE ASTRONOMIE ──────────────────────────────────────────────────────── + fillCard(ctx, PAD, Y, W - PAD * 2, ASTRO_H - GUTTER, C.card, C.border, 10); + + const astroItems = [ + ['Lever soleil', astro.sunrise], + ['Coucher', astro.sunset], + ['Zenith', astro.solar_noon], + ['Duree du jour', astro.day_length], + ['Lune', `${astro.moon.phaseEmoji} ${astro.moon.phaseName} ${astro.moon.illumination}%`], + ]; + const aColW = (W - PAD * 4) / astroItems.length; + astroItems.forEach((item, i) => { + const ax = PAD * 2 + i * aColW; + txt(ctx, item[0], ax, Y + 22, { size: 12, color: C.dim }); + txt(ctx, item[1], ax, Y + 44, { size: 15, weight: 'bold', color: C.bright, max: aColW - 8 }); + }); + + Y += ASTRO_H; + + // ── BANDE MARINE ──────────────────────────────────────────────────────────── + if (marine && MARINE_H) { + const mh = marine.hourly; + const { start: ms } = getNextHoursWindow(mh.time); + const wH = mh.wave_height?.[ms]; + const wP = mh.wave_period?.[ms]; + const wD = mh.wave_direction?.[ms]; + const sH = mh.swell_wave_height?.[ms]; + + if (wH != null) { + fillCard(ctx, PAD, Y, W - PAD * 2, MARINE_H - GUTTER, C.marine, C.marineB, 10); + const marineStr = [ + `Vagues : ${wH?.toFixed(2)} m`, + `Periode : ${wP?.toFixed(1)} s`, + `Direction : ${getWindDirection(wD)} (${wD?.toFixed(0)}deg)`, + sH != null ? `Houle : ${sH?.toFixed(2)} m` : '', + ].filter(Boolean).join(' | '); + txt(ctx, marineStr, PAD * 2, Y + 28, { size: 15, weight: 'bold', color: C.marineT }); + } + Y += MARINE_H; + } + + hline(ctx, Y); + Y += SEP; + + // ── GRILLE DE GRAPHIQUES (2 colonnes) ─────────────────────────────────────── + const CW2 = (W - PAD * 2 - GUTTER) / 2; + const chartOrder = ['temperature','wind','rain','pressure','clouds','uv','solar','tide']; + const validCharts = chartOrder.filter(k => Buffer.isBuffer(charts[k])); + + for (let i = 0; i < validCharts.length; i++) { + const col = i % 2; + const ligne = Math.floor(i / 2); + const cx = PAD + col * (CW2 + GUTTER); + const cy = Y + ligne * (CHART_H + GUTTER); + + const img = await loadImage(charts[validCharts[i]]); + ctx.drawImage(img, cx, cy, CW2, CHART_H); + } + + Y += Math.ceil(validCharts.length / 2) * (CHART_H + GUTTER); + + // ── FOOTER ────────────────────────────────────────────────────────────────── + hline(ctx, Y, 0.4); + Y += 10; + txt( + ctx, + `Open-Meteo | Sunrise-Sunset.org | QuickChart.io | Marees harmoniques (estimation) — ${now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })}`, + W / 2, Y + 26, + { size: 12, color: C.dim, align: 'center' } + ); + + return canvas.encode('png'); +} + +module.exports = { generateCompositeImage }; diff --git a/services/weather.js b/services/weather.js new file mode 100644 index 0000000..96da0e1 --- /dev/null +++ b/services/weather.js @@ -0,0 +1,254 @@ +'use strict'; + +const axios = require('axios'); + +// ─── Cache ───────────────────────────────────────────────────────────────────── +let cache = { data: null, ts: null }; +const CACHE_MS = () => (parseInt(process.env.CACHE_MINUTES, 10) || 10) * 60 * 1000; + +// ─── Appel API principal ──────────────────────────────────────────────────────── +async function fetchWeatherData() { + const now = Date.now(); + if (cache.data && cache.ts && now - cache.ts < CACHE_MS()) { + if (process.env.DEBUG_METEO === 'true') console.log('[cache] Données météo en cache'); + return cache.data; + } + + const lat = process.env.LAT; + const lon = process.env.LON; + if (!lat || !lon) throw new Error('LAT et LON manquants dans .env'); + + const hourlyVars = [ + 'temperature_2m', 'apparent_temperature', 'relative_humidity_2m', + 'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m', + 'precipitation', 'precipitation_probability', 'rain', 'snowfall', 'showers', + 'pressure_msl', 'cloud_cover', 'cloud_cover_low', 'cloud_cover_mid', 'cloud_cover_high', + 'visibility', 'uv_index', 'weather_code', + ].join(','); + + const dailyVars = [ + 'temperature_2m_max', 'temperature_2m_min', + 'apparent_temperature_max', 'apparent_temperature_min', + 'precipitation_sum', 'precipitation_probability_max', + 'wind_speed_10m_max', 'wind_gusts_10m_max', + 'uv_index_max', 'sunrise', 'sunset', 'weather_code', + ].join(','); + + const currentVars = [ + 'temperature_2m', 'apparent_temperature', 'relative_humidity_2m', + 'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m', + 'precipitation', 'rain', 'snowfall', 'weather_code', + 'pressure_msl', 'cloud_cover', 'visibility', 'uv_index', 'is_day', + ].join(','); + + const baseParams = { + latitude: lat, + longitude: lon, + timezone: 'Europe/Paris', + wind_speed_unit: 'kmh', + forecast_days: 2, + past_days: 1, + }; + + // Appels parallèles : météo principale + marine + const [weatherRes, marineRes] = await Promise.allSettled([ + axios.get('https://api.open-meteo.com/v1/forecast', { + params: { ...baseParams, current: currentVars, hourly: hourlyVars, daily: dailyVars }, + timeout: 10000, + }), + axios.get('https://marine-api.open-meteo.com/v1/marine', { + params: { + ...baseParams, + hourly: 'wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction', + daily: 'wave_height_max,swell_wave_height_max', + }, + timeout: 8000, + }), + ]); + + if (weatherRes.status === 'rejected') { + throw new Error(`Open-Meteo inaccessible : ${weatherRes.reason?.message}`); + } + + const weather = weatherRes.value.data; + const marine = marineRes.status === 'fulfilled' ? marineRes.value.data : null; + + if (process.env.DEBUG_METEO === 'true') { + console.log('[debug] current:', JSON.stringify(weather.current, null, 2)); + if (marine) console.log('[debug] marine disponible'); + } + + const result = { weather, marine, fetchedAt: new Date() }; + cache = { data: result, ts: now }; + return result; +} + +// ─── Invalidation manuelle du cache ─────────────────────────────────────────── +function invalidateCache() { + cache = { data: null, ts: null }; +} + +// ─── Index de l'heure courante dans les données horaires ────────────────────── +function getCurrentHourIndex(hourlyTimes) { + // Open-Meteo renvoie les heures en heure locale Paris (ex: "2024-06-10T14:00") + // On génère la même chaîne à partir de l'heure Paris actuelle + const nowParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' }); + // format: "YYYY-MM-DD HH:MM:SS" + const parisHourPrefix = nowParis.substring(0, 13).replace(' ', 'T'); // "YYYY-MM-DDTHH" + + let best = 0; + for (let i = 0; i < hourlyTimes.length; i++) { + if (hourlyTimes[i].startsWith(parisHourPrefix)) return i; + if (hourlyTimes[i] < `${parisHourPrefix}:59`) best = i; + } + return best; +} + +// ─── Fenêtre des prochaines N heures depuis maintenant ──────────────────────── +function getNextHoursWindow(hourlyTimes, count = 24) { + const start = getCurrentHourIndex(hourlyTimes); + const end = Math.min(start + count, hourlyTimes.length); + return { start, end }; +} + +// ─── Index du jour courant dans les tableaux daily ──────────────────────────── +function getTodayDailyIndex(dailyTimes) { + // avec past_days:1, index 0 = hier, 1 = aujourd'hui, 2 = demain + const todayParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' }).substring(0, 10); + const idx = dailyTimes.findIndex(d => d === todayParis); + return idx >= 0 ? idx : 1; // fallback à 1 (aujourd'hui) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function celsiusToKelvin(c) { + return (c + 273.15).toFixed(2); +} + +function getWindDirection(deg) { + if (deg == null) return 'N/A'; + const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSO','SO','OSO','O','ONO','NO','NNO']; + return dirs[Math.round(((deg % 360) + 360) % 360 / 22.5) % 16]; +} + +function getWeatherDescription(code) { + const map = { + 0: '☀️ Ciel dégagé', + 1: '🌤️ Peu nuageux', + 2: '⛅ Partiellement nuageux', + 3: '☁️ Couvert', + 45: '🌫️ Brouillard', + 48: '🌫️ Brouillard givrant', + 51: '🌦️ Bruine légère', + 53: '🌦️ Bruine modérée', + 55: '🌧️ Bruine dense', + 61: '🌧️ Pluie légère', + 63: '🌧️ Pluie modérée', + 65: '🌧️ Pluie forte', + 71: '🌨️ Neige légère', + 73: '🌨️ Neige modérée', + 75: '❄️ Neige forte', + 77: '🌨️ Grésil', + 80: '🌦️ Averses légères', + 81: '🌧️ Averses modérées', + 82: '⛈️ Averses violentes', + 85: '🌨️ Averses de neige', + 86: '❄️ Averses de neige fortes', + 95: '⛈️ Orage', + 96: '⛈️ Orage avec grêle', + 99: '⛈️ Orage avec grêle forte', + }; + return map[code] ?? `Code météo ${code}`; +} + +function getEmbedColor(code) { + if (code === 0) return 0xFFD700; + if (code <= 2) return 0x87CEEB; + if (code <= 3) return 0x708090; + if (code <= 48) return 0x9E9E9E; + if (code <= 55) return 0x90CAF9; + if (code <= 65) return 0x2196F3; + if (code <= 77) return 0xB0C4DE; + if (code <= 82) return 0x42A5F5; + return 0x7B1FA2; // orages +} + +function getPressureTendency(hourlyPressure, currentIdx) { + if (currentIdx < 3) return '→ Stable'; + const diff = hourlyPressure[currentIdx] - hourlyPressure[currentIdx - 3]; + if (diff > 2) return '↑ En hausse'; + if (diff < -2) return '↓ En baisse'; + return '→ Stable'; +} + +function getVisibilityLabel(vis) { + if (vis == null) return 'N/A'; + const km = vis / 1000; + if (km >= 20) return '✨ Excellente'; + if (km >= 10) return '👍 Bonne'; + if (km >= 5) return '😶‍🌫️ Correcte'; + if (km >= 2) return '⚠️ Réduite'; + if (km >= 1) return '🟠 Mauvaise'; + return '🔴 Brouillard'; +} + +function knotsFromKmh(kmh) { + if (kmh == null) return 'N/A'; + return (kmh / 1.852).toFixed(1); +} + +function getUvLabel(uv) { + if (uv == null) return 'N/A'; + if (uv <= 2) return '🟢 Faible'; + if (uv <= 5) return '🟡 Modéré'; + if (uv <= 7) return '🟠 Élevé'; + if (uv <= 10) return '🔴 Très élevé'; + return '🟣 Extrême'; +} + +// ─── Détection d'alertes météo extrêmes ─────────────────────────────────────── +function checkAlerts(current, todayDaily) { + const alerts = []; + + const wind = current.wind_speed_10m; + const gusts = current.wind_gusts_10m; + const rain = current.precipitation; + const code = current.weather_code; + const vis = current.visibility; + + if (wind >= 90) alerts.push({ lvl: '🔴', msg: `Vent violent : **${wind} km/h**` }); + else if (wind >= 65) alerts.push({ lvl: '🟠', msg: `Vent fort : **${wind} km/h**` }); + else if (wind >= 50) alerts.push({ lvl: '🟡', msg: `Vent modéré-fort : **${wind} km/h**` }); + + if (gusts >= 110) alerts.push({ lvl: '🔴', msg: `Rafales extrêmes : **${gusts} km/h**` }); + else if (gusts >= 80) alerts.push({ lvl: '🟠', msg: `Rafales fortes : **${gusts} km/h**` }); + + if (rain >= 15) alerts.push({ lvl: '🔴', msg: `Pluie torrentielle : **${rain} mm/h**` }); + else if (rain >= 7) alerts.push({ lvl: '🟠', msg: `Fortes précipitations : **${rain} mm/h**` }); + + if (code >= 95) alerts.push({ lvl: '🔴', msg: `Orage en cours ⛈️` }); + + const snowDaily = todayDaily?.precipitation_sum; + if (current.snowfall >= 1) alerts.push({ lvl: '🟡', msg: `Chutes de neige en cours ❄️` }); + + if (vis != null && vis < 500) alerts.push({ lvl: '🔴', msg: `Visibilité quasi nulle : **${(vis/1000).toFixed(1)} km**` }); + else if (vis != null && vis < 1500) alerts.push({ lvl: '🟠', msg: `Brouillard dense : **${(vis/1000).toFixed(1)} km**` }); + + return alerts; +} + +module.exports = { + fetchWeatherData, + invalidateCache, + getCurrentHourIndex, + getNextHoursWindow, + getTodayDailyIndex, + celsiusToKelvin, + getWindDirection, + getWeatherDescription, + getEmbedColor, + getPressureTendency, + getVisibilityLabel, + getUvLabel, + knotsFromKmh, + checkAlerts, +};