Оформление
Бот погоды целиком
Бот слушает комнаты канала и на команду /погода Москва отвечает погодой сейчас.
Пример показывает то, чего нет в эхо-боте: разбор команды, поход во внешний сервис и ответ, собранный из его данных. Именно так устроено большинство полезных ботов.
Погоду берём у 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, чтобы/погодабез аргумента отвечал сразу. - Живое сообщение — отправить ответ, а затем обновлять его правкой: так делают боты со счётчиками и статусами.
- Реакция вместо ответа — если команду писали не боту, а в обсуждении, уместнее поставить реакцию, чем добавлять сообщение.