Skip to content

Бот погоды целиком ​

Бот слушает комнаты канала и на команду /погода Москва отвечает погодой сейчас.

Пример показывает то, чего нет в эхо-боте: разбор команды, поход во внешний сервис и ответ, собранный из его данных. Именно так устроено большинство полезных ботов.

Погоду берём у Open-Meteo — он бесплатный, без регистрации и без ключа, поэтому пример запускается сразу после копирования.

Что понадобится ​

Приложение, установленное на канал (как это делается), и ключ со scope:

ScopeЗачем
eventsслышать сообщения в комнатах
messages:writeотвечать
commandsобъявить /погода в подсказке у поля ввода (необязательно)

Остальное можно снять при выпуске ключа: боту погоды не нужны ни история комнат, ни список участников, ни голосовые. Чем уже ключ, тем меньше цена его утечки.

Ещё нужен Node 18+ и два пакета:

bash
npm i centrifuge ws

Код ​

js
// weather-bot.mjs
// Запуск: BOT_TOKEN=emb_ваш_ключ node weather-bot.mjs
import { Centrifuge } from 'centrifuge'
import WebSocket from 'ws'

const API = 'https://emotify.ru/api/v1'
const TOKEN = process.env.BOT_TOKEN
const COMMAND = '/погода'

if (!TOKEN) {
  console.error('Задайте переменную окружения BOT_TOKEN')
  process.exit(1)
}

// Коды погоды WMO — то, чем Open-Meteo описывает состояние неба.
// Здесь не все значения, а сгруппированные: человеку нужна фраза, а не код.
const WEATHER = {
  0: '☀️ ясно',
  1: '🌤 преимущественно ясно',
  2: '⛅ переменная облачность',
  3: '☁️ пасмурно',
  45: '🌫 туман',
  48: '🌫 изморозь',
  51: '🌦 морось',
  53: '🌦 морось',
  55: '🌦 сильная морось',
  61: '🌧 небольшой дождь',
  63: '🌧 дождь',
  65: '🌧 сильный дождь',
  66: '🌧 ледяной дождь',
  67: '🌧 ледяной дождь',
  71: '🌨 небольшой снег',
  73: '🌨 снег',
  75: '❄️ сильный снег',
  77: '🌨 снежная крупа',
  80: '🌦 ливень',
  81: '🌦 ливень',
  82: '⛈ сильный ливень',
  85: '🌨 снежный заряд',
  86: '🌨 сильный снежный заряд',
  95: '⛈ гроза',
  96: '⛈ гроза с градом',
  99: '⛈ сильная гроза с градом'
}

async function geocode(query) {
  const url = new URL('https://geocoding-api.open-meteo.com/v1/search')
  url.searchParams.set('name', query)
  url.searchParams.set('count', '1')
  url.searchParams.set('language', 'ru')

  const res = await fetch(url)
  if (!res.ok) throw new Error(`геокодер ответил ${res.status}`)

  const { results } = await res.json()
  return results?.[0] ?? null
}

// Город → координаты. Геокодер Open-Meteo понимает русские названия.
// Вторая попытка с заменой дефисов на пробелы — потому что люди пишут
// «Советская-Гавань», а в справочнике она «Советская Гавань». Повтор идёт
// ТОЛЬКО если точный запрос ничего не нашёл: у «Ростов-на-Дону» дефисы
// настоящие, и портить их нельзя.
async function findCity(query) {
  const exact = await geocode(query)
  if (exact) return exact

  const relaxed = query.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
  return relaxed === query ? null : geocode(relaxed)
}

// Погода сейчас. timezone=auto — чтобы время считалось местным для города.
async function fetchWeather(latitude, longitude) {
  const url = new URL('https://api.open-meteo.com/v1/forecast')
  url.searchParams.set('latitude', String(latitude))
  url.searchParams.set('longitude', String(longitude))
  url.searchParams.set(
    'current',
    'temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m'
  )
  url.searchParams.set('timezone', 'auto')

  const res = await fetch(url)
  if (!res.ok) throw new Error(`погода ответила ${res.status}`)

  const { current } = await res.json()
  return current
}

function formatAnswer(place, current) {
  // У городов федерального значения регион называется так же, как город,
  // и без этой проверки выходит «Москва, Москва, Россия»
  const parts = [place.name, place.admin1, place.country].filter(Boolean)
  const where = [...new Set(parts)].join(', ')

  const sky = WEATHER[current.weather_code] ?? 'погода неясная'
  const temp = Math.round(current.temperature_2m)
  const feels = Math.round(current.apparent_temperature)
  const wind = Math.round(current.wind_speed_10m)

  // «Ощущается как» показываем, только когда разница заметна
  const feelsPart = Math.abs(temp - feels) >= 2 ? `, ощущается как ${feels}°` : ''

  return (
    `**${where}** — ${sky}\n` +
    `${temp}°C${feelsPart} · влажность ${current.relative_humidity_2m}% · ветер ${wind} км/ч`
  )
}

async function reply(channelId, roomId, text) {
  const res = await fetch(`${API}/rooms/${roomId}/messages`, {
    method: 'POST',
    headers: { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ channelId, text })
  })

  if (!res.ok) {
    // 403 — нет права у роли или scope у ключа; 429 — квота 20 сообщений в минуту
    console.error('Не удалось ответить:', res.status, await res.text())
  }
}

