码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js11.5 KB
/**
 * 菜谱查询插件(recipe)
 *
 * 功能:
 *   1) 指令模式:发送「菜谱 <菜名>」查询做法与食材;「菜谱 随机」来一道随机菜;「菜谱 分类」看菜系。
 *   2) 智能助手:声明 aiTools=get_recipe,AI 可主动调用(如「今天做什么菜」「红烧肉怎么做」)。
 *
 * 数据源:
 *   - 默认 TheMealDB(https://www.themealdb.com):完全免费、无需 Key、含食材/步骤/图片/视频。
 *     内容偏英文,菜名请用英文(如 chicken / pasta),或用「菜谱 随机」。
 *   - 可选「天行数据 caipu」:中文菜谱,需在插件设置里填入 API Key,并在设置中将数据源切换为「天行数据」。
 *
 * 命令前缀已在 meta.commandPrefix 声明,smart 会自动跳过该前缀(不会把「菜谱 xxx」当成普通对话)。
 */

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

const PLUGIN_ID = 'recipe';
const THEMEALDB_BASE = 'https://www.themealdb.com/api/json/v1/1';

// 读取本插件在某机器人上的配置(与后台「插件设置」打通)
async function getSettings(botId) {
  try {
    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;
    return cfg;
  } catch (e) {
    return {};
  }
}

async function dataTmpDir() {
  const dir = path.join(__dirname, '..', '..', 'data', 'tmp');
  fs.mkdirSync(dir, { recursive: true });
  return dir;
}

// ---------------- 数据源:TheMealDB(免费免 Key) ----------------

function normalizeTheMealDB(meal) {
  const ingredients = [];
  for (let i = 1; i <= 20; i++) {
    const ing = meal['strIngredient' + i];
    const mea = meal['strMeasure' + i];
    if (ing && String(ing).trim()) {
      ingredients.push({ name: String(ing).trim(), measure: mea ? String(mea).trim() : '' });
    }
  }
  const instr = meal.strInstructions || '';
  const steps = instr
    .split(/\r?\n/)
    .map((s) => s.trim())
    .filter(Boolean);
  return {
    name: meal.strMeal || '未知菜名',
    category: meal.strCategory || '',
    area: meal.strArea || '',
    image: meal.strMealThumb || '',
    video: meal.strYoutube || '',
    source: meal.strSource || '',
    ingredients,
    steps: steps.length ? steps : [instr].filter(Boolean),
  };
}

async function searchTheMealDB(query) {
  const url = `${THEMEALDB_BASE}/search.php?s=${encodeURIComponent(query)}`;
  const resp = await axios.get(url, { timeout: 15000 });
  const meals = resp.data && resp.data.meals;
  if (!meals || !meals.length) return [];
  return meals.map(normalizeTheMealDB).slice(0, 10);
}

async function randomTheMealDB() {
  const resp = await axios.get(`${THEMEALDB_BASE}/random.php`, { timeout: 15000 });
  const meals = resp.data && resp.data.meals;
  if (!meals || !meals.length) return null;
  return normalizeTheMealDB(meals[0]);
}

async function categoriesTheMealDB() {
  const resp = await axios.get(`${THEMEALDB_BASE}/categories.php`, { timeout: 15000 });
  const cats = resp.data && resp.data.categories;
  return cats ? cats.map((c) => c.strCategory).filter(Boolean) : [];
}

// ---------------- 数据源:天行数据 caipu(中文,需 Key) ----------------

async function searchTianapi(key, query) {
  const url =
    `https://apis.tianapi.com/caipu/index?key=${encodeURIComponent(key)}` +
    `&word=${encodeURIComponent(query)}&num=3&page=1`;
  const resp = await axios.get(url, { timeout: 15000 });
  if (!resp.data || resp.data.code !== 200) {
    throw new Error('天行数据返回:' + (resp.data && resp.data.msg ? resp.data.msg : '未知错误'));
  }
  const list = (resp.data.result && resp.data.result.data) || [];
  return list.map((d) => {
    const steps = String(d.content || '')
      .split(/\d+\./)
      .map((s) => s.trim())
      .filter(Boolean);
    return {
      name: d.title || '未知菜名',
      category: d.tag || '',
      area: '',
      image: d.pic || '',
      video: '',
      source: '',
      ingredients: [],
      ingredientsText: d.materiale || '',
      steps: steps.length ? steps : [d.content || ''].filter(Boolean),
    };
  });
}

