码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js19.3 KB
/**
 * 插件:车辆状态查询(Vehicle Status)
 * --------------------------------------------------
 * 从用户配置的「查询接口地址」拉取车辆实时状态并格式化回复。
 *
 * 适用接口:返回与 EVCC lua.js 上报结构一致、或等价的 JSON,例如:
 *   {
 *     "report_time": "2026-03-23 19:43:37",
 *     "timestamp": 1773724205000,
 *     "vehicle": { "battery_soc": 91.4, "fuel_level": 0, ... },
 *     "location": { "available": 1, "latitude": 30.12, "longitude": 120.12, ... }
 *   }
 * 也兼容外面包一层 { code, message, data } 的形式。
 *
 * 指令(触发词可在插件设置里改,默认「车辆」):
 *   车辆                  → 拉取并展示车辆状态摘要
 *   车辆 https://...      → 临时用该地址拉取(覆盖已配置地址)
 *   车辆 原始             → 输出接口返回的完整 JSON
 *   车辆 帮助             → 显示用法
 *   车辆 设置             → 提示如何配置接口地址
 */

const axios = require('axios');
const db = require('../../lib/db');

const PLUGIN_ID = 'vehicle';
const HTTP_TIMEOUT = 10000;
// 测试/默认接口(无需密钥)。可在插件设置里覆盖。
const DEFAULT_API_URL = 'https://www.naigou.cn/car/query.php';
// 对比模式触发词(独立使用,如「对比」「变化」)
const COMPARE_WORDS = ['对比', 'compare', '变化', '差异'];

// ==================== 中文字段映射 ====================
const VEHICLE_MAP = {
  battery_soc:        { name: '电量',       unit: '%' },
  fuel_level:         { name: '油量',       unit: '' },
  total_mileage:      { name: '总里程',     unit: 'km' },
  speed:              { name: '速度',       unit: 'km/h' },
  charging_status:    { name: '充电状态',   unit: '' },
  fuel_cap_status:    { name: '加油盖状态', unit: '' },
  drive_mode:         { name: '驾驶模式',   unit: '' },
  gear:               { name: '档位',       unit: '' },
  fuel_range:         { name: '燃油续航',   unit: 'km' },
  charge_amount_1:    { name: '充电量',     unit: '' },
  ev_range:           { name: '纯电续航',   unit: 'km' },
};

const LOCATION_MAP = {
  available:          { name: '定位可用',   unit: '' },
  latitude:           { name: '纬度',       unit: '' },
  longitude:          { name: '经度',       unit: '' },
  altitude:           { name: '海拔',       unit: 'm' },
  accuracy:           { name: '定位精度',   unit: 'm' },
  speed_mps:          { name: '定位速度',   unit: 'm/s' },
  bearing:            { name: '方向角',     unit: '°' },
  provider:           { name: '定位来源',   unit: '' },
  location_timestamp: { name: '定位时间',   unit: '' },
  age_ms:             { name: '定位缓存年龄', unit: 'ms' },
};

const CHARGING_STATUS = { 0: '未充电', 1: '充电中', 2: '已充满', 3: '充电异常' };
const GEAR_STATUS = { 0: 'P档', 1: 'R档', 2: 'N档', 3: 'D档', 4: '手动模式' };
const FUEL_CAP_STATUS = { 0: '关闭', 1: '打开', 2: '未知' };

// ==================== 配置读取 ====================
async function loadConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, PLUGIN_ID]
  );
  const cfg = {};
  for (const r of rows) cfg[r.config_key] = r.config_value;
  if (!cfg.trigger) cfg.trigger = '车辆';
  return cfg;
}

function validateConfig(cfg, urlOverride) {
  // 有临时地址、已配置地址、或存在默认地址,均可视为可用
  const hasUrl = !!(urlOverride || cfg.api_url || DEFAULT_API_URL);
  const missing = hasUrl ? [] : ['查询接口地址 (api_url)'];
  return { valid: missing.length === 0, missing };
}

