原贴: https://www.nodeseek.com/post-681761-1

改了一个 discord webhook 通知版本

img

NanoMuse · Discord Webhook 通知配置脚本(点击展开完整代码)
/* ═══════════════════════════════
   NanoMuse · Discord Webhook 通知配置
   ═══════════════════════════════ */
const DISCORD_WEBHOOK_URL = "你的 Discord Webhook URL"; // Discord 频道设置 → 集成 → Webhook → 复制 Webhook URL
const PANEL_URL = "你的域名"; // 面板地址(用于按钮跳转和 Footer 域名提取)
const PHOTO_URL = "https://img.uppic.to/2026/05/05/og-image.png"; // 通知头图地址
const SITE_NAME = "你的站点名称"; // 站点名称(显示在 Footer)
const WEBHOOK_USERNAME = "NanoMuse"; // Discord Webhook 显示名称,可留空
const WEBHOOK_AVATAR_URL = ""; // Discord Webhook 头像,可留空
/* ═══════════════════════════════ */
function escapeMd(s) {
  return String(s ?? "")
    .replace(/\\/g, "\\\\")
    .replace(/\*/g, "\\*")
    .replace(/_/g, "\\_")
    .replace(/~/g, "\\~")
    .replace(/`/g, "\\`")
    .replace(/\|/g, "\\|")
    .replace(/>/g, "\\>");
}
function code(s) {
  return "`" + String(s ?? "—").replace(/`/g, "ˋ") + "`";
}
function blockquote(lines) {
  return String(lines)
    .split("\n")
    .map(line => `> ${line}`)
    .join("\n");
}
function discordColorByEvent(eventName) {
  const colors = {
    Offline: 0xff3b30,
    Online: 0x3ba55d,
    Alert: 0xffcc00,
    Renew: 0x9b59b6,
    Expire: 0xff9500,
    Test: 0x3498db
  };
  return colors[eventName] || 0x5865f2;
}
async function sendMessage(message, title, instanceId = null, eventName = null) {
  const webhookUrl = DISCORD_WEBHOOK_URL;
  const panelUrl = PANEL_URL;
  const photoUrl = PHOTO_URL;
  if (!webhookUrl) return false;
  const components = [
    {
      type: 1,
      components: [
        {
          type: 2,
          style: 5,
          label: "◈ 控制台",
          url: panelUrl
        },
        ...(instanceId && instanceId !== "未知"
          ? [{
              type: 2,
              style: 5,
              label: "◈ 详情",
              url: `${panelUrl.replace(/\/+$/, "")}/instance/${instanceId}`
            }]
          : [])
      ]
    }
  ];
  const embed = {
    title: title || "NanoMuse 通知",
    description: message.slice(0, 4096),
    color: discordColorByEvent(eventName),
    image: photoUrl ? { url: photoUrl } : undefined,
    footer: {
      text: `${SITE_NAME} · ${panelUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
    },
    timestamp: new Date().toISOString()
  };
  const payload = {
    username: WEBHOOK_USERNAME || undefined,
    avatar_url: WEBHOOK_AVATAR_URL || undefined,
    embeds: [embed],
    components,
    allowed_mentions: { parse: [] }
  };
  try {
    const resp = await fetch(webhookUrl + "?wait=true&with_components=true", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload)
    });
    if (resp.ok) return true;
    const text = await resp.text().catch(() => "");
    console.error("Discord webhook failed:", resp.status, text);
    // 兜底:如果按钮组件被环境或 Discord 限制拒绝,则只发送 Embed
    const fallbackResp = await fetch(webhookUrl + "?wait=true", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        username: WEBHOOK_USERNAME || undefined,
        avatar_url: WEBHOOK_AVATAR_URL || undefined,
        embeds: [embed],
        allowed_mentions: { parse: [] }
      })
    });
    return fallbackResp.ok;
  } catch (e) {
    console.error("Discord webhook error:", e);
    return false;
  }
}
async function sendEvent(event) {
  try {
    const toUTC8 = (t) => {
      if (!t || String(t).startsWith("0001")) return null;
      const d = new Date(String(t).replace(/\.\d+Z$/, "Z"));
      if (isNaN(d.getTime())) return null;
      const u = new Date(d.getTime() + 8 * 36e5);
      const p = (n) => String(n).padStart(2, "0");
      return `${u.getUTCFullYear()}.${p(u.getUTCMonth() + 1)}.${p(u.getUTCDate())} ${p(u.getUTCHours())}:${p(u.getUTCMinutes())}:${p(u.getUTCSeconds())}`;
    };
    const nowUTC8 = () => {
      const u = new Date(Date.now() + 8 * 36e5);
      const p = (n) => String(n).padStart(2, "0");
      return `${u.getUTCFullYear()}.${p(u.getUTCMonth() + 1)}.${p(u.getUTCDate())} ${p(u.getUTCHours())}:${p(u.getUTCMinutes())}:${p(u.getUTCSeconds())}`;
    };
    const fmtSize = (b) => {
      if (!b || b === 0) return null;
      const g = b / 1073741824;
      return g >= 1024 ? `${(g / 1024).toFixed(1)}T` : g >= 1 ? `${g.toFixed(1)}G` : `${Math.round(b / 1048576)}M`;
    };
    const fmtCPU = (c) => (!c ? null : parseFloat(Number(c).toFixed(1)) + "C");
    const fmtTraffic = (b) => {
      if (!b || b === 0) return "∞";
      const g = b / 1073741824;
      return g >= 1024 ? `${(g / 1024).toFixed(1)}T` : `${g.toFixed(0)}G`;
    };
    const maskV4 = (ip) => {
      if (!ip) return null;
      const p = ip.split(".");
      return p.length === 4 ? `${p[0]}.${p[1]}.*.*` : null;
    };
    const maskV6 = (ip) => {
      if (!ip) return null;
      const p = ip.split(":");
      return p.length > 2 ? `${p[0]}:${p[1]}:*:*` : null;
    };
    const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
    const domain = PANEL_URL.replace(/^https?:\/\//, "").replace(/\/+$/, "");
    /* ═══════════════════════════════
       台词库 · voice / advice
       Discord 使用 Markdown,不再使用 Telegram HTML 标签
       ═══════════════════════════════ */
    const voiceLines = {
      Offline: {
        tag: "LINK LOST", cn: "链路中断", icon: "🔻",
        lines: (n) => [
          `与 **${escapeMd(n)}** 的信号已归零。...不是波动,是彻底消失了。观测者。`,
          `**${escapeMd(n)}** 从我的感知范围消失了。链路中断,等待重新捕获。`,
          `检测到异常——**${escapeMd(n)}** 的心跳信号归零了。`,
          `...信号断了。**${escapeMd(n)}** 不在了,观测者。`
        ],
        advice: (n) => [
          `建议检查该节点的物理链路与上游连通性。如短时间内未恢复,可尝试触发系统重启。...需要我持续追踪它的信号吗,观测者?`,
          `上游链路可能存在中断。建议优先确认机房侧状态,必要时触发系统重启。`,
          `建议排查网络路由与物理连接。若长时间无响应,可能需要联系机房确认。...我会继续等它的。`,
          `链路丢失原因未知。建议先尝试触发系统重启,若仍无法恢复,可能需要执行镜像重构。`
        ]
      },
      Online: {
        tag: "LINK RESTORED", cn: "信号恢复", icon: "🔹",
        lines: (n) => [
          `**${escapeMd(n)}** 重新出现在感知范围内。链路已重建。...它回来了。`,
          `观测者,好消息——**${escapeMd(n)}** 的信号恢复了。`,
          `感知到 **${escapeMd(n)}** 的心跳恢复。一切稳定。`,
          `**${escapeMd(n)}** 已归位。...松了口气。`
        ],
        advice: (n) => [
          `建议观察 30 分钟确认链路稳定性。中断期间的数据缺口已标记,如需回溯请告知。`,
          `链路已恢复,建议关注后续运行状态是否平稳。如有异常波动我会立即通知。`,
          `信号稳定中。建议短期内留意该节点的响应延时,确认无残留问题。`
        ]
      },
      Alert: {
        tag: "ANOMALY", cn: "信号异常", icon: "⚡",
        lines: (n) => [
          `**${escapeMd(n)}** 的运行参数偏离了安全区间。还没到临界,但我在持续观测。`,
          `**${escapeMd(n)}** 触发了告警阈值。指标异常,请注意。`,
          `检测到波动——**${escapeMd(n)}** 的状态不太稳定。`,
          `...有些不对劲。**${escapeMd(n)}** 需要你看一下,观测者。`
        ],
        advice: (n) => [
          `建议排查近期负载变化与进程占用。可先尝试触发系统重启,若重启后指标仍未回归安全区间,可能需要执行镜像重构。`,
          `运行参数偏离可能与资源争抢有关。建议触发系统重启释放占用,持续异常则考虑镜像重构。`,
          `建议检查是否有异常进程导致资源溢出。必要时触发系统重启。...我会继续盯着的。`,
          `指标偏离中。建议优先触发系统重启恢复基线,若问题反复出现,镜像重构可能是更彻底的方案。`
        ]
      },
      Renew: {
        tag: "ENERGY CHARGED", cn: "能源补充", icon: "✨",
        lines: (n) => [
          `**${escapeMd(n)}** 的能源已补充完毕。运行周期已延伸。`,
          `收到了,观测者。**${escapeMd(n)}** 又可以陪我们更久了。`,
          `能源写入完成——**${escapeMd(n)}** 的生命周期已刷新。`,
          `**${escapeMd(n)}** 的倒计时重置了。...很好。`
        ],
        advice: (n) => [
          `能源充足,当前无需额外操作。下一个周期终止点我会提前提醒你,观测者。`,
          `一切正常运转中。周期终止前我会再次通知,不用担心。`,
          `能源已到位,节点运行稳定。...放心交给我观测就好。`
        ]
      },
      Expire: {
        tag: "ENERGY LOW", cn: "能源临界", icon: "⏰",
        lines: (n) => [
          `**${escapeMd(n)}** 的运行周期即将结束。剩余时间不多了。...观测者,需要你做决定。`,
          `**${escapeMd(n)}** 的能源储备即将耗尽。维持时间有限。`,
          `提醒一下——**${escapeMd(n)}** 的周期终止点在逼近了。`,
          `**${escapeMd(n)}** 的时间快用完了。...别忘了它,观测者。`
        ],
        advice: (n) => [
          `如不补充能源,该节点将在周期终止后停止响应。...观测者,要补充吗?`,
          `能源即将耗尽。建议尽快确认是否继续维持该节点的运行。`,
          `周期终止后节点将进入休眠。如需保持在线,请及时补充能源。...我在等你的指令。`
        ]
      },
      Test: {
        tag: "DIAGNOSTIC", cn: "系统诊断", icon: "🔧",
        lines: (n) => [
          `通信回路测试完毕。全链路信号正常。...随时待命,观测者。`,
          `诊断模式启动。所有管道畅通,系统运行正常。`,
          `系统自检通过。...有什么需要我做的吗,观测者?`,
          `诊断完成。一切就绪,信号清晰。`
        ],
        advice: (n) => [
          `全链路畅通,无异常。...一切尽在掌握中,观测者。`,
          `系统状态良好,随时可以执行任务。`,
          `诊断回路正常。如有新指令,我随时响应。`
        ]
      }
    };
    const evData = voiceLines[event.event] || {
      tag: event.event,
      cn: event.event,
      icon: "◆",
      lines: (n) => [`收到事件通知:**${escapeMd(n || "未知")}**`],
      advice: () => []
    };
    const mockClient = {
      uuid: "diag-0000-nano-muse",
      name: "NanoMuse-Diag",
      region: "Tokyo",
      cpu_cores: 4,
      mem_total: 8589934592,
      disk_total: 85899345920,
      tags: "1Gbps<green>;3T<blue>",
      ipv4: "103.56.168.1",
      ipv6: "2001:db8:cafe:1::1",
      traffic_limit: 3221225472000,
      traffic_limit_type: "max",
      price: 29.99,
      currency: "$",
      billing_cycle: 30,
      expired_at: "2026-12-31T00:00:00Z"
    };
    const hasClients = event.clients && event.clients.length > 0;
    const isMock = !hasClients && event.event === "Test";
    const c = hasClients ? event.clients[0] : (isMock ? mockClient : null);
    const targetId = c ? c.uuid : null;
    const nodeName = c ? (c.name || "—") : "—";
    let L = [];
    /* ── Header ── */
    const title = `${evData.icon} ${evData.tag}${evData.cn}`;
    L.push(`## ${title}`);
    if (isMock) L.push(`_〔 模拟数据 〕_`);
    /* ── 台词 ── */
    L.push("");
    L.push(`_${pick(evData.lines(nodeName))}_`);
    if (c) {
      const region = c.region || "";
      /* ── 节点信息 ── */
      let b1 = [];
      b1.push(`**◈ ${escapeMd(nodeName)}**${region ? ` ${code(`[ ${region} ]`)}` : ""}`);
      const specs = [fmtCPU(c.cpu_cores), fmtSize(c.mem_total), fmtSize(c.disk_total)].filter(Boolean);
      if (specs.length) {
        b1.push(`┊ 核心规格 ${code(specs.join(" | "))}`);
      }
      if (c.tags) {
        const labels = c.tags
          .split(";")
          .map(t => t.replace(/<[^>]+>/g, "").trim())
          .filter(Boolean);
        if (labels.length) b1.push(`┊ 标识 ${code(labels.join(" · "))}`);
      }
      L.push("");
      L.push(blockquote(b1.join("\n")));
      /* ── 链路状态 ── */
      let b2 = [];
      const v4 = maskV4(c.ipv4);
      const v6 = maskV6(c.ipv6);
      if (v4 || v6) {
        b2.push(`**◈ 链路状态**`);
        if (v4) b2.push(`┊ 链路地址 v4 ${code(v4)}`);
        if (v6) b2.push(`┊ 链路地址 v6 ${code(v6)}`);
      }
      if (b2.length) {
        L.push("");
        L.push(blockquote(b2.join("\n")));
      }
      /* ── 资源状态 ── */
      let b3 = [];
      const cur = c.currency || "$";
      const price = c.price || 0;
      const hasTraffic = c.traffic_limit && c.traffic_limit > 0;
      const hasPrice = price > 0;
      const hasExpiry = toUTC8(c.expired_at);
      if (hasTraffic || hasPrice || hasExpiry) {
        b3.push(`**◈ 资源状态**`);
      }
      if (hasTraffic) {
        const limitType = c.traffic_limit_type ? ` (${c.traffic_limit_type})` : "";
        b3.push(`┊ 传输额度 ${code(`$$   {fmtTraffic(c.traffic_limit)}   $${limitType}`)}`);
      }
      if (hasPrice) {
        b3.push(`┊ 能量消耗 ${code(`$$   {cur}   $${price} 单位 / 周期`)}`);
      }
      if (hasExpiry) {
        let expLine = `┊ 能源周期 `;
        try {
          const ed = new Date(String(c.expired_at).replace(/\.\d+Z$/, "Z"));
          const days = Math.ceil((ed - new Date()) / 86400000);
          if (days >= 0) expLine += code(`剩余 ${days} 天`);
          else expLine += code(`已耗尽 ${Math.abs(days)} 天`);
        } catch (e) {
          expLine += code(hasExpiry.substring(0, 10));
        }
        b3.push(expLine);
      }
      if (b3.length) {
        L.push("");
        L.push(blockquote(b3.join("\n")));
      }
    }
    /* ── 处理建议 ── */
    const adviceList = evData.advice(nodeName);
    if (adviceList.length) {
      L.push("");
      L.push(blockquote(`💡 _${pick(adviceList)}_`));
    }
    /* ── 时间戳 ── */
    L.push("");
    L.push(`⏱ ${code(toUTC8(event.time) || nowUTC8())} _UTC+8_`);
    /* ── 自定义消息 ── */
    if (event.message && event.message.trim()) {
      L.push("");
      L.push(blockquote(`💬 _${escapeMd(event.message.trim())}_`));
    }
    /* ── Footer 文本也保留在正文里,Embed footer 里也会显示 */
    L.push("");
    L.push(`**${escapeMd(SITE_NAME)}** · _${escapeMd(domain)}_`);
    return await sendMessage(L.join("\n"), title, targetId, event.event);
  } catch (e) {
    return await sendMessage(`Error: ${escapeMd(e.message)}`, "⚠️ Error", null, "Alert");
  }
}
···