码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
index.js9.8 KB
 /**
 * 内置插件:OpenClaw AI Agent 对话
 * --------------------------------------------------
 * 通过 OpenClaw Gateway 的 OpenAI 兼容端点进行 AI Agent 交互。
 * Token 与 AI 对话共享额度。
 *
 * 配置优先级(通过 resolveClawConfig 解析):
 *   1. 机器人自定义 API(plugin_settings: use_custom_claw=1 + claw_api_base + claw_api_key)
 *   2. 管理员全局配置(settings 表: claw_api_base / claw_api_key / claw_model …)
 *
 * 每机器人用户配置(trigger、session_key、以及自定义 API)存在 plugin_settings 表。
 */

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

/** 读取全局 OpenClaw 配置(管理员后台设置) */
async function loadGlobalClawConfig() {
  return {
    api_base: (await settings.getSetting('claw_api_base', '')).replace(/\/+$/, '').replace(/\/v1\/?$/, ''),
    api_key: await settings.getSetting('claw_api_key', ''),
    model: await settings.getSetting('claw_model', 'openclaw/default'),
    backend_model: await settings.getSetting('claw_backend_model', ''),
    system_prompt: await settings.getSetting('claw_system_prompt', ''),
    max_tokens: parseInt(await settings.getSetting('claw_max_tokens', '2000'), 10) || 2000,
    temperature: parseFloat(await settings.getSetting('claw_temperature', '0.7')) || 0.7,
  };
}

/** 读取该机器人的用户配置(trigger、session_key、自定义 API 等) */
async function loadUserConfig(botId) {
  const rows = await db.rows(
    'SELECT config_key, config_value FROM plugin_settings WHERE bot_id=? AND plugin_id=?',
    [botId, 'openclaw']
  );
  const cfg = {};
  rows.forEach(r => { cfg[r.config_key] = r.config_value; });
  return cfg;
}

/**
 * 解析 OpenClaw 的 API 接口来源:
 * - 若机器人配置了「自定义接口」(use_custom_claw=1 且基地址/密钥齐全) → 使用自定义接口
 * - 否则回退到管理员在后台统一配置的全局 claw_* 设置
 */
async function resolveClawConfig(botId) {
  const global = await loadGlobalClawConfig();
  const bot = await loadUserConfig(botId);
  if (bot.use_custom_claw === '1' && bot.claw_api_base && bot.claw_api_key) {
    return {
      custom: true,
      api_base: bot.claw_api_base.replace(/\/+$/, '').replace(/\/v1\/?$/, ''),
      api_key: bot.claw_api_key,
      model: (bot.claw_model || 'openclaw/default').trim(),
      backend_model: (bot.claw_backend_model || '').trim(),
      system_prompt: bot.claw_system_prompt || '',
      max_tokens: parseInt(bot.claw_max_tokens, 10) || 2000,
      temperature: parseFloat(bot.claw_temperature) || 0.7,
    };
  }
  return { ...global, custom: false };
}

/** 检查触发词 */
function matchTrigger(text, trigger) {
  if (!trigger && trigger !== '') trigger = 'claw';
  if (!trigger) return text;
  const prefixes = [trigger + ' ', trigger + ',', '/' + trigger + ' '];
  for (const p of prefixes) {
    if (text.startsWith(p)) return text.slice(p.length).trim();
  }
  return null;
}

/** 获取用户 Token 用量(开源版:不限额) */
async function getUserTokenInfo(userId) {
  return { used: 0, limit: Number.MAX_SAFE_INTEGER };
}

/** 增加用户 Token 用量(同步写入每日用量热力图表) */
async function addUserTokens(userId, tokens) {
  await db.exec(
    'UPDATE users SET ai_tokens_used = ai_tokens_used + ? WHERE id = ?',
    [tokens, userId]
  );
  const d = new Date();
  const day = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  try {
    await db.exec(
      'INSERT INTO token_usage(user_id, day, tokens) VALUES(?, ?, ?) ON CONFLICT(user_id, day) DO UPDATE SET tokens = tokens + excluded.tokens',
      [userId, day, tokens]
    );
  } catch (e) { /* 热力图统计为非关键路径,失败忽略 */ }
}

