Selaa lähdekoodia

feat: 仓位操作添加 Discord 通知

- 新增 discord.ts 模块,通过 webhook 发送 embed 消息
- 开仓/加仓/减仓/关仓操作成功、失败、跳过时均发送通知
- 手动关仓也会发送通知
- 通知包含操作类型、状态、目标地址、交易链接等信息

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
zhangchunrui 1 viikko sitten
vanhempi
commit
973059d5f2
2 muutettua tiedostoa jossa 180 lisäystä ja 4 poistoa
  1. 87 4
      src/lib/copier/index.ts
  2. 93 0
      src/lib/discord.ts

+ 87 - 4
src/lib/copier/index.ts

@@ -17,6 +17,7 @@ import type { ParsedOperation } from '../monitor/types'
 import { scaleAmount } from './ratio'
 import { getTokenPrices, calculateCopyScale } from './price'
 import { ensureSufficientBalances, getUsdcBalance, swapTokensBackToUsdc, USDC_MINT } from './swap'
+import { sendDiscordNotification } from '../discord'
 
 // We import from the built SDK dist using relative path
 // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -112,6 +113,14 @@ export class CopyEngine {
 
     console.log(`[CopyEngine] Manual close position TX: ${txid}`)
 
+    sendDiscordNotification({
+      operation: 'close_position',
+      status: 'success',
+      targetAddress: 'manual',
+      ourTxSig: txid,
+      ourNftMint: ourNftMint,
+    })
+
     // Swap received tokens back to USDC (if enabled)
     if (this.isSwapAfterCloseEnabled()) {
       await sleep(3000)
@@ -226,9 +235,14 @@ export class CopyEngine {
       // Check USDC balance before proceeding
       const neededUsd = mintA === USDC_MINT || mintB === USDC_MINT ? ourUsd * 0.6 : ourUsd
       if (!(await this.checkUsdcBalance(neededUsd))) {
-        updateCopyHistory(historyId, {
+        const skipMsg = `Insufficient USDC balance for $${ourUsd.toFixed(2)} position`
+        updateCopyHistory(historyId, { status: 'skipped', errorMessage: skipMsg })
+        sendDiscordNotification({
+          operation: 'open_position',
           status: 'skipped',
-          errorMessage: `Insufficient USDC balance for $${ourUsd.toFixed(2)} position`,
+          targetAddress: op.signer,
+          targetTxSig: op.signature,
+          errorMessage: skipMsg,
         })
         return
       }
@@ -305,10 +319,27 @@ export class CopyEngine {
         tickLower: tickLower!,
         tickUpper: tickUpper!,
       })
+
+      sendDiscordNotification({
+        operation: 'open_position',
+        status: 'success',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        ourTxSig: txid,
+        ourNftMint: ourNftMint || undefined,
+        extraFields: [{ name: '金额', value: `$${ourUsd.toFixed(2)}`, inline: true }],
+      })
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e)
       console.error(`[CopyEngine] Open position failed:`, msg)
       updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
+      sendDiscordNotification({
+        operation: 'open_position',
+        status: 'failed',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        errorMessage: msg,
+      })
     }
   }
 
@@ -383,9 +414,14 @@ export class CopyEngine {
       // Check USDC balance before proceeding
       const neededUsd = mintA === USDC_MINT || mintB === USDC_MINT ? ourUsd * 0.6 : ourUsd
       if (!(await this.checkUsdcBalance(neededUsd))) {
-        updateCopyHistory(historyId, {
+        const skipMsg = `Insufficient USDC balance for $${ourUsd.toFixed(2)} add liquidity`
+        updateCopyHistory(historyId, { status: 'skipped', errorMessage: skipMsg })
+        sendDiscordNotification({
+          operation: 'add_liquidity',
           status: 'skipped',
-          errorMessage: `Insufficient USDC balance for $${ourUsd.toFixed(2)} add liquidity`,
+          targetAddress: op.signer,
+          targetTxSig: op.signature,
+          errorMessage: skipMsg,
         })
         return
       }
@@ -434,10 +470,26 @@ export class CopyEngine {
         ourAmountB: scaledAmountB.toString(),
         status: 'success',
       })
+      sendDiscordNotification({
+        operation: 'add_liquidity',
+        status: 'success',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        ourTxSig: txid,
+        ourNftMint: mapping.our_nft_mint,
+        extraFields: [{ name: '金额', value: `$${ourUsd.toFixed(2)}`, inline: true }],
+      })
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e)
       console.error(`[CopyEngine] Add liquidity failed:`, msg)
       updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
+      sendDiscordNotification({
+        operation: 'add_liquidity',
+        status: 'failed',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        errorMessage: msg,
+      })
     }
   }
 
@@ -510,10 +562,25 @@ export class CopyEngine {
         ourTxSig: txid,
         status: 'success',
       })
