← Back to Plugins
Tools

Native Moonshot

RexLai-TW By RexLai-TW 👁 8 views ▲ 0 votes

OpenClaw Moonshot Plugin 安裝教學

GitHub

Configuration Example

{
  "id": "moonshot-native",
  "name": "Moonshot Native Commands",
  "version": "1.0.0",
  "description": "Native commands for querying Moonshot API balance with trend analysis",
  "entry": "index.ts",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "alertThreshold": {
        "type": "number",
        "description": "Balance alert threshold in USD",
        "default": 5
      }
    }
  }
}

README

 好的,這是一份完整的 **OpenClaw Plugin 安裝教學**,專門針對 Moonshot API 餘額查詢插件,可以直接放到 GitHub 上。

---

# OpenClaw Moonshot Plugin 安裝教學

[![OpenClaw](https://img.shields.io/badge/OpenClaw-Plugin-blue)](https://openclaw.io)
[![Moonshot](https://img.shields.io/badge/Moonshot-API-green)](https://platform.moonshot.ai)

> 一個 OpenClaw Native Plugin,提供 Telegram 與 TUI 雙介面的 Moonshot API 餘額即時查詢功能。

---

## 目錄

1. [功能特色](#功能特色)
2. [系統需求](#系統需求)
3. [安裝步驟](#安裝步驟)
4. [配置說明](#配置說明)
5. [指令使用](#指令使用)
6. [故障排除](#故障排除)
7. [進階設定](#進階設定)
8. [檔案結構](#檔案結構)

---

## 功能特色

| 功能 | 說明 |
|------|------|
| **即時餘額查詢** | 總餘額、代金券、現金分項顯示 |
| **趨勢預估** | 自動計算日均消耗與預估可用天數 |
| **歷史記錄** | 自動儲存查詢記錄,支援趨勢分析 |
| **智能警報** | 低餘額、負債、即將到期多層級提醒 |
| **雙介面支援** | Telegram Bot + TUI 終端機 |
| **Native Command** | 直接執行,無需 AI 解析,反應极速 |

---

## 系統需求

- **OpenClaw** >= 0.3.0
- **Node.js** >= 18.0.0
- **curl** 與 **jq** 指令
- **Moonshot API Key** ([取得方式](https://platform.moonshot.ai))

---

## 安裝步驟

### Step 1: 確認 OpenClaw 已安裝

```bash
# 檢查版本
openclaw --version
# 應顯示 >= 0.3.0

# 若未安裝,請參考官網 https://openclaw.io/docs/installation
```

### Step 2: 建立 Plugin 目錄結構

```bash
# 建立 extensions 目錄(OpenClaw 會自動載入此目錄下的 Plugin)
mkdir -p ~/.openclaw/extensions/moonshot-native
```

### Step 3: 下載 Plugin 檔案

```bash
# 方式 A: 直接複製(本教學)
# 將下方提供的檔案內容複製到對應位置

# 方式 B: Git Clone(如果你 fork 到 GitHub)
git clone https://github.com/YOUR_USERNAME/openclaw-moonshot-plugin.git
cp -r openclaw-moonshot-plugin/* ~/.openclaw/extensions/moonshot-native/
```

### Step 4: 建立必要檔案

#### 4.1 Plugin Manifest(必需)

建立 `~/.openclaw/extensions/moonshot-native/openclaw.plugin.json`:

```json
{
  "id": "moonshot-native",
  "name": "Moonshot Native Commands",
  "version": "1.0.0",
  "description": "Native commands for querying Moonshot API balance with trend analysis",
  "entry": "index.ts",
  "configSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "alertThreshold": {
        "type": "number",
        "description": "Balance alert threshold in USD",
        "default": 5
      }
    }
  }
}
```

#### 4.2 主程式檔案

建立 `~/.openclaw/extensions/moonshot-native/index.ts`:

```typescript
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';

const execAsync = promisify(exec);

export default function (api: any) {
  const MOONSHOT_API_KEY = process.env.MOONSHOT_API_KEY;
  const ALERT_THRESHOLD = parseFloat(process.env.MOONSHOT_ALERT_THRESHOLD || '5');
  
  if (!MOONSHOT_API_KEY) {
    api.registerCommand({
      name: 'moonshot',
      handler: () => ({ 
        text: '❌ 錯誤:未設定 MOONSHOT_API_KEY\n\n請在 ~/.openclaw/.env 中加入:\nMOONSHOT_API_KEY=sk-你的-api-key' 
      })
    });
    return;
  }

  const HISTORY_DIR = path.join(process.env.HOME || '/root', '.openclaw', 'extensions', 'moonshot-native', 'history');
  if (!fs.existsSync(HISTORY_DIR)) {
    fs.mkdirSync(HISTORY_DIR, { recursive: true });
  }

  async function queryBalance() {
    const { stdout } = await execAsync(
      `curl -s --max-time 10 -H "Authorization: Bearer ${MOONSHOT_API_KEY}" -H "Content-Type: application/json" "https://api.moonshot.ai/v1/users/me/balance"`,
      { timeout: 15000 }
    );
    const data = JSON.parse(stdout);
    if (data.code !== 0) throw new Error(data.message || 'API error');
    return data.data;
  }

  function saveHistory(balanceData: any) {
    try {
      const record = { timestamp: new Date().toISOString(), balance: balanceData };
      const historyFile = path.join(HISTORY_DIR, `history_${new Date().toISOString().slice(0,7)}.jsonl`);
      fs.appendFileSync(historyFile, JSON.stringify(record) + '\n');
    } catch (e) {
      api.logger?.warn?.('[moonshot-native] Save history failed: ' + e);
    }
  }

  function getHistoryTrend(days: number = 7): any[] {
    try {
      const files = fs.readdirSync(HISTORY_DIR).filter(f => f.endsWith('.jsonl'));
      const records: any[] = [];
      files.forEach(file => {
        const content = fs.readFileSync(path.join(HISTORY_DIR, file), 'utf8');
        content.split('\n').forEach(line => {
          if (line.trim()) {
            try { 
              const record = JSON.parse(line);
              if (record.timestamp && record.balance) records.push(record);
            } catch (e) {}
          }
        });
      });
      records.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
      return records.slice(0, days);
    } catch (e) {
      return [];
    }
  }

  function calculateTrend(currentBalance: number, history: any[]) {
    const result = {
      avgDailyCost: 0,
      estimatedDays: 'N/A',
      trendDirection: '→',
      trendEmoji: '➡️',
      totalSpent: 0,
      recordsCount: history.length
    };

    if (history.length < 2) return result;

    const oldest = parseFloat(history[history.length - 1].balance?.available_balance || 0);
    const newest = history[0].balance?.available_balance 
      ? parseFloat(history[0].balance.available_balance) 
      : currentBalance;
    
    const totalDiff = oldest - newest;
    result.totalSpent = totalDiff > 0 ? totalDiff : 0;

    const oldestDate = new Date(history[history.length - 1].timestamp);
    const newestDate = new Date(history[0].timestamp);
    const daysDiff = Math.max(1, (newestDate.getTime() - oldestDate.getTime()) / (1000 * 60 * 60 * 24));

    result.avgDailyCost = result.totalSpent / daysDiff;

    if (result.avgDailyCost > 0) {
      result.trendDirection = '下降';
      result.trendEmoji = '📉';
    }

    if (result.avgDailyCost > 0 && currentBalance > 0) {
      const days = Math.floor(currentBalance / result.avgDailyCost);
      result.estimatedDays = days > 999 ? '999+' : days.toString();
    } else if (currentBalance > 0) {
      result.estimatedDays = '∞';
    }

    return result;
  }

  function formatDetailedReport(data: any, history: any[]): string {
    const available = parseFloat(data.available_balance);
    const voucher = parseFloat(data.voucher_balance);
    const cash = parseFloat(data.cash_balance);
    const availableStr = available.toFixed(5);
    const voucherStr = voucher.toFixed(5);
    const cashStr = cash.toFixed(5);
    
    const total = available;
    const voucherPct = total > 0 ? ((voucher / total) * 100).toFixed(2) : 0;
    const status = available > 0 ? '✅ 正常' : '❌ 已停用';
    
    const trend = calculateTrend(available, history);
    
    let changeText = '';
    if (history.length >= 1 && history[0].balance?.available_balance) {
      const lastBalance = parseFloat(history[0].balance.available_balance);
      const diff = available - lastBalance;
      if (Math.abs(diff) > 0.00001) {
        const emoji = diff > 0 ? '📈' : '📉';
        changeText = `\n  └─ 較上次: ${emoji} $${Math.abs(diff).toFixed(5)} USD`;
      }
    }

    let report = `
🟢 Moonshot API 詳細報告
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⏰ ${new Date().toLocaleString('zh-TW')}
🔑 ${MOONSHOT_API_KEY.slice(0, 8)}...${MOONSHOT_API_KEY.slice(-4)}

💰 餘額明細
  ├─ 總可用: $${availableStr} USD
  │   ├─ 🎫 代金券: $${voucherStr} USD (${voucherPct}%)
  │   └─ 💳 現金: $${cashStr} USD
  ├─ 組成: ${'█'.repeat(Math.floor(parseFloat(voucherPct)/5))}${'░'.repeat(20-Math.floor(parseFloat(voucherPct)/5))}${changeText}

📈 使用趨勢與預估
  ├─ 歷史記錄: ${trend.recordsCount} 筆
  ├─ 總消耗: $${trend.totalSpent.toFixed(5)} USD
  ├─ 日均消耗: $${trend.avgDailyCost.toFixed(5)} USD
  ├─ 趨勢方向: ${trend.trendEmoji} ${trend.trendDirection}
  └─ ⏳ 預估可用: ${trend.estimatedDays} 天

💳 帳戶狀態
  ├─ API 呼叫: ${status}
  ├─ 警報閾值: $${ALERT_THRESHOLD} USD
  └─ 帳戶健康: ${cash < 0 ? '⚠️ 現金欠款' : '✅ 正常'}`;

    if (available <= 0) {
      report += `\n\n🚨 嚴重警報:可用餘額已用完!\n     API 呼叫已停用,請立即儲值!`;
    } else if (available < ALERT_THRESHOLD) {
      report += `\n\n⚠️  警報:餘額低於 $${ALERT_THRESHOLD} USD\n     建議盡快儲值以避免服務中斷。`;
    }

    if (cash < 0) {
      report += `\n\n⚠️  注意:現金餘額為負數 ($${cashStr} USD)\n     可用餘額僅包含代金券部分。`;
    }

    if (trend.estimatedDays !== 'N/A' && trend.estimatedDays !== '∞' && parseInt(trend.estimatedDays) < 7) {
      report += `\n\n⏰ 提醒:預估僅剩 ${trend.estimatedDays} 天可用\n     建議本週內完成儲值。`;
    }

    report += `\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`;
    return report;
  }

  function formatShortReport(data: any): string {
    const available = parseFloat(data.available_balance).toFixed(2);
    const voucher = parseFloat(data.voucher_balance).toFixed(2);
    const cash = parseFloat(data.cash_balance).toFixed(2);
    const status = parseFloat(available) > 0 ? '✅' : '❌';
    return `💰 $${available} | 🎫 $${voucher} | 💳 $${cash} ${status}`;
  }

  function formatHistoryReport(records: any[]): string {
    if (records.length === 0) {
      return `📈 歷史趨勢\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n尚無記錄,請先執行 /moonshot 建立基準`;
    }

    const balances = records.map(r => parseFloat(r.balance?.available_balance || 0));
    const maxBalance = Math.max(...balances);
    const minBalance = Math.min(...balances);
    const firstBalance = balances[balances.length - 1];
    const lastBalance = balances[0];
    const totalChange = firstBalance - lastBalance;

    let report = `📈 Moonshot 餘額歷史(最近 ${records.length} 筆)\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`;
    
    records.forEach((record, index) => {
      const date = new Date(record.timestamp).toLocaleString('zh-TW', { 
        month: 'short', 
        day: 'numeric', 
        hour: '2-digit', 
        minute: '2-digit' 
      });
      const balance = parseFloat(record.balance?.available_balance || 0).toFixed(2);
      const emoji = index === 0 ? '📍' : '  ';
      
      let diffText = '';
      if (index < records.length - 1) {
        const prev = parseFloat(records[index + 1].balance?.available_balance || 0);
        const curr = parseFloat(record.balance?.available_balance || 0);
        const diff = curr - prev;
        if (Math.abs(diff) > 0.001) {
          const diffEmoji = diff > 0 ? '↑' : '↓';
          diffText = ` (${diffEmoji}$${Math.abs(diff).toFixed(2)})`;
        }
      }
      
 

... (truncated)
tools

Comments

Sign in to leave a comment

Loading comments...