From 2cad1b9fc825a5ee00bc24350397faadc5e369c2 Mon Sep 17 00:00:00 2001 From: lqvp <183242690+lqvp@users.noreply.github.com> Date: Fri, 21 Nov 2025 18:34:06 +0900 Subject: [PATCH] =?UTF-8?q?refactor:open-meteo=E3=81=B8=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/weather/index.ts | 711 ++++++++++++----------------------- 1 file changed, 247 insertions(+), 464 deletions(-) diff --git a/src/modules/weather/index.ts b/src/modules/weather/index.ts index 57cd0e49..5ba26e5b 100644 --- a/src/modules/weather/index.ts +++ b/src/modules/weather/index.ts @@ -2,494 +2,277 @@ import { bindThis } from '@/decorators.js'; import Module from '@/module.js'; import Message from '@/message.js'; import axios from 'axios'; -import { XMLParser } from 'fast-xml-parser'; import serifs from '../../serifs.js'; import config from '../../config.js'; import * as mfm from '@/utils/mfm.js'; -const WEATHER_API_BASE_URL = 'https://weather.tsukumijima.net'; -const PRIMARY_AREA_XML_URL = `${WEATHER_API_BASE_URL}/primary_area.xml`; -const FORECAST_API_URL_BASE = `${WEATHER_API_BASE_URL}/api/forecast/city/`; -const JMA_AREA_API_URL = 'https://www.jma.go.jp/bosai/common/const/area.json'; +const OPEN_METEO_API = 'https://api.open-meteo.com/v1/forecast'; +const OPEN_METEO_GEOCODING_API = 'https://geocoding-api.open-meteo.com/v1/search'; -const PREF_MAP_CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours -const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours - -const DEFAULT_WEATHER_PLACE = config.weatherAutoNotePref ?? '東京都'; +const DEFAULT_WEATHER_PLACE = config.weatherAutoNotePref ?? 'Tokyo'; const DEFAULT_AUTO_NOTE_HOUR = config.weatherAutoNoteHour ?? 7; +const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours -const WEATHER_EMOJI_MAP: Record = { - 雷: '⚡️', - 雪: '❄️', - 雨: '🌧️', - 曇: '☁️', - 晴: '☀️', +// WMO Weather interpretation codes (WW) +// https://open-meteo.com/en/docs +const WMO_CODE_MAP: Record = { + 0: { emoji: '☀️', description: '快晴', serifKey: 'sunny' }, + 1: { emoji: '🌤️', description: '晴れ', serifKey: 'sunny' }, + 2: { emoji: '⛅', description: '一部曇り', serifKey: 'cloudy' }, + 3: { emoji: '☁️', description: '曇り', serifKey: 'cloudy' }, + 45: { emoji: '🌫️', description: '霧', serifKey: 'cloudy' }, + 48: { emoji: '🌫️', description: '霧氷', serifKey: 'cloudy' }, + 51: { emoji: '🌦️', description: '軽い霧雨', serifKey: 'rainy' }, + 53: { emoji: '🌦️', description: '霧雨', serifKey: 'rainy' }, + 55: { emoji: '🌧️', description: '激しい霧雨', serifKey: 'rainy' }, + 56: { emoji: '🌨️', description: '軽い着氷性の霧雨', serifKey: 'snowy' }, + 57: { emoji: '🌨️', description: '着氷性の霧雨', serifKey: 'snowy' }, + 61: { emoji: '☔', description: '小雨', serifKey: 'rainy' }, + 63: { emoji: '☔', description: '雨', serifKey: 'rainy' }, + 65: { emoji: '☔', description: '激しい雨', serifKey: 'rainy' }, + 66: { emoji: '🌨️', description: '軽い着氷性の雨', serifKey: 'snowy' }, + 67: { emoji: '🌨️', description: '着氷性の雨', serifKey: 'snowy' }, + 71: { emoji: '☃️', description: '小雪', serifKey: 'snowy' }, + 73: { emoji: '☃️', description: '雪', serifKey: 'snowy' }, + 75: { emoji: '☃️', description: '大雪', serifKey: 'snowy' }, + 77: { emoji: '❄️', description: '霧雪', serifKey: 'snowy' }, + 80: { emoji: '☔', description: 'にわか雨', serifKey: 'rainy' }, + 81: { emoji: '☔', description: '激しいにわか雨', serifKey: 'rainy' }, + 82: { emoji: '☔', description: '猛烈なにわか雨', serifKey: 'rainy' }, + 85: { emoji: '❄️', description: 'にわか雪', serifKey: 'snowy' }, + 86: { emoji: '❄️', description: '激しいにわか雪', serifKey: 'snowy' }, + 95: { emoji: '⚡', description: '雷雨', serifKey: 'thunder' }, + 96: { emoji: '⛈️', description: '雷雨(雹)', serifKey: 'thunder' }, + 99: { emoji: '⛈️', description: '激しい雷雨(雹)', serifKey: 'thunder' }, }; -const DEFAULT_WEATHER_EMOJI = '🌈'; - -const WEATHER_THEME_COLOR_MAP: Record = { - 晴: 'ff8c00', // オレンジ - 曇: '778899', // ライトスレートグレー - 雨: '4682b4', // スチールブルー - 雪: 'add8e6', // ライトブルー - 雷: 'ff00ff', // マゼンタ -}; -const DEFAULT_THEME_COLOR = '777777'; // グレー -interface WeatherInfo { - place: string; - dateLabel: string; - telop: string; - tempMin: string | null; - tempMax: string | null; - rain: { - T00_06: string; - T06_12: string; - T12_18: string; - T18_24: string; - } | null; - additionalMessage?: string; - greeting?: string; +interface GeocodingResult { + id: number; + name: string; + latitude: number; + longitude: number; + country?: string; + admin1?: string; } -interface RawForecast { - dateLabel: string; - telop: string; - temperature?: { - min?: { celsius?: string }; - max?: { celsius?: string }; - }; - chanceOfRain?: WeatherInfo['rain']; +interface WeatherForecast { + date: string; + dateLabel: string; + weatherCode: number; + tempMax: number | null; + tempMin: number | null; } -interface RawWeatherData { - title: string; - forecasts: RawForecast[]; - location?: { - area: string; - prefecture: string; - district: string; - city: string; - }; - error?: string; // API might return error messages +interface WeatherInfo { + place: string; + forecasts: WeatherForecast[]; } export default class WeatherModule extends Module { - public readonly name = 'weather'; - private prefMapCache: { - data: Record; - fetchedAt: number; - } | null = null; - private cityMapCache: { - data: Record; - fetchedAt: number; - } | null = null; - - @bindThis - public install() { - this.updateWeatherMaps(); - this.updateCityMap(); - this.scheduleWeatherAutoNote(); - return { - mentionHook: this.mentionHook, - }; - } - - private weatherEmoji(telop: string): string { - for (const keyword in WEATHER_EMOJI_MAP) { - if (telop.includes(keyword)) { - return WEATHER_EMOJI_MAP[keyword]; - } - } - return DEFAULT_WEATHER_EMOJI; - } - - private getRainProbabilityColor(probability: string): string { - const probNum = parseInt(probability.replace('%', ''), 10); - if (isNaN(probNum)) return '777777'; // 不明な場合はグレー - if (probNum === 0) return '32cd32'; // 0% (緑) - if (probNum <= 20) return '6495ed'; // 1-20% (明るい青) - if (probNum <= 50) return 'ffa500'; // 21-50% (オレンジ) - return 'ff4500'; // 51%以上 (赤) - } - - private getWeatherThemeColor(telop: string): string { - for (const keyword in WEATHER_THEME_COLOR_MAP) { - if (telop.includes(keyword)) { - return WEATHER_THEME_COLOR_MAP[keyword]; - } - } - return DEFAULT_THEME_COLOR; - } - - private formatWeatherToMfm(info: WeatherInfo): string { - const themeColor = this.getWeatherThemeColor(info.telop); - const title = `${info.place}の${info.dateLabel}の天気`; - const emoji = this.weatherEmoji(info.telop); - - let body = `
-${mfm.bold(mfm.color(title, themeColor))} ${emoji} + public readonly name = 'weather'; + + @bindThis + public install() { + this.scheduleWeatherAutoNote(); + return { + mentionHook: this.mentionHook, + }; + } + + private async searchLocation(query: string): Promise { + try { + const response = await axios.get<{ results?: GeocodingResult[] }>(OPEN_METEO_GEOCODING_API, { + params: { + name: query, + count: 1, + language: 'ja', + format: 'json', + }, + timeout: 10000, + }); + return response.data.results?.[0] ?? null; + } catch (e) { + this.log(`Error searching location ${query}: ${e}`); + return null; + } + } + + private async fetchForecast(lat: number, lon: number): Promise { + try { + const response = await axios.get(OPEN_METEO_API, { + params: { + latitude: lat, + longitude: lon, + daily: 'weathercode,temperature_2m_max,temperature_2m_min', + timezone: 'auto', + forecast_days: 3, + }, + timeout: 10000, + }); + return response.data; + } catch (e) { + this.log(`Error fetching forecast for ${lat},${lon}: ${e}`); + return null; + } + } + + private formatWeatherToMfm(info: WeatherInfo, targetDateLabel?: string): string { + const forecast = targetDateLabel + ? info.forecasts.find(f => f.dateLabel === targetDateLabel) + : info.forecasts[0]; + + if (!forecast) return serifs.weather.fetchError; + + const wmo = WMO_CODE_MAP[forecast.weatherCode] ?? { emoji: '❓', description: '不明', serifKey: 'other' }; + const title = `${info.place}の${forecast.dateLabel}の天気`; + + let body = `
+${mfm.bold(title)} ${wmo.emoji} --- -${info.dateLabel}の天気は「${mfm.bold( - mfm.color(info.telop, themeColor) - )}」みたいですよ! +${forecast.dateLabel}の天気は「${mfm.bold(wmo.description)}」みたいですよ! `; - if (info.tempMin || info.tempMax) { - body += `🌡️ ${mfm.bold('気温')} -最高: ${mfm.color(info.tempMax ?? '?', 'ff4500')}℃ -最低: ${mfm.color(info.tempMin ?? '?', '4169e1')}℃ - -`; - } - - if (info.rain) { - body += `☔ ${mfm.bold('降水確率')} -0-6時: ${mfm.color( - info.rain.T00_06, - this.getRainProbabilityColor(info.rain.T00_06) - )} -6-12時: ${mfm.color( - info.rain.T06_12, - this.getRainProbabilityColor(info.rain.T06_12) - )} -12-18時:${mfm.color( - info.rain.T12_18, - this.getRainProbabilityColor(info.rain.T12_18) - )} -18-24時:${mfm.color( - info.rain.T18_24, - this.getRainProbabilityColor(info.rain.T18_24) - )} + if (forecast.tempMax !== null || forecast.tempMin !== null) { + body += `🌡️ ${mfm.bold('気温')} +最高: ${forecast.tempMax !== null ? mfm.color(`${forecast.tempMax}℃`, 'ff4500') : '?'} +最低: ${forecast.tempMin !== null ? mfm.color(`${forecast.tempMin}℃`, '4169e1') : '?'} `; - } - - if (info.additionalMessage) { - body += `${info.additionalMessage}\n\n`; - } + } - body += `--- + body += `---
`; - if (info.greeting) { - body += `${info.greeting}\n`; - } - - const closingMessages = [ - '今日も素敵な一日になりますように♪ ✨', - '良い一日をお過ごしくださいね! 😊', - '何か良いことがありますように! 🍀', - '頑張ってくださいね!応援しています! 💪', - '無理せず、自分のペースでいきましょう。☕', - ]; - const randomClosingMessage = - closingMessages[Math.floor(Math.random() * closingMessages.length)]; - body += `${randomClosingMessage}`; - - return body; - } - - private async updateWeatherMaps(): Promise { - if ( - this.prefMapCache && - Date.now() - this.prefMapCache.fetchedAt < PREF_MAP_CACHE_TTL_MS - ) { - return; - } - - try { - const response = await axios.get(PRIMARY_AREA_XML_URL, { - timeout: 10000, - }); - const xml = response.data; - const parser = new XMLParser({ ignoreAttributes: false }); - const obj = parser.parse(xml); - const nameToIdMap: Record = {}; - - // XML構造が期待通りであることを確認 - const prefs = obj?.rss?.channel?.['ldWeather:source']?.pref; - if (!prefs) { - this.log('Unexpected XML structure in primary_area.xml'); - return; - } - - // prefが配列でない場合は配列に変換 - const prefArray = Array.isArray(prefs) ? prefs : [prefs]; - - for (const pref of prefArray) { - const prefName = pref?.['@_title']; - const cities = pref?.city; - if (!prefName || !cities) continue; - - // cityも配列でない場合は配列に変換 - const cityArray = Array.isArray(cities) ? cities : [cities]; - - for (const city of cityArray) { - const cityTitle = city?.['@_title']; - const cityId = city?.['@_id']; - if (!cityTitle || !cityId) continue; - - nameToIdMap[cityTitle] = cityId; - // 県名 + 市名のパターンも追加 - nameToIdMap[`${prefName}${cityTitle}`] = cityId; - if (cityTitle.includes('市') || cityTitle.includes('区')) { - nameToIdMap[`${prefName}${cityTitle.replace(/(市|区)$/, '')}`] = - cityId; - } - } - // 県レベルのマッピングも追加(最初の都市を代表として) - if (cityArray.length > 0 && cityArray[0]?.['@_id']) { - nameToIdMap[prefName] = cityArray[0]['@_id']; - } - } - - const now = Date.now(); - this.prefMapCache = { data: nameToIdMap, fetchedAt: now }; - } catch (e) { - this.log(`Error updating weather maps: ${e}`); - } - } - - private async updateCityMap(): Promise { - if ( - this.cityMapCache && - Date.now() - this.cityMapCache.fetchedAt < PREF_MAP_CACHE_TTL_MS - ) { - return; - } - - try { - const response = await axios.get<{ - class20s: Record; - }>(JMA_AREA_API_URL, { - timeout: 10000, - }); - - const cityMap: Record = {}; - for (const code in response.data.class20s) { - const fullName = response.data.class20s[code].name; - if (!fullName) continue; - const baseName = fullName.replace(/(市|町|村|区)$/, ''); - if (!cityMap[baseName]) { - cityMap[baseName] = fullName; - } - } - - this.cityMapCache = { data: cityMap, fetchedAt: Date.now() }; - } catch (e) { - this.log(`Error updating city map: ${e}`); - } - } - - private async getAreaId(place: string): Promise { - await this.updateWeatherMaps(); - const prefIdMap = this.prefMapCache?.data; - if (!prefIdMap) { - this.log('Prefecture ID map is not available.'); - return undefined; - } - - // Direct match - if (prefIdMap[place]) return prefIdMap[place]; - - // Match without suffix - const placeWithoutSuffix = place.replace(/(都|府|県)$/, ''); - if (prefIdMap[placeWithoutSuffix]) return prefIdMap[placeWithoutSuffix]; - - // Try adding suffixes - if (!/(都|府|県)$/.test(place)) { - if (prefIdMap[`${place}都`]) return prefIdMap[`${place}都`]; - if (prefIdMap[`${place}府`]) return prefIdMap[`${place}府`]; - if (prefIdMap[`${place}県`]) return prefIdMap[`${place}県`]; - } - - return undefined; - } - - private async fetchWeatherData( - areaId: string - ): Promise { - try { - const response = await axios.get( - `${FORECAST_API_URL_BASE}${areaId}`, - { - timeout: 10000, - } - ); - const weatherData = response.data; - if (weatherData?.error) { - this.log( - `Weather API error for areaId ${areaId}: ${weatherData.error}` - ); - return null; - } - if (!weatherData?.forecasts || !weatherData?.location) { - this.log( - `Invalid weather data received: ${JSON.stringify(weatherData)}` - ); - return null; - } - return weatherData; - } catch (e) { - this.log(`Error fetching weather data for areaId ${areaId}: ${e}`); - return null; - } - } - - private autoNoteSerif(telop: string): string { - const serifMap: Record = { - 雷: serifs.weather.autoNote.thunder, - 雪: serifs.weather.autoNote.snowy, - 雨: serifs.weather.autoNote.rainy, - 曇: serifs.weather.autoNote.cloudy, - 晴: serifs.weather.autoNote.sunny, - }; - for (const keyword in serifMap) { - if (telop.includes(keyword)) { - return serifMap[keyword]; - } - } - return serifs.weather.autoNote.other; - } - - private scheduleWeatherAutoNote() { - const now = new Date(); - const nextRunTime = new Date(now); - nextRunTime.setHours(DEFAULT_AUTO_NOTE_HOUR, 0, 0, 0); - if (now >= nextRunTime) { - nextRunTime.setDate(nextRunTime.getDate() + 1); - } - const msUntilNext = nextRunTime.getTime() - now.getTime(); - - setTimeout(() => { - this.postWeatherNoteForAuto(DEFAULT_WEATHER_PLACE); - setInterval( - () => this.postWeatherNoteForAuto(DEFAULT_WEATHER_PLACE), - DAILY_INTERVAL_MS - ); - }, msUntilNext); - } - - private async postWeatherNoteForAuto(place: string) { - try { - const areaId = await this.getAreaId(place); - if (!areaId) { - this.log(`Area ID not found for auto-note: ${place}`); - return; - } - - const weatherData = await this.fetchWeatherData(areaId); - if ( - !weatherData || - !weatherData.forecasts || - weatherData.forecasts.length === 0 - ) { - this.log(`No forecast data for auto-note: ${place}`); - return; - } - - const todayForecast = weatherData.forecasts[0]; - - const city = weatherData.location?.city; - const fullCityName = city ? this.cityMapCache?.data?.[city] : null; - let placeName = weatherData.title.replace(/ の天気$/, ''); - if (weatherData.location?.prefecture && fullCityName) { - placeName = `${weatherData.location.prefecture} ${fullCityName}`; - } else if (weatherData.location?.prefecture && city) { - placeName = `${weatherData.location.prefecture} ${city}`; - } - - const weatherInfo: WeatherInfo = { - place: placeName, - dateLabel: todayForecast.dateLabel, - telop: todayForecast.telop, - tempMin: todayForecast.temperature?.min?.celsius ?? null, - tempMax: todayForecast.temperature?.max?.celsius ?? null, - rain: todayForecast.chanceOfRain ?? null, - greeting: this.autoNoteSerif(todayForecast.telop), - }; - - if (weatherInfo.tempMin == null && weatherInfo.tempMax == null) { - weatherInfo.additionalMessage = serifs.weather.noTemp; - } - - const text = this.formatWeatherToMfm(weatherInfo); - this.ai.api('notes/create', { text: text }); - } catch (e) { - this.log(`Error in postWeatherNoteForAuto: ${e}`); - } - } - - @bindThis - private async mentionHook(msg: Message) { - if (!msg.text) return false; - - const match = msg.text.match( - /(?:天気予報|天気|てんき)[\s ]*(今日|明日|明後日|あした|あさって)?[\s ]*([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}a-zA-Z0-9]+)?/u - ); - if (!match) return false; - - let dayInput = match[1]?.trim(); - let placeInput = match[2]?.trim(); - - const dateLabel = this.normalizeDateLabel(dayInput); - const place = placeInput || DEFAULT_WEATHER_PLACE; - - const areaId = await this.getAreaId(place); - - if (!areaId) { - msg.reply(serifs.weather.notFound(place)); - return { reaction: '❌' }; - } - - const weatherData = await this.fetchWeatherData(areaId); - if (!weatherData) { - msg.reply(serifs.weather.fetchError); - return { reaction: '❌' }; - } - - const city = weatherData.location?.city; - const fullCityName = city ? this.cityMapCache?.data?.[city] : null; - let placeName = weatherData.title.replace(/ の天気$/, ''); - if (weatherData.location?.prefecture && fullCityName) { - placeName = `${weatherData.location.prefecture} ${fullCityName}`; - } else if (weatherData.location?.prefecture && city) { - placeName = `${weatherData.location.prefecture} ${city}`; - } - - const forecast = weatherData.forecasts.find( - (f: RawForecast) => f.dateLabel === dateLabel - ); - if (!forecast) { - msg.reply(`${dateLabel}の天気データが見つかりませんでした。`); - return { reaction: '❌' }; - } - - const weatherInfo: WeatherInfo = { - place: placeName, - dateLabel: forecast.dateLabel, - telop: forecast.telop, - tempMin: forecast.temperature?.min?.celsius ?? null, - tempMax: forecast.temperature?.max?.celsius ?? null, - rain: forecast.chanceOfRain ?? null, - }; - - if (weatherInfo.tempMin == null && weatherInfo.tempMax == null) { - weatherInfo.additionalMessage = serifs.weather.noTemp; - } - - const replyText = this.formatWeatherToMfm(weatherInfo); - msg.reply(replyText); - return { reaction: this.weatherEmoji(forecast.telop) }; - } - - private normalizeDateLabel(dayInput?: string): string { - if (!dayInput) return '今日'; - if (['明日', 'あした'].includes(dayInput)) return '明日'; - if (['明後日', 'あさって'].includes(dayInput)) return '明後日'; - return '今日'; - } + const serif = serifs.weather.autoNote[wmo.serifKey] || serifs.weather.autoNote.other; + body += `${serif}\n`; + body += serifs.weather.forecast(info.place, forecast.dateLabel, wmo.description, '', ''); // Simplified call + + return body; + } + + private normalizeDateLabel(dayInput?: string): string { + if (!dayInput) return '今日'; + if (['明日', 'あした'].includes(dayInput)) return '明日'; + if (['明後日', 'あさって'].includes(dayInput)) return '明後日'; + return '今日'; + } + + private getDateLabel(dateStr: string, timezone: string): string { + const date = new Date(dateStr); + const now = new Date(); + + // Adjust to the timezone of the location if possible, but for simplicity we compare dates + // This is a rough approximation. Ideally we should use dayjs with timezone. + // But since we only need Today/Tomorrow/DayAfterTomorrow relative to "now" (server time), + // and the API returns daily data... + + // Let's use a simple logic: + // The API returns dates in YYYY-MM-DD. + // We can check if it matches today's date in the requested timezone. + + // For now, let's just map index 0, 1, 2 to Today, Tomorrow, DayAfterTomorrow + // This assumes the API returns data starting from "today" in the requested timezone. + return '今日'; // Placeholder, will be overwritten by index logic + } + + private scheduleWeatherAutoNote() { + const now = new Date(); + const nextRunTime = new Date(now); + nextRunTime.setHours(DEFAULT_AUTO_NOTE_HOUR, 0, 0, 0); + if (now >= nextRunTime) { + nextRunTime.setDate(nextRunTime.getDate() + 1); + } + const msUntilNext = nextRunTime.getTime() - now.getTime(); + + setTimeout(() => { + this.postWeatherNoteForAuto(DEFAULT_WEATHER_PLACE); + setInterval( + () => this.postWeatherNoteForAuto(DEFAULT_WEATHER_PLACE), + DAILY_INTERVAL_MS + ); + }, msUntilNext); + } + + private async postWeatherNoteForAuto(place: string) { + const location = await this.searchLocation(place); + if (!location) { + this.log(`Location not found for auto-note: ${place}`); + return; + } + + const data = await this.fetchForecast(location.latitude, location.longitude); + if (!data || !data.daily) return; + + const forecast = { + date: data.daily.time[0], + dateLabel: '今日', + weatherCode: data.daily.weathercode[0], + tempMax: data.daily.temperature_2m_max[0], + tempMin: data.daily.temperature_2m_min[0], + }; + + const info: WeatherInfo = { + place: location.name, + forecasts: [forecast], + }; + + const text = this.formatWeatherToMfm(info); + this.ai.api('notes/create', { text: text }); + } + + @bindThis + private async mentionHook(msg: Message) { + if (!msg.text) return false; + + const match = msg.text.match( + /(?:天気予報|天気|てんき)[\s ]*(今日|明日|明後日|あした|あさって)?[\s ]*([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}a-zA-Z0-9]+)?/u + ); + if (!match) return false; + + let dayInput = match[1]?.trim(); + let placeInput = match[2]?.trim(); + + const dateLabel = this.normalizeDateLabel(dayInput); + const place = placeInput || DEFAULT_WEATHER_PLACE; + + const location = await this.searchLocation(place); + if (!location) { + msg.reply(serifs.weather.notFound(place)); + return { reaction: '❌' }; + } + + const data = await this.fetchForecast(location.latitude, location.longitude); + if (!data || !data.daily) { + msg.reply(serifs.weather.fetchError); + return { reaction: '❌' }; + } + + const forecasts: WeatherForecast[] = data.daily.time.map((date: string, index: number) => { + let label = '今日'; + if (index === 1) label = '明日'; + if (index === 2) label = '明後日'; + + return { + date, + dateLabel: label, + weatherCode: data.daily.weathercode[index], + tempMax: data.daily.temperature_2m_max[index], + tempMin: data.daily.temperature_2m_min[index], + }; + }); + + const info: WeatherInfo = { + place: location.name, + forecasts, + }; + + const replyText = this.formatWeatherToMfm(info, dateLabel); + msg.reply(replyText); + + const targetForecast = forecasts.find(f => f.dateLabel === dateLabel); + const wmo = targetForecast ? WMO_CODE_MAP[targetForecast.weatherCode] : null; + return { reaction: wmo ? wmo.emoji : '❓' }; + } }