// ---------------- 渲染 ----------------

function formatRecipe(r) {
  const lines = [];
  lines.push('🍳 ' + r.name);
  const tags = [r.category, r.area].filter(Boolean).join(' · ');
  if (tags) lines.push('【' + tags + '】');

  if (r.ingredients && r.ingredients.length) {
    lines.push('');
    lines.push('🥬 食材:');
    lines.push(r.ingredients.map((i) => (i.measure ? i.measure + ' ' : '') + i.name).join('、'));
  } else if (r.ingredientsText) {
    lines.push('');
    lines.push('🥬 食材:');
    lines.push(r.ingredientsText);
  }

  if (r.steps && r.steps.length) {
    lines.push('');
    lines.push('👩‍🍳 做法:');
    r.steps.forEach((s, i) => lines.push(i + 1 + '. ' + s));
  }

  if (r.video) lines.push('', '📺 视频:' + r.video);
  return lines.join('\n');
}

async function sendRecipe(ctx, r) {
  // 先发成品图(若有),失败不影响文字
  if (r.image) {
    let tmp = null;
    try {
      tmp = path.join(await dataTmpDir(), `recipe_${Date.now()}.jpg`);
      const resp = await axios.get(r.image, { responseType: 'arraybuffer', timeout: 20000 });
      fs.writeFileSync(tmp, resp.data);
      await ctx.sendMedia(tmp, 'image', (r.name || 'recipe') + '.jpg');
    } catch (e) {
      // 图片发送失败忽略
    } finally {
      if (tmp) {
        try { fs.unlinkSync(tmp); } catch (_) {}
      }
    }
  }
  await ctx.sendText(formatRecipe(r));
}

async function resolveResults(cfg, query) {
  if (cfg.provider === 'tianapi') {
    const key = cfg.tianapi_key;
    if (!key) {
      throw new Error(
        '使用天行数据需先配置 API Key:在插件设置里填入「天行数据 caipu」的 Key,或将数据源改回默认的 TheMealDB(免 Key)。'
      );
    }
    return await searchTianapi(key, query);
  }
  return await searchTheMealDB(query);
}

async function searchAndSend(ctx, query) {
  const cfg = await getSettings(ctx.bot.id);
  const results = await resolveResults(cfg, query);
  if (!results.length) {
    return ctx.sendText('没找到「' + query + '」相关的菜谱,换个菜名试试?');
  }
  await sendRecipe(ctx, results[0]);
  if (results.length > 1) {
    const others = results
      .slice(1, 6)
      .map((r) => r.name)
      .join('、');
    await ctx.sendText('(还找到:' + others + ' 等共 ' + results.length + ' 道,可「菜谱 <具体菜名>」查看)');
  }
}

const HELP =
  '🍳 菜谱查询\n' +
  '────────────\n' +
  '• 菜谱 <菜名> :查询做法与食材(如「菜谱 pasta」「菜谱 红烧肉」)\n' +
  '• 菜谱 随机   :来一道随机菜\n' +
  '• 菜谱 分类   :查看菜系/分类\n' +
  '• 菜谱 帮     :显示本帮助\n' +
  '────────────\n' +
  '数据源默认 TheMealDB(免费免 Key,内容偏英文,建议用英文菜名或「菜谱 随机」);\n' +
  '如需中文菜谱,可在插件设置里填入「天行数据 caipu」Key 并切换数据源。';

// ---------------- 指令入口 ----------------

