码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
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;
  },
};