// ⚠️ `text` приходит РАЗМЕТКОЙ. Человек, выделивший город жирным, пришлёт
// «/погода **Москва**» — и геокодер такого города не найдёт. Снимаем только
// парные обёртки вокруг всего значения: `_` и `~` встречаются в названиях,
// и портить их нельзя.
function stripFormatting(value) {
  const wrappers = ['***', '**', '__', '~~', '*', '_', '`']
  let result = value.trim()
  let changed = true

  while (changed) {
    changed = false
    for (const wrapper of wrappers) {
      const doubled = wrapper.length * 2
      if (result.length > doubled && result.startsWith(wrapper) && result.endsWith(wrapper)) {
        result = result.slice(wrapper.length, -wrapper.length).trim()
        changed = true
      }
    }
  }

  return result
}

// Разбор команды: «/погода Москва» → «Москва».
// null — это не наша команда, пустая строка — команда без города.
function parseCommand(text) {
  if (typeof text !== 'string') return null

  const trimmed = text.trim()
  if (!trimmed.toLowerCase().startsWith(COMMAND)) return null

  return stripFormatting(trimmed.slice(COMMAND.length))
}

async function handleMessage(event) {
  const city = parseCommand(event.data.message?.text)
  if (city === null) return

  const { channelId } = event
  const { roomId } = event.data

  if (!city) {
    await reply(channelId, roomId, `Напишите город: \`${COMMAND} Москва\``)
    return
  }

  try {
    const place = await findCity(city)
    if (!place) {
      await reply(channelId, roomId, `Не нашёл город «${city}». Попробуйте другое название.`)
      return
    }

    const current = await fetchWeather(place.latitude, place.longitude)
    await reply(channelId, roomId, formatAnswer(place, current))
  } catch (error) {
    // Внешний сервис может не ответить — человек должен узнать об этом от бота,
    // а не из тишины
    console.error('Погода не получена:', error)
    await reply(channelId, roomId, 'Не смог узнать погоду — сервис не ответил. Попробуйте позже.')
  }
}

// Объявляем команду при старте — тогда её видно в подсказке у поля ввода
// и на экране установки бота на канал. Список рядом с обработчиком: что
// объявлено, то и разбирается ниже
async function announceCommands() {
  const res = await fetch(`${API}/commands`, {
    method: 'PUT',
    headers: { Authorization: `Bot ${TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      commands: [{ name: COMMAND, description: 'Погода в городе', usage: '<город>' }]
    })
  })

  // Ключ без scope «commands» — бот работает, просто не показывается в меню
  if (!res.ok) console.warn('Команды не объявлены:', res.status, await res.text())
}

async function connect() {
  const res = await fetch(`${API}/events/token`, { headers: { Authorization: `Bot ${TOKEN}` } })
  if (!res.ok) {
    // 403 с requiredScope: events — ключ выпущен без доступа к событиям
    console.error('Не получил токен подписки:', res.status, await res.text())
    process.exit(1)
  }

  const { token, channel, url } = await res.json()
  const client = new Centrifuge(url, { token, websocket: WebSocket })

  client
    .newSubscription(channel)
    .on('publication', ({ data: event }) => {
      if (event.event !== 'message.created') return
      // Своих сообщений бот не получает — зацикливания не будет
      handleMessage(event).catch(console.error)
    })
    .subscribe()

  client.on('connected', () => console.log('Бот погоды подключён. Команда:', COMMAND))
  client.connect()
}

announceCommands().catch(console.error)
connect().catch(console.error)

// Токен подписки живёт 6 часов — обновляемся заранее
setInterval(() => connect().catch(console.error), 5 * 60 * 60 * 1000)

Как выглядит ответ ​

Напишите в комнате канала:

/погода Москва

Бот ответит:

Москва, Россия — ☁️ пасмурно 12°C · влажность 85% · ветер 6 км/ч

Названия городов понимаются и на русском, и на латинице, а ответ всегда приходит по-русски: Reykjavik превратится в «Рейкьявик, Исландия».

На что обратить внимание ​

Команда разбирается в три состояния, а не в два. null — это чужое сообщение, и бот молчит; пустая строка — команда без города, и бот подсказывает формат; строка с городом — работаем. Если свести к «есть команда / нет команды», человек, написавший /погода без города, не получит в ответ ничего и решит, что бот сломан.

Ошибка внешнего сервиса доходит до человека. Геокодер и погода — чужие сервисы, они иногда не отвечают. Молчание бота в этот момент неотличимо от «бот не работает», поэтому в catch стоит ответ, а не только запись в консоль.

Своих сообщений бот не получает — это свойство платформы, а не заслуга кода. Поэтому бот, отвечающий на каждое сообщение, не зацикливается сам на себе.

Ответ приходит в ту же комнату, откуда пришла команда: roomId берётся из события, а не задаётся в настройках. Один и тот же бот так работает во всех комнатах и на всех каналах, где он установлен.

Повтор поиска города — из живого теста, а не из головы. Человек написал /погода Советская-Гавань, и бот честно ответил «не нашёл»: в справочнике город называется через пробел. Отсюда вторая попытка с заменой дефисов — но только после неудачи первой, иначе пострадал бы Ростов-на-Дону, где дефисы настоящие.

Разметку снимаем обязательно. message.text — это Markdown: человек, выделивший город жирным, пришлёт **Москва**, и геокодер такого не найдёт. Тоже находка живого теста — на ней встал бот-модератор, искавший участника по нику **Ник**.

Куда развивать ​

  • Прогноз на завтра — у Open-Meteo есть параметр daily (temperature_2m_max, temperature_2m_min, weather_code).
  • Погода по умолчанию для канала — запоминать город на channelId, чтобы /погода без аргумента отвечал сразу.
  • Живое сообщение — отправить ответ, а затем обновлять его правкой: так делают боты со счётчиками и статусами.
  • Реакция вместо ответа — если команду писали не боту, а в обсуждении, уместнее поставить реакцию, чем добавлять сообщение.

Документация внешнего API Emotify