+      sendDiscordNotification({
+        operation: 'decrease_liquidity',
+        status: 'success',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        ourTxSig: txid,
+        ourNftMint: mapping.our_nft_mint,
+      })
     } catch (e) {
       const msg = e instanceof Error ? e.message : String(e)
       console.error(`[CopyEngine] Decrease liquidity failed:`, msg)
       updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
+      sendDiscordNotification({
+        operation: 'decrease_liquidity',
+        status: 'failed',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        errorMessage: msg,
+      })
     }
   }
 
@@ -564,6 +631,15 @@ export class CopyEngine {
       })
       updatePositionMappingStatus(mapping.target_nft_mint, 'closed')
 
+      sendDiscordNotification({
+        operation: 'close_position',
+        status: 'success',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        ourTxSig: txid,
+        ourNftMint: mapping.our_nft_mint,
+      })
+
       // Swap received tokens back to USDC (if enabled)
       if (this.isSwapAfterCloseEnabled()) {
         await sleep(3000)
@@ -579,6 +655,13 @@ export class CopyEngine {
       const msg = e instanceof Error ? e.message : String(e)
       console.error(`[CopyEngine] Close position failed:`, msg)
       updateCopyHistory(historyId, { status: 'failed', errorMessage: msg })
+      sendDiscordNotification({
+        operation: 'close_position',
+        status: 'failed',
+        targetAddress: op.signer,
+        targetTxSig: op.signature,
+        errorMessage: msg,
+      })
     }
   }
 }

+ 93 - 0
src/lib/discord.ts

@@ -0,0 +1,93 @@
+const DISCORD_WEBHOOK_URL =
+  'https://discord.com/api/webhooks/1481484588651118604/RJDUi9zauxWjruUTVrzhl6-xQZ1qPdkkQtNNXWb44qxsmXgJ44BfQYDuoBrT4EbOE9ob'
+
+type OperationType = 'open_position' | 'add_liquidity' | 'decrease_liquidity' | 'close_position'
+
+const OP_LABELS: Record<OperationType, string> = {
+  open_position: '开仓',
+  add_liquidity: '加仓',
+  decrease_liquidity: '减仓',
+  close_position: '关仓',
+}
+
+const STATUS_COLORS: Record<string, number> = {
+  success: 0x00c853, // green
+  failed: 0xff1744, // red
+  skipped: 0xffa726, // orange
+}
+
+interface NotifyParams {
+  operation: OperationType
+  status: 'success' | 'failed' | 'skipped'
+  targetAddress: string
+  targetTxSig?: string
+  ourTxSig?: string
+  ourNftMint?: string
+  errorMessage?: string
+  extraFields?: { name: string; value: string; inline?: boolean }[]
+}
+
+export async function sendDiscordNotification(params: NotifyParams): Promise<void> {
+  const { operation, status, targetAddress, targetTxSig, ourTxSig, ourNftMint, errorMessage, extraFields } = params
+  const label = OP_LABELS[operation] || operation
+  const statusText = status === 'success' ? '成功' : status === 'failed' ? '失败' : '跳过'
+
+  const fields: { name: string; value: string; inline?: boolean }[] = [
+    { name: '操作', value: label, inline: true },
+    { name: '状态', value: statusText, inline: true },
+    { name: '目标地址', value: `\`${shortAddr(targetAddress)}\``, inline: true },
+  ]
+
+  if (targetTxSig) {
+    fields.push({
+      name: '目标交易',
+      value: `[${shortAddr(targetTxSig)}](https://solscan.io/tx/${targetTxSig})`,
+      inline: true,
+    })
+  }
+
+  if (ourTxSig) {
+    fields.push({
+      name: '我方交易',
+      value: `[${shortAddr(ourTxSig)}](https://solscan.io/tx/${ourTxSig})`,
+      inline: true,
+    })
+  }
+
+  if (ourNftMint) {
+    fields.push({ name: 'NFT Mint', value: `\`${shortAddr(ourNftMint)}\``, inline: true })
+  }
+
+  if (errorMessage) {
+    fields.push({ name: '错误信息', value: errorMessage.slice(0, 256) })
+  }
+
+  if (extraFields) {
+    fields.push(...extraFields)
+  }
+
+  const embed = {
+    title: `跟单${label} - ${statusText}`,
+    color: STATUS_COLORS[status] ?? 0x9e9e9e,
+    fields,
+    timestamp: new Date().toISOString(),
+  }
+
+  try {
+    const res = await fetch(DISCORD_WEBHOOK_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ embeds: [embed] }),
+    })
+    if (!res.ok) {
+      console.warn(`[Discord] Webhook failed: ${res.status} ${res.statusText}`)
+    }
+  } catch (e) {
+    console.warn(`[Discord] Webhook error:`, e instanceof Error ? e.message : e)
+  }
+}
+
+function shortAddr(addr: string): string {
+  if (addr.length <= 12) return addr
+  return `${addr.slice(0, 6)}...${addr.slice(-4)}`
+}