// 去掉响应里掺杂在 JSON 之前的 HTML/PHP 警告等垃圾内容
// 例如:<br />...<br />\n\n{ "code":200,... }
function extractJson(text) {
  const s = (text || '').trim();
  if (!s) return s;
  const idx = s.search(/[\[{]/);
  return idx >= 0 ? s.slice(idx) : s;
}

// 在基础 URL 上追加 action 参数(兼容已带查询串的地址)
function withAction(url, action) {
  if (!action) return url;
  return url + (url.includes('?') ? '&' : '?') + 'action=' + encodeURIComponent(action);
}

// ==================== 接口请求 ====================
async function fetchVehicle(url, token) {
  const headers = { 'Accept': 'application/json' };
  if (token) headers['Authorization'] = `Bearer ${token}`;
  const res = await axios.get(url, { timeout: HTTP_TIMEOUT, headers });
  // 容错:部分接口 Content-Type 非 JSON 或混入 HTML 警告,axios 未自动解析,这里手动转
  if (typeof res.data === 'string') {
    const cleaned = extractJson(res.data);
    if (cleaned.startsWith('{') || cleaned.startsWith('[')) {
      try { return JSON.parse(cleaned); } catch (_) { /* 保持原字符串 */ }
    }
  }
  return res.data;
}

/** 把接口返回归一化成内部 vehicle/location 结构 */
function normalize(raw) {
  if (!raw || typeof raw !== 'object') return null;
  // 直接是上报结构
  if (raw.vehicle || raw.location) return raw;
  // 常见外层包装:data / result / payload / response
  for (const key of ['data', 'result', 'payload', 'response']) {
    const sub = raw[key];
    if (sub && typeof sub === 'object' && !Array.isArray(sub)) {
      if (sub.vehicle || sub.location || sub['车辆状态'] || sub['定位信息']) return sub;
    }
  }
  if (raw.data && typeof raw.data === 'object') return raw.data;
  // report.php 日志行结构 { received_at, raw_data }
  if (raw.raw_data) return raw.raw_data;
  return raw;
}

// 扁平中文结构(query.php action=summary 等):{ 电量, 油量, 总里程, 续航, 定位 ... }
const FLAT_KEYS = ['电量', '油量', '总里程', '速度', '续航', '充电状态', '驾驶模式', '档位', '加油盖状态', '定位', '纯电续航', '燃油续航', '充电量'];
function isFlatSummary(obj) {
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
  return Object.keys(obj).some(k => FLAT_KEYS.includes(k) || /电量|油量|里程|续航|充电|定位|档位/.test(k));
}
function flatToList(obj) {
  const lines = [];
  for (const k of Object.keys(obj)) {
    if (['接收时间', '上报时间', '时间戳', '更新时间', '定位'].includes(k)) continue; // 已在头部/定位区块展示
    const v = obj[k];
    lines.push(`${k}:${typeof v === 'object' ? JSON.stringify(v) : v}`);
  }
  return lines;
}

// ==================== 格式化 ====================
function fmtVehicle(v) {
  if (!v || typeof v !== 'object') return [];
  const lines = [];
  for (const key of Object.keys(VEHICLE_MAP)) {
    if (!(key in v)) continue;
    const info = VEHICLE_MAP[key];
    let val = v[key];
    if (key === 'charging_status' && CHARGING_STATUS[val] != null) val = `${val}(${CHARGING_STATUS[val]})`;
    else if (key === 'gear' && GEAR_STATUS[val] != null) val = `${val}(${GEAR_STATUS[val]})`;
    else if (key === 'fuel_cap_status' && FUEL_CAP_STATUS[val] != null) val = `${val}(${FUEL_CAP_STATUS[val]})`;
    lines.push(`${info.name}:${val}${info.unit}`);
  }
  return lines;
}

function fmtLocation(l) {
  if (!l || typeof l !== 'object') return [];
  const lines = [];
  for (const key of Object.keys(LOCATION_MAP)) {
    if (!(key in l)) continue;
    const info = LOCATION_MAP[key];
    let val = l[key];
    if (key === 'available') val = val == 1 ? '可用' : '不可用';
    else if (typeof val === 'number') val = String(val);
    lines.push(`${info.name}:${val}${info.unit}`);
  }
  return lines;
}

// 兼容三种定位结构:翻译数组 / 英文 location 对象 / 扁平中文 定位 对象
function fmtLocationFlex(loc) {
  if (Array.isArray(loc)) return fmtTranslatedList(loc);
  if (!loc || typeof loc !== 'object') return [];
  // 英文 location 对象(含 latitude/longitude/available)
  if (loc.latitude != null || loc.longitude != null || loc.available != null) return fmtLocation(loc);
  // 扁平中文 定位 对象(经度/纬度/...),逐键渲染
  const lines = [];
  for (const k of Object.keys(loc)) {
    let v = loc[k];
    if (typeof v === 'object') v = JSON.stringify(v);
    lines.push(`${k}:${v}`);
  }
  return lines;
}

/** 翻译后列表({名称,值,单位,描述?} 数组)转文本行,兼容 query.php 的 latest 返回 */
function fmtTranslatedList(arr) {
  if (!Array.isArray(arr)) return [];
  return arr.map(it => {
    let line = `${it.名称}:${it.值}${it.单位 || ''}`;
    if (it.描述) line += `(${it.描述})`;
    return line;
  });
}

// ==================== 逆地理编码(经纬度 → 文字地址) ====================
const GEOCODE_URL = 'https://api.bigdatacloud.net/data/reverse-geocode-client';
const GEOCODE_TIMEOUT = 8000;

function toNum(v) {
  if (v == null) return null;
  const n = typeof v === 'number' ? v : parseFloat(String(v).trim());
  return Number.isFinite(n) ? n : null;
}

// 从定位结构(翻译数组 / 英文 location 对象 / 扁平中文 定位 对象)抽取经纬度
function extractLatLng(locSrc) {
  if (Array.isArray(locSrc)) {
    const m = {};
    for (const it of locSrc) {
      const k = it['原始字段'] || it['名称'];
      if (k != null) m[k] = it['值'];
    }
    return {
      lat: toNum(m.latitude != null ? m.latitude : m['纬度']),
      lng: toNum(m.longitude != null ? m.longitude : m['经度']),
    };
  }
  return {
    lat: toNum(locSrc.latitude != null ? locSrc.latitude : locSrc['纬度']),
    lng: toNum(locSrc.longitude != null ? locSrc.longitude : locSrc['经度']),
  };
}

// 把 BigDataCloud 返回拼成中文地址(省+市+区/县+街道)
function formatAddress(d) {
  if (!d) return null;
  const adm = (d.localityInfo && d.localityInfo.administrative) || [];
  // 去掉国家(adminLevel 2),保留省(4)市(5)县区(6)街道(8)
  const parts = adm.filter(a => a.adminLevel >= 4 && a.name).map(a => a.name);
  if (parts.length) return parts.join('');
  const fallback = [d.principalSubdivision, d.city || d.locality, d.district].filter(Boolean);
  return fallback.length ? fallback.join('') : null;
}

// 经纬度反查文字地址(免费接口,无需 key;失败返回 null)
async function reverseGeocode(lat, lng) {
  if (lat == null || lng == null) return null;
  try {
    const url = `${GEOCODE_URL}?latitude=${lat}&longitude=${lng}&localityLanguage=zh`;
    const res = await axios.get(url, { timeout: GEOCODE_TIMEOUT });
    return formatAddress(res.data);
  } catch (_) {
    return null;
  }
}

async function buildSummary(data) {
  let report = normalize(data);
  if (!report) return '⚠️ 接口未返回有效数据。';

  // 数组(query.php action=list/raw 多条)→ 取最新一条
  if (Array.isArray(report)) {
    if (!report.length) return '⚠️ 接口返回空列表。';
    const item = report[0];
    report = (item && item.raw_data) ? item.raw_data : item;
  }

  const out = [];
  out.push('🚗 车辆状态');

  // 头部时间(兼容原始 / 翻译 / 扁平三种结构)
  const reportTime = report.report_time || report['上报时间'] || report['更新时间'];
  const recvTime   = report['接收时间'];
  const ts         = report.timestamp || report['时间戳'];
  if (reportTime) out.push(`📅 上报时间:${reportTime}`);
  else if (ts) out.push(`📅 时间戳:${ts}`);
  if (recvTime) out.push(`📥 接收时间:${recvTime}`);

  out.push('———————————');

  // 车辆状态:翻译数组 > 原始对象 > 扁平中文结构
  let vLines;
  if (Array.isArray(report['车辆状态'])) vLines = fmtTranslatedList(report['车辆状态']);
  else if (report.vehicle) vLines = fmtVehicle(report.vehicle);
  else if (isFlatSummary(report)) vLines = flatToList(report);
  else vLines = [];

  if (vLines.length) out.push(...vLines);
  else out.push('(无车辆数据)');

  // 定位信息:只要有定位结构就展示(翻译数组 / 英文 location / 扁平中文 定位 对象)
  const locSrc = report['定位信息'] || report.location || report['定位'];
  if (locSrc) {
    const lLines = fmtLocationFlex(locSrc);
    if (lLines.length) {
      out.push('———————————');
      out.push('📍 定位信息');

      // 经纬度反查文字地址(失败不影响其余字段)
      const { lat, lng } = extractLatLng(locSrc);
      if (lat != null && lng != null) {
        const addr = await reverseGeocode(lat, lng);
        if (addr) out.push(`🏠 地址:${addr}`);
      }

      out.push(...lLines);
    }
  }
  return out.join('\n');
}

// 两次上报差异对比(query.php?action=compare)
function buildCompare(data) {
  if (!data || typeof data !== 'object') return '⚠️ 接口未返回有效的对比数据。';

  const out = [];
  out.push('🔍 车辆状态对比');

  const t = data['对比时间'] || {};
  if (t['当前']) out.push(`📅 当前:${t['当前']}`);
  if (t['上次']) out.push(`📅 上次:${t['上次']}`);
  if (t['间隔']) out.push(`⏱️ 间隔:${t['间隔']}`);

  const changed = data['是否有变化'];
  if (changed != null) {
    out.push(`🔔 是否有变化:${changed}`);
  }

  out.push('———————————');

  const list = data['车辆状态对比'];
  if (!Array.isArray(list) || !list.length) {
    out.push('(无对比数据)');
    return out.join('\n');
  }

  for (const it of list) {
    const name = it['名称'] || '?';
    const cur = it['当前值'];
    const unit = it['单位'] || '';
    let line = `${name}:${cur}${unit}`;

    // 当前描述(如「手动模式」「未知」)追加在值后
    if (it['当前描述']) line += `(${it['当前描述']})`;

    // 变化状态
    const change = it['变化'];
    if (change && change !== '不变') {
      let diff = '';
      if (it['差值'] != null) diff = ` 差值 ${it['差值']}${unit}`;
      line += `  ⟳ ${change}${diff}(上次 ${it['上次值']}${unit})`;
    } else {
      line += '  · 不变';
    }
    out.push(line);
  }
  return out.join('\n');
}

// ==================== onMessage ====================
async function onMessage(msg, ctx) {
  const text = (msg.content || '').trim();
  if (!text) return false;

  const botId = typeof ctx.bot === 'object' ? ctx.bot.id : ctx.bot;
  if (!botId) return false;

  const cfg = await loadConfig(botId);
  const trigger = (cfg.trigger || '车辆').trim();

  // 触发词匹配:text 等于触发词,或以触发词开头(其后可带任意字,如「车辆信息」「车辆状态」)
  let rest = '';
  let mode = 'status';
  if (text === trigger) { rest = ''; }
  else if (text.startsWith(trigger)) { rest = text.slice(trigger.length).trim(); }
  else if (COMPARE_WORDS.includes(text)) { mode = 'compare'; rest = ''; }
  else { return false; }

  // 「车辆对比」「车辆变化」等 → 对比模式
  if (mode === 'status' && COMPARE_WORDS.includes(rest)) { mode = 'compare'; rest = ''; }

  // 帮助
  if (rest === '帮助' || rest === 'help' || rest === '?') {
    await ctx.sendText(
      `🚗 车辆状态查询 用法:\n` +
      `· ${trigger}            → 查询当前车辆状态\n` +
      `· ${trigger} <地址>     → 临时用该地址查询(如 ${trigger} https://x/api/vehicle)\n` +
      `· ${trigger} 对比       → 对比本次与上次上报的差异\n` +
      `· 对比 / 变化           → 同上(独立触发词)\n` +
      `· ${trigger} 原始       → 输出接口完整 JSON\n` +
      `· ${trigger} 设置       → 查看如何配置接口地址\n` +
      `· ${trigger} 帮助       → 本说明\n\n` +
      `车辆数据来源:在「插件设置 → 车辆状态查询」里填写「查询接口地址」与可选「Bearer 令牌」。`
    );
    return true;
  }

  // 设置提示
  if (rest === '设置' || rest === 'config') {
    await ctx.sendText(
      '⚙️ 配置车辆查询接口:\n' +
      '请到后台 → 插件设置 → 车辆状态查询,填写:\n' +
      '· 查询接口地址:返回车辆 JSON 的 URL(GET)\n' +
      '· Bearer 令牌:接口需要的鉴权令牌(可选)\n\n' +
      'JSON 结构需包含 vehicle / location 字段,与 EVCC lua.js 上报格式一致。'
    );
    return true;
  }

  // 临时地址覆盖:trigger 后若像 URL 则用作本次地址
  let urlOverride = null;
  if (rest && /^https?:\/\//i.test(rest.split(/\s+/)[0])) {
    urlOverride = rest.split(/\s+/)[0];
  }

  const { valid, missing } = validateConfig(cfg, urlOverride);
  if (!valid) {
    await ctx.sendText(
      `⚠️ 车辆查询未配置完成\n缺少:${missing.join('、')}\n\n` +
      `请在后台 → 插件设置 → 车辆状态查询 中填写「查询接口地址」;\n` +
      `或临时发送「${trigger} https://你的接口地址」进行查询。`
    );
    return true;
  }

  const url = urlOverride || cfg.api_url || DEFAULT_API_URL;
  const token = cfg.api_token || '';
  // 对比模式走 ?action=compare
  const fetchUrl = mode === 'compare' ? withAction(url, 'compare') : url;

  let raw;
  try {
    raw = await fetchVehicle(fetchUrl, token);
  } catch (e) {
    if (e.code === 'ECONNREFUSED' || e.code === 'ENOTFOUND' || e.code === 'ETIMEDOUT') {
      await ctx.sendText(`⚠️ 无法连接到车辆接口(${fetchUrl})\n请检查地址是否正确、服务是否运行。`);
    } else if (e.response?.status === 401) {
      await ctx.sendText('⚠️ 车辆接口鉴权失败(401),请检查 Bearer 令牌。');
    } else {
      await ctx.sendText(`⚠️ 查询车辆接口出错:${e.message}`);
    }
    return true;
  }

  // 对比模式
  if (mode === 'compare') {
    let payload = raw;
    if (raw && raw.data && typeof raw.data === 'object') payload = raw.data;
    await ctx.sendText(buildCompare(payload));
    return true;
  }

  // 原始模式
  if (rest === '原始' || rest === 'raw') {
    const pretty = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2);
    await ctx.sendText('📦 接口原始返回:\n' + pretty.slice(0, 4000));
    return true;
  }

  // 摘要模式
  const summary = await buildSummary(raw);
  await ctx.sendText(summary);
  return true;
}

// ==================== 导出 ====================
module.exports = {
  meta: {
    id: PLUGIN_ID,
    name: '车辆状态查询',
    version: '1.0.0',
    author: '奶狗',
    category: '信息获取',
    description: '从配置的「查询接口地址」拉取车辆实时状态(电量、里程、定位等)并格式化回复,兼容 EVCC lua.js 上报结构。',
    entry: 'vehicle/index.js',
    configurable: true,
    settingsSchema: [
      { key: 'api_url', label: '查询接口地址', type: 'text', placeholder: 'https://你的域名/api/vehicle', help: '返回车辆 JSON 的 GET 接口地址(需包含 vehicle / location 字段)。' },
      { key: 'api_token', label: 'Bearer 令牌', type: 'password', placeholder: '可选', help: '接口鉴权令牌;留空则不带 Authorization 头。' },
      { key: 'trigger', label: '触发词', type: 'text', placeholder: '车辆', help: '发送「触发词」即查询;默认「车辆」。' },
    ],
  },
  onMessage,
};