码桶
发现社区成员的开源项目
index.js20.9 KB
/**
* 插件:天气查询
* --------------------------------------------------
* 可切换双数据源:
* 1) Open-Meteo(默认,免 key):https://open-meteo.com
* - 天气预报:https://api.open-meteo.com/v1/forecast
* - 地理编码(城市名 -> 经纬度):https://geocoding-api.open-meteo.com/v1/search
* 2) 和风天气 QWeather(需免费 API KEY,国内更快更准):https://dev.qweather.com
* - 在「天气插件配置」里填 API KEY 即自动启用。
* - 地理编码仍走 Open-Meteo(免 key、中文城市已优化,如「南阳」精准命中河南南阳市);
* 拿到经纬度后再调和风天气接口(按坐标查询),绕开和风 GeoAPI 的访问限制。
* - 免费版(标准订阅)提供实况 + 3 天预报。
*
* 功能:
* 1) 命令查询:天气 北京 / 北京天气 / 北京 明天 / 北京 后天 / 北京 未来3天
* 2) 默认城市记忆:天气城市 北京(保存) / 我的天气城市(查看),存于 user_facts,AI 也能回忆
* 3) 智能助手:声明 aiTools=get_weather,AI 可主动调用(自然语言问天气时)
*/
const axios = require('axios');
// ---- Open-Meteo 端点 ----
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const GEO_URL = 'https://geocoding-api.open-meteo.com/v1/search';
// ---- 和风天气端点(API KEY 鉴权,X-QW-Api-Key) ----
const QW_NOW_URL = 'https://api.qweather.com/v7/weather/now';
const QW_3D_URL = 'https://api.qweather.com/v7/weather/3d';
// 天气代码(WMO)-> [中文描述, emoji](Open-Meteo 用)
const WMO = {
0: ['晴', '☀️'],
1: ['晴间多云', '🌤️'],
2: ['局部多云', '⛅'],
3: ['阴', '☁️'],
45: ['有雾', '🌫️'],
48: ['雾凇', '🌫️'],
51: ['小毛毛雨', '🌦️'],
53: ['毛毛雨', '🌦️'],
55: ['大毛毛雨', '🌦️'],
56: ['冻毛毛雨', '🌧️'],
57: ['冻毛毛雨', '🌧️'],
61: ['小雨', '🌧️'],
63: ['中雨', '🌧️'],
65: ['大雨', '🌧️'],
66: ['冻雨', '🌧️'],
67: ['强冻雨', '🌧️'],
71: ['小雪', '🌨️'],
73: ['中雪', '❄️'],
75: ['大雪', '❄️'],
77: ['雪粒', '🌨️'],
80: ['阵雨', '🌦️'],
81: ['强阵雨', '🌦️'],
82: ['暴雨', '⛈️'],
85: ['阵雪', '🌨️'],
86: ['强阵雪', '❄️'],
95: ['雷阵雨', '⛈️'],
96: ['雷阵雨伴冰雹', '⛈️'],
99: ['强雷暴伴冰雹', '⛈️'],
};
function wmo(code) {
return WMO[code] || ['未知', '🌡️'];
}
// 和风中文天气描述 -> emoji
function qwEmoji(text) {
const t = text || '';
if (/雷|暴/.test(t)) return '⛈️';
if (/雨|阵水/.test(t)) return '🌧️';
if (/雪|霰|雹/.test(t)) return '❄️';
if (/雾|霾|沙/.test(t)) return '🌫️';
if (/阴/.test(t)) return '☁️';
if (/多云|少云|晴间/.test(t)) return '⛅';
if (/晴/.test(t)) return '☀️';
return '🌡️';
}
// 默认城市在 user_facts 中的 key(与 memory 归一化后的 peer 保持一致,AI 可回忆)
const CITY_FKEY = '天气城市';
// ---- 缓存(避免重复请求) ----
const geoCache = new Map(); // cityLower -> geo
const weatherCache = new Map(); // `${src}|${lat},${lon}` -> { data, ts }
const configCache = new Map(); // botId -> { cfg, ts }
const WEATHER_TTL = 20 * 60 * 1000; // 20 分钟
const CONFIG_TTL = 5 * 60 * 1000; // 5 分钟
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 与 plugins/memory 保持一致:剥掉 iLink 对同一 openid 给出的两种后缀
function normalizePeer(p) {
if (!p) return p;
return String(p).replace(/_im_wechat$/i, '').replace(/@im\.wechat$/i, '');
}
// ==================== 插件配置(和风 API KEY,按 bot 存于 plugin_settings) ====================
async function loadConfig(botId) {
if (botId != null) {
const c = configCache.get(botId);
if (c && Date.now() - c.ts < CONFIG_TTL) return c.cfg;
}
const cfg = { key: '' };
try {
const db = require('../../lib/db');
const rows = await db.rows(
'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
[botId, 'weather']
);
const m = {};
rows.forEach((r) => { m[r.config_key] = r.config_value; });
cfg.key = m.qweather_api_key || '';
} catch (e) {
console.warn('[weather] 读取配置失败:', e.message);
}
if (botId != null) configCache.set(botId, { cfg, ts: Date.now() });
return cfg;
}
// 设置 / 查询默认城市的指令(前缀匹配,避免误触普通聊天)
const SET_RE = /^(我的天气城市|天气城市|天气\s*城市|我的城市|设置城市|设置天气城市|城市)\s*(.+)?$/;
// 是否像天气查询:① 含「天气」/weather;② 短文本+时间词+无动作词(如「北京明天」);③ 纯城市名(如「上海」)
const TIME_RE = /(今天|今日|明天|明日|明儿|后天|大后天|未来\s*\d*\s*天|\d+\s*天|\d+\s*日)/;
const ACTION_WORDS = ['去', '玩', '出发', '到', '住', '吃', '买', '开', '飞', '坐', '旅游', '出差', '见面', '开会', '跑步', '散步', '出发去'];
const CHAT_WORDS = ['吗', '呢', '怎么', '怎样', '为什么', '是什么', '哪个', '几点', '多少', '可以', '需要', '请', '谢谢', '你好', '在吗', '帮我', '我想', '我要', '哈哈', '嗯', '哦', '啊', '好的', '好吧', '不是', '没有', '对', '错', '行', '是', '不', '没'];
function isWeatherQuery(text) {
if (text.includes('天气') || /weather/i.test(text)) return true;
if (text.length <= 12 && TIME_RE.test(text) && !ACTION_WORDS.some((w) => text.includes(w))) return true;
// 纯城市名:纯中文/字母、2~10 字、无动作词/语气词(「上海」「纽约」能查,「哈哈」「在吗」不触发)
if (/^[\u4e00-\u9fffA-Za-z]{2,10}$/.test(text) && !ACTION_WORDS.some((w) => text.includes(w))
&& !CHAT_WORDS.some((w) => text.includes(w))) {
return true;
}
return false;
}
/** 解析「天数窗口」:返回 { days, start };start=0 今天起,1 明天,2 后天 */
function parseQuery(text) {
let days = 1, start = 0;
if (text.includes('今天') || text.includes('今日')) { days = 1; start = 0; }
else if (text.includes('明天') || text.includes('明日') || text.includes('明儿')) { days = 1; start = 1; }
else if (text.includes('后天') || text.includes('明后天')) { days = 1; start = 2; }
else if (text.includes('大后天')) { days = 1; start = 3; }
const m = text.match(/未来\s*(\d+)\s*天|(\d+)\s*天|(\d+)\s*日/);
if (m) {
const n = parseInt(m[1] || m[2] || m[3] || m[4], 10);
days = Math.min(Math.max(n || 1, 1), 7);
start = 0;
}
return { days, start };
}
/** 从文本中提取城市名(去掉天气/天数/语气词) */
function parseCity(text) {
let s = String(text || '');
s = s.replace(/^(天气|查天气|查询天气|看天气|问天气|weather)\s*/i, '');
s = s.replace(/天气|气温|温度|预报|查询|怎么样|如何|怎样|咋样|吗|呢|情况|下雨|下雪|天气情况/g, '');
s = s.replace(/(今天|今日|明天|明日|明儿|后天|大后天|未来\s*\d*\s*天|\d+\s*天|\d+\s*日)/g, '');
s = s.replace(/(的|我|想|要|查|看|问|知道|了解|下|一下|帮|我们|大家)/g, '');
return s.trim() || null;
}
// ==================== 地理编码(Open-Meteo,中文已优化) ====================
/**
* Open-Meteo 地理编码(免 key)。
* 关键修复:裸名「南阳」只返回同名小镇且不含河南南阳市,补「市」字查「南阳市」即精准命中;
* 合并多候选后按「中国优先 + 行政级别 + 人口」挑选主城。和风 GeoAPI 在部分账号有访问限制,
* 故本插件统一用 Open-Meteo 做地理编码,再拿经纬度去和风查天气。
*/
async function geocode(city) {
const key = String(city).trim().toLowerCase();
if (geoCache.has(key)) return geoCache.get(key);
const variants = [city];
if (/^[\u4e00-\u9fff]+$/.test(city) && !/(市|省|自治区|特别行政区)$/.test(city)) {
variants.push(city + '市');
}
let results = [];
try {
const resp = await Promise.all(variants.map((v) =>
axios.get(GEO_URL, {
params: { name: v, count: 10, language: 'zh', format: 'json' },
timeout: 10000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WeatherBot/1.0)' },
})
));
for (const { data } of resp) {
if (data && Array.isArray(data.results)) results = results.concat(data.results);
}
} catch (err) {
console.warn('[weather] 地理编码失败:', city, err.message);
}
if (!results.length) return null;
const score = (r) => {
let s = 0;
if (r.country_code === 'CN') s += 1e12;
if (/^PPLA/.test(r.feature_code || '')) s += 5e8;
else if ((r.feature_code || '').startsWith('PPL')) s += 1e6;
s += (r.population || 0);
return s;
};
const r = results.slice().sort((a, b) => score(b) - score(a))[0];
const geo = {
name: (r.name || city).replace(/市$/, ''),
admin1: (r.admin1 || '').replace(/省|市|自治区|特别行政区|壮族|回族|维吾尔/g, ''),
country: r.country || '',
lat: r.latitude,
lon: r.longitude,
};
geoCache.set(key, geo);
return geo;
}
// ==================== 天气预报 ====================
async function fetchOpenMeteo(lat, lon, days) {
const cacheKey = `om:${lat},${lon},${days}`;
const cached = weatherCache.get(cacheKey);
if (cached && Date.now() - cached.ts < WEATHER_TTL) return cached.data;
let lastErr;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const { data } = await axios.get(FORECAST_URL, {
params: {
latitude: lat,
longitude: lon,
current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,is_day',
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max',
timezone: 'auto',
forecast_days: days,
wind_speed_unit: 'ms',
},
timeout: 10000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WeatherBot/1.0)' },
});
if (data) {
weatherCache.set(cacheKey, { data, ts: Date.now() });
return data;
}
break;
} catch (err) {
lastErr = err;
if (attempt === 0) await sleep(1000);
}
}
console.warn('[weather] Open-Meteo 天气获取失败:', lastErr && lastErr.message);
return null;
}
async function fetchQWeather(lat, lon, key) {
const cacheKey = `qw:${lat},${lon}`;
const cached = weatherCache.get(cacheKey);
if (cached && Date.now() - cached.ts < WEATHER_TTL) return cached.data;
const headers = { 'X-QW-Api-Key': key };
const location = `${lon},${lat}`; // 和风格式:经度,纬度
let lastErr;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const [nowR, d3R] = await Promise.all([
axios.get(QW_NOW_URL, { params: { location }, headers, timeout: 10000 }),
axios.get(QW_3D_URL, { params: { location }, headers, timeout: 10000 }),
]);
const view = { source: '和风天气 QWeather', current: null, daily: [] };
const now = nowR.data && nowR.data.now;
if (now) {
view.current = {
icon: qwEmoji(now.text),
text: now.text,
temp: Number(now.temp),
feels: Number(now.feelsLike),
humidity: Number(now.humidity),
wind: (Number(now.windSpeed) / 3.6).toFixed(1), // km/h -> m/s
windUnit: 'm/s',
};
}
const daily = (d3R.data && d3R.data.daily) || [];
view.daily = daily.map((d) => ({
date: d.fxDate,
text: d.textDay,
icon: qwEmoji(d.textDay),
tempMin: Number(d.tempMin),
tempMax: Number(d.tempMax),
pop: (d.pop != null ? Number(d.pop) : null),
}));
weatherCache.set(cacheKey, { data: view, ts: Date.now() });
return view;
} catch (err) {
lastErr = err;
if (attempt === 0) await sleep(1000);
}
}
console.warn('[weather] 和风天气获取失败:', lastErr && lastErr.message);
return null;
}
// 把 Open-Meteo raw 转成统一 view
async function buildOpenMeteoView(geo, days) {
const data = await fetchOpenMeteo(geo.lat, geo.lon, days);
if (!data || (!data.current && !(data.daily && data.daily.time && data.daily.time.length))) return null;
const view = { source: 'Open-Meteo(免费开源天气)', current: null, daily: [] };
if (data.current) {
const c = data.current;
const [desc, icon] = wmo(c.weather_code);
view.current = {
icon, text: desc,
temp: Math.round(c.temperature_2m),
feels: Math.round(c.apparent_temperature),
humidity: c.relative_humidity_2m,
wind: c.wind_speed_10m,
windUnit: 'm/s',
};
}
const d = data.daily;
if (d && d.time && d.time.length) {
for (let i = 0; i < d.time.length; i++) {
const [desc, icon] = wmo(d.weather_code[i]);
view.daily.push({
date: d.time[i],
text: desc,
icon,
tempMin: Math.round(d.temperature_2m_min[i]),
tempMax: Math.round(d.temperature_2m_max[i]),
pop: d.precipitation_probability_max ? d.precipitation_probability_max[i] : null,
});
}
}
return view;
}
// ==================== 渲染(统一结构) ====================
function renderView(geo, view, start) {
const lines = [];
const parts = [geo.name, geo.admin1, geo.country].filter(Boolean);
const loc = [...new Set(parts)].join(' ');
lines.push(`🌤️ ${loc} 天气`);
if (start === 0 && view.current) {
const c = view.current;
lines.push(`${c.icon} 现在:${c.text} ${c.temp}°C(体感 ${c.feels}°C)`);
lines.push(`💧 湿度 ${c.humidity}% 🌬️ 风 ${c.wind} ${c.windUnit}`);
lines.push('');
}
if (view.daily && view.daily.length) {
view.daily.forEach((d, i) => {
const label = i === 0 ? '今天' : i === 1 ? '明天' : i === 2 ? '后天' : d.date.slice(5);
let line = `${d.icon} ${label} ${d.date.slice(5)}:${d.text} ${d.tempMin}~${d.tempMax}°C`;
if (d.pop != null) line += ` 🌧️${d.pop}%`;
lines.push(line);
});
}
lines.push('');
lines.push(`数据来源:${view.source}`);
return lines.join('\n');
}
// ==================== 默认城市记忆(复用 user_facts) ====================
async function saveCity(botId, peerId, city) {
try {
const db = require('../../lib/db');
const p = normalizePeer(peerId);
const now = Math.floor(Date.now() / 1000);
await db.exec(
`INSERT INTO user_facts(bot_id, peer_id, fkey, fvalue, updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(bot_id, peer_id, fkey) DO UPDATE SET fvalue=?, updated_at=?`,
[botId, p, CITY_FKEY, city, now, city, now]
);
return true;
} catch (e) {
console.error('[weather] 保存城市失败:', e.message);
return false;
}
}
async function recallCity(botId, peerId) {
try {
const db = require('../../lib/db');
const p = normalizePeer(peerId);
const rows = await db.rows(
'SELECT fvalue FROM user_facts WHERE bot_id=? AND peer_id=? AND fkey=? ORDER BY updated_at DESC LIMIT 1',
[botId, p, CITY_FKEY]
);
return rows.length ? rows[0].fvalue : null;
} catch (e) {
return null;
}
}
// ==================== 统一查询入口(命令 & AI 共用) ====================
async function doWeather(city, days, start, botId, peerId) {
if (!city && botId && peerId) city = await recallCity(botId, peerId);
if (!city) {
return '🏙️ 请告诉我城市,例如「天气 北京」「上海天气」「广州 明天」。\n也可先设置默认城市:天气城市 北京';
}
const cfg = botId ? await loadConfig(botId) : { key: '' };
const useQ = !!(cfg.key);
// 地理编码统一走 Open-Meteo(和风 GeoAPI 部分账号有访问限制,故用 OM 取坐标)
const geo = await geocode(city);
if (!geo) {
return `🔍 没找到城市「${city}」。\n中国城市用中文即可(如 北京、南阳);国外城市请用英文或拼音(如 New York、Tokyo)。`;
}
const totalDays = Math.min(Math.max((start || 0) + (days || 1), 1), 7);
let view;
if (useQ) {
view = await fetchQWeather(geo.lat, geo.lon, cfg.key); // 和风免费版最多 3 天
if (!view) view = await buildOpenMeteoView(geo, totalDays); // 和风失败兜底 Open-Meteo
} else {
view = await buildOpenMeteoView(geo, totalDays);
}
if (!view) return '🌥️ 天气查询失败,请稍后再试。';
// 按 start 偏移、按 days 数量裁剪每日列表
const daily = (view.daily || []).slice(start || 0).slice(0, days || 1);
return renderView(geo, { ...view, daily }, start || 0);
}
module.exports = {
meta: {
id: 'weather',
name: '天气查询',
version: '1.1.0',
author: '奶狗',
category: '信息获取',
description: '查询全球城市的实时天气与未来预报(温度/体感/湿度/风速/降水概率)。默认用免费免 key 的 Open-Meteo;在插件配置中填入和风天气 API KEY 后,天气数据自动改用和风(国内更快更准),地理编码仍用 Open-Meteo。',
usage: '发送「天气 北京」「北京天气」「北京 明天」「北京 未来3天」;设置默认城市:天气城市 北京;查看:我的天气城市',
entry: 'weather/index.js',
configurable: true,
settingsSchema: [
{
key: 'qweather_api_key',
label: '和风天气 API KEY',
type: 'password',
placeholder: '在 dev.qweather.com 控制台「凭据」获取的 API KEY',
help: '填写后天气数据改走和风天气(国内更快更准、覆盖国内外城市),地理编码仍用免费 Open-Meteo。留空则仍用默认 Open-Meteo(免 key)。和风免费版提供实况+3天预报。',
},
],
aiTools: [
{
function: {
name: 'get_weather',
description: '查询指定城市的天气预报(实时天气 + 未来 1~7 天)。当用户询问天气、气温、是否下雨/下雪、适合穿什么、出行/旅游天气等时使用。',
parameters: {
type: 'object',
properties: {
city: {
type: 'string',
description: '城市名,如 北京、上海、广州,或「城市,国家」如 Paris,France。可留空,留空则使用用户已设置的默认城市。',
},
days: {
type: 'integer',
description: '预报天数,1=今天,2=今天+明天,最多 7。默认 1。',
default: 1,
},
},
required: [],
},
},
},
],
},
// 智能助手工具执行:返回天气文本(由 AI 转发给用户)
async handleAiTool(name, args, ctx) {
if (name !== 'get_weather') return '';
const botId = ctx && ctx.bot && ctx.bot.id;
const peerId = ctx && ctx.msg && ctx.msg.peer_id;
const city = (args && args.city ? String(args.city).trim() : '') || null;
const days = Math.min(Math.max(parseInt((args && args.days) || '1', 10) || 1, 1), 7);
return await doWeather(city, days, 0, botId, peerId);
},
async onMessage(msg, ctx) {
const text = (msg.content || '').trim();
if (!text) return false;
const botId = (ctx.bot && ctx.bot.id) || msg.bot_id || msg.botId;
const peerId = msg.peer_id;
// 1) 设置 / 查询默认城市
const setM = text.match(SET_RE);
if (setM) {
const cityArg = (setM[2] || '').trim();
if (!cityArg) {
const cur = await recallCity(botId, peerId);
await ctx.sendText(
cur
? `🌆 你已设置的天气城市:${cur}\n发送「天气」即可查询 ${cur} 的天气。\n更换:天气城市 <城市名>`
: '🌆 你还没有设置天气城市。\n设置:天气城市 北京\n之后发送「天气」即可查询。'
);
return true;
}
const ok = await saveCity(botId, peerId, cityArg);
await ctx.sendText(
ok
? `✅ 已设置天气城市:${cityArg}\n发送「天气」即可查询 ${cityArg} 的天气。`
: '⚠️ 城市保存失败,请稍后再试。'
);
return true;
}
// 2) 天气查询(含「天气」/weather,或短文本+时间词+无动作词如「北京明天」)
if (!isWeatherQuery(text)) return false;
const { days, start } = parseQuery(text);
const city = parseCity(text);
const reply = await doWeather(city, days, start, botId, peerId);
await ctx.sendText(reply);
return true;
},
};