async function onMessage(msg, ctx) {
  const text = (msg.content || '').trim();
  if (!text) return;
  const m = text.match(/^(?:菜谱|recipe)\s*(.*)$/i);
  if (!m) return;
  const arg = (m[1] || '').trim();

  try {
    if (!arg || ['帮', '帮助', 'help', '?', '菜单'].includes(arg.toLowerCase())) {
      await ctx.sendText(HELP);
      return true;
    }

    const cfg = await getSettings(ctx.bot.id);
    const useTianapi = cfg.provider === 'tianapi';

    if (arg === '随机' || arg === 'random') {
      if (useTianapi) {
        await ctx.sendText('当前数据源(天行数据)不支持「随机」,请直接发送「菜谱 <菜名>」');
        return true;
      }
      const r = await randomTheMealDB();
      if (!r) {
        await ctx.sendText('暂时没找到随机菜谱,换一个试试~');
        return true;
      }
      await sendRecipe(ctx, r);
      return true;
    }

    if (arg === '分类' || arg === 'categories') {
      if (useTianapi) {
        await ctx.sendText('当前数据源(天行数据)不支持「分类」,请直接发送「菜谱 <菜名>」');
        return true;
      }
      const cats = await categoriesTheMealDB();
      if (!cats.length) {
        await ctx.sendText('获取分类失败,稍后再试~');
        return true;
      }
      await ctx.sendText('🍽 菜系 / 分类:\n' + cats.join('、'));
      return true;
    }

    await searchAndSend(ctx, arg);
    return true;
  } catch (e) {
    console.error('[recipe] 出错:', e.message);
    await ctx.sendText('查询菜谱出错了:' + e.message);
    return true;
  }
}

// ---------------- 智能助手工具 ----------------

const aiTools = [
  {
    type: 'function',
    function: {
      name: 'get_recipe',
      description:
        '根据菜名查询菜谱,返回食材清单与做法步骤。当用户询问「XX 怎么做」「今天做什么菜」「推荐个菜谱」时调用。',
      parameters: {
        type: 'object',
        properties: {
          query: { type: 'string', description: '要查询的菜名,如「红烧肉」「pasta」' },
        },
        required: ['query'],
      },
    },
  },
];

async function handleAiTool(name, args, ctx) {
  if (name !== 'get_recipe') return null;
  const query = args && args.query;
  if (!query) return '未提供菜名';
  try {
    const cfg = await getSettings(ctx.bot.id);
    const results = await resolveResults(cfg, query);
    if (!results.length) return '未找到「' + query + '」的菜谱';
    let out = formatRecipe(results[0]);
    if (results.length > 1) {
      const others = results
        .slice(1, 4)
        .map((r) => r.name)
        .join('、');
      out += '\n(还找到:' + others + ')';
    }
    return out;
  } catch (e) {
    return '查询菜谱失败:' + e.message;
  }
}

const meta = {
  id: PLUGIN_ID,
  name: '菜谱查询',
  description:
    '发送「菜谱 <菜名>」查询做法与食材,「菜谱 随机」来一道随机菜,「菜谱 分类」看菜系。默认数据源 TheMealDB(免费免 Key,偏英文);可在插件设置里切换为天行数据(中文,需 Key)。',
  category: '娱乐',
  commandPrefix: ['菜谱', 'recipe'],
  version: '1.0.0',
  configurable: true,
  settingsSchema: [
    {
      key: 'provider',
      label: '数据源',
      type: 'select',
      options: [
        { label: 'TheMealDB(免费免 Key,内容偏英文)', value: 'themealdb' },
        { label: '天行数据(中文,需填 Key)', value: 'tianapi' },
      ],
      help: '默认用 TheMealDB 即可开箱即用;想要中文菜谱请选「天行数据」并填 Key。',
    },
    {
      key: 'tianapi_key',
      label: '天行数据 API Key',
      type: 'password',
      placeholder: '在天行数据「菜谱 caipu」申请的 key',
      help: '仅当数据源选「天行数据」时需要。',
    },
  ],
  aiTools,
};

module.exports = { meta, onMessage, handleAiTool, aiTools };