module.exports = {
  meta: {
    id: 'openclaw',
    name: 'OpenClaw Agent',
    version: '2.0.0',
    author: '奶狗',
    category: 'AI对话',
    description: '对接 OpenClaw AI Agent 框架。每个机器人可单独设置触发词、Gateway 接口与模型;不设置则使用管理员全局配置。Token 与 AI 对话共享额度。',
    entry: 'openclaw/index.js',
    builtin: true,
    // 字段化配置:每个机器人各存一份(plugin_settings 按 bot_id + plugin_id 隔离)
    settingsSchema: [
      { key: 'trigger', label: '触发词', type: 'text', placeholder: 'claw', help: '发送「触发词 你的问题」即调用。留空默认 claw。' },
      { key: 'use_custom_claw', label: '使用自定义接口', type: 'switch', help: '开启后使用下方自定义 Gateway;关闭则用管理员全局配置。' },
      { key: 'claw_api_base', label: 'Gateway 地址', type: 'text', placeholder: 'https://your-gateway.com', help: '自定义 OpenClaw Gateway 基地址(无需带 /v1)。' },
      { key: 'claw_api_key', label: '网关令牌', type: 'password', placeholder: 'Bearer 令牌', help: '自定义接口的 API Key。' },
      { key: 'claw_model', label: '模型', type: 'text', placeholder: 'openclaw/default', help: '请求使用的模型名。' },
      { key: 'claw_backend_model', label: '后端模型', type: 'text', placeholder: '可选', help: '透传 x-openclaw-model 头,留空则不发送。' },
      { key: 'session_key', label: '会话密钥', type: 'text', placeholder: '可选', help: '透传 x-openclaw-session-key 头,用于会话隔离。' },
      { key: 'claw_system_prompt', label: '系统提示词', type: 'textarea', placeholder: '可选', help: '每次对话前置的 system 消息。' },
      { key: 'claw_max_tokens', label: '最大 Token', type: 'number', placeholder: '2000', help: '单次回复最大 Token 数,默认 2000。' },
      { key: 'claw_temperature', label: '温度', type: 'number', placeholder: '0.7', help: '采样温度 0~2,默认 0.7。' },
    ],
  },

  async onMessage(msg, ctx) {
    const botId = ctx.bot.id;
    const userId = ctx.bot.user_id;
    const text = (msg.content || '').trim();
    if (!text) return false;

    const ucfg = await loadUserConfig(botId);
    const trigger = ucfg.trigger !== undefined ? ucfg.trigger : 'claw';
    const session_key = ucfg.session_key || '';
    const prompt = matchTrigger(text, trigger);
    if (prompt === null) return false;

    // 解析 API 配置:优先使用机器人自定义接口,否则用管理员全局配置
    const cfg = await resolveClawConfig(botId);

    if (!cfg.api_base) {
      await ctx.sendText('[OpenClaw] 未配置 Gateway 地址,请先在网页端设置。');
      return true;
    }

    if (!cfg.api_key) {
      await ctx.sendText('[OpenClaw] 未配置网关令牌,请先在网页端设置。');
      return true;
    }

    if (!prompt) {
      await ctx.sendText('[OpenClaw] 请输入你的问题,例如:' + trigger + ' 帮我总结今天的任务');
      return true;
    }

    // 检查 Token 额度
    const tokenInfo = await getUserTokenInfo(userId);
    if (tokenInfo.used >= tokenInfo.limit) {
      const usedK = Math.round(tokenInfo.used / 1000);
      const limitK = Math.round(tokenInfo.limit / 1000);
      await ctx.sendText('[OpenClaw] Token 额度已用完(' + usedK + 'K / ' + limitK + 'K),请联系管理员提升额度。');
      return true;
    }

    try {
      const headers = {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + cfg.api_key,
      };
      if (cfg.backend_model) {
        headers['x-openclaw-model'] = cfg.backend_model;
      }
      if (session_key) {
        headers['x-openclaw-session-key'] = session_key;
      }

      const body = {
        model: cfg.model,
        messages: [
          ...(cfg.system_prompt ? [{ role: 'system', content: cfg.system_prompt }] : []),
          { role: 'user', content: prompt }
        ],
        max_tokens: cfg.max_tokens,
        temperature: cfg.temperature,
      };

      const resp = await axios.post(
        cfg.api_base + '/v1/chat/completions',
        body,
        { headers, timeout: 90000 }
      );

      const reply = resp.data?.choices?.[0]?.message?.content;
      const toolCalls = resp.data?.choices?.[0]?.message?.tool_calls;
      const usageTokens = resp.data?.usage?.total_tokens || 0;

      if (usageTokens > 0) {
        await addUserTokens(userId, usageTokens);
      }

      if (reply) {
        const newUsed = tokenInfo.used + usageTokens;
        const remain = Math.max(0, tokenInfo.limit - newUsed);
        const footer = remain > 0 ? '\n\n— 剩余约 ' + Math.round(remain / 1000) + 'K Token' : '';
        await ctx.sendText(reply.trim() + footer);
      } else if (toolCalls && toolCalls.length > 0) {
        const toolNames = toolCalls.map(t => t.function?.name || 'unknown').join(', ');
        await ctx.sendText('[OpenClaw] Agent 正在执行工具调用: ' + toolNames);
      } else {
        await ctx.sendText('[OpenClaw] Agent 未返回有效内容');
      }
    } catch (err) {
      console.error('[openclaw] API调用失败:', err.message);
      let errMsg = err.message;
      if (err.response?.data?.error?.message) {
        errMsg = err.response.data.error.message;
      } else if (err.response?.status === 401 || err.response?.status === 403) {
        errMsg = '网关认证失败,请检查令牌';
      } else if (err.response?.status === 404) {
        errMsg = '端点未找到,请确认 Gateway 地址正确';
      } else if (err.code === 'ECONNABORTED') {
        errMsg = '请求超时,请检查 Gateway 服务状态';
      } else if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED') {
        errMsg = '无法连接到 Gateway 地址';
      }
      await ctx.sendText('[OpenClaw] 调用失败: ' + errMsg);
    }

    return true;
  },
};