nansen-cli 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +4 -3
  3. package/skills/{nansen-dca-watch → nansen-dca-tracker}/SKILL.md +1 -1
  4. package/skills/{nansen-defi-exposure → nansen-defi-positions}/SKILL.md +1 -1
  5. package/skills/{nansen-exit-signal → nansen-exit-signals}/SKILL.md +1 -1
  6. package/skills/{nansen-fund-watch → nansen-fund-tracker}/SKILL.md +1 -1
  7. package/skills/{nansen-search → nansen-general-search}/SKILL.md +1 -1
  8. package/skills/{nansen-holder-quality → nansen-holder-analysis}/SKILL.md +1 -1
  9. package/skills/{nansen-perp-scan → nansen-perp-screener}/SKILL.md +1 -1
  10. package/skills/{nansen-perp-trader → nansen-perp-trader-profile}/SKILL.md +1 -1
  11. package/skills/{nansen-pm-deep-dive → nansen-polymarket-deep-dive}/SKILL.md +1 -1
  12. package/skills/{nansen-pm-insider-scan → nansen-polymarket-insider-scan}/SKILL.md +1 -1
  13. package/skills/{nansen-polymarket-trader → nansen-polymarket-trader-profile}/SKILL.md +1 -1
  14. package/skills/{nansen-portfolio-history → nansen-portfolio-tracker}/SKILL.md +1 -1
  15. package/skills/{nansen-prediction-market → nansen-prediction-markets}/SKILL.md +1 -1
  16. package/skills/{nansen-cross-chain-flow → nansen-sm-cross-chain-flows}/SKILL.md +1 -1
  17. package/skills/{nansen-alerts → nansen-smart-alerts}/SKILL.md +1 -1
  18. package/skills/{nansen-alpha-discovery → nansen-smart-money-alpha}/SKILL.md +1 -1
  19. package/skills/{nansen-smart-money → nansen-smart-money-tracker}/SKILL.md +1 -1
  20. package/skills/{nansen-sm-trend → nansen-smart-money-trend}/SKILL.md +1 -1
  21. package/skills/{nansen-token → nansen-token-research}/SKILL.md +1 -1
  22. package/skills/{nansen-token-discovery → nansen-token-screener}/SKILL.md +1 -1
  23. package/skills/{nansen-token-forensics → nansen-token-transfer-analysis}/SKILL.md +1 -1
  24. package/skills/{nansen-trade → nansen-trading}/SKILL.md +2 -2
  25. package/skills/{nansen-batch-wallet → nansen-wallet-batch}/SKILL.md +1 -1
  26. package/skills/{nansen-wallet-attribution → nansen-wallet-clustering}/SKILL.md +1 -1
  27. package/skills/{nansen-wallet-analysis → nansen-wallet-deep-dive}/SKILL.md +1 -1
  28. package/skills/{nansen-wallet-migration → nansen-wallet-keychain-migration}/SKILL.md +1 -1
  29. package/skills/{nansen-wallet → nansen-wallet-manager}/SKILL.md +1 -1
  30. package/skills/{nansen-profiler → nansen-wallet-profiler}/SKILL.md +1 -1
  31. package/skills/{nansen-web-fetch → nansen-web-fetcher}/SKILL.md +1 -1
  32. package/skills/{nansen-web-search → nansen-web-searcher}/SKILL.md +1 -1
  33. package/src/commands/alerts.js +717 -0
  34. /package/skills/{nansen-wallet-attribution → nansen-wallet-clustering}/REFERENCE.md +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#306](https://github.com/nansen-ai/nansen-cli/pull/306) [`f685eb8`](https://github.com/nansen-ai/nansen-cli/commit/f685eb8b87d2915a7a94e8c1a3d92f84e4802e4a) Thanks [@0xlaveen](https://github.com/0xlaveen)! - Rename 30 skills for clarity and clawhub slug uniqueness. Abbreviations expanded (`pm` → `polymarket`, `sm` prefix added where relevant), ambiguous names made specific (`nansen-wallet` → `nansen-wallet-manager`, `nansen-profiler` → `nansen-wallet-profiler`, `nansen-search` → `nansen-general-search`, `nansen-trade` → `nansen-trading`, etc.).
8
+
9
+ ### Patch Changes
10
+
11
+ - [#308](https://github.com/nansen-ai/nansen-cli/pull/308) [`569d7d4`](https://github.com/nansen-ai/nansen-cli/commit/569d7d43e428732d3c10b4e57368cb12ef0086c3) Thanks [@kome12](https://github.com/kome12)! - fix: include src subdirectories in npm package
12
+
13
+ The `files` field in package.json used `src/*.js` which only matched files directly in `src/`, causing `src/commands/` to be missing from the 1.18.0 publish. Changed to `src/**/*.js` to include all subdirectories recursively, and added `!src/__tests__/**` to exclude test files from the package.
14
+
3
15
  ## 1.18.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -8,8 +8,9 @@
8
8
  "nansen": "./src/index.js"
9
9
  },
10
10
  "files": [
11
- "src/*.js",
12
- "src/*.json",
11
+ "src/**/*.js",
12
+ "src/**/*.json",
13
+ "!src/__tests__/**",
13
14
  "skills/**",
14
15
  "CHANGELOG.md"
15
16
  ],
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-dca-watch
2
+ name: nansen-dca-tracker
3
3
  description: "What tokens are whales dollar-cost averaging into? Jupiter DCA strategies by smart money and target token fundamentals."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-defi-exposure
2
+ name: nansen-defi-positions
3
3
  description: "What DeFi positions does a wallet hold? Protocol-by-protocol breakdown of assets, debts, and rewards across chains."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-exit-signal
2
+ name: nansen-exit-signals
3
3
  description: "Is smart money exiting a token I hold? Net flow direction, seller breakdown by label, and recent SM trades."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-fund-watch
2
+ name: nansen-fund-tracker
3
3
  description: "What are crypto funds and VCs holding right now? Cross-chain fund portfolios and net accumulation signals."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-search
2
+ name: nansen-general-search
3
3
  description: Search for tokens or entities by name. Use when you have a token name and need the full address, or want to find an entity.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-holder-quality
2
+ name: nansen-holder-analysis
3
3
  description: "Is this token held by quality wallets or retail noise? SM holder ratio, flow breakdown by label, and recent buyer quality."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-perp-scan
2
+ name: nansen-perp-screener
3
3
  description: "What is the state of the Hyperliquid perp market? Top contracts by volume/OI, trader leaderboard, and SM perp activity."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-perp-trader
2
+ name: nansen-perp-trader-profile
3
3
  description: "Deep dive on a Hyperliquid perp trader. Identity, open positions, recent trades, and overall PnL."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-pm-deep-dive
2
+ name: nansen-polymarket-deep-dive
3
3
  description: "Deep dive on a Polymarket market — OHLCV, orderbook, top holders, positions, trades, and PnL leaderboard. Use when analysing a specific prediction market."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-pm-insider-scan
2
+ name: nansen-polymarket-insider-scan
3
3
  description: "Scan a resolved Polymarket market for wallets exhibiting suspicious trading patterns: fresh funding, single-market focus, extreme ROI, late entry at high prices."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-polymarket-trader
2
+ name: nansen-polymarket-trader-profile
3
3
  description: "What is a Polymarket trader betting on? Trades by address, PnL breakdown, and market context. Use when analysing a specific Polymarket wallet."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-portfolio-history
2
+ name: nansen-portfolio-tracker
3
3
  description: "How has a wallet's portfolio changed over time? Historical balances, current snapshot, and per-token PnL."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-prediction-market
2
+ name: nansen-prediction-markets
3
3
  description: "Polymarket screeners — discover trending events, top markets by volume, and search for specific markets. Use when browsing what's happening on prediction markets."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-cross-chain-flow
2
+ name: nansen-sm-cross-chain-flows
3
3
  description: "Is SM buying this token on one chain but selling on another? Detect capital rotation."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-alerts
2
+ name: nansen-smart-alerts
3
3
  description: Manage smart alerts — list, create, update, toggle, delete. Use when setting up or managing token flow alerts, smart money alerts, or notification rules.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-alpha-discovery
2
+ name: nansen-smart-money-alpha
3
3
  description: "What tokens is smart money accumulating before they pump? Token screener with SM filter cross-referenced against netflow."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-smart-money
2
+ name: nansen-smart-money-tracker
3
3
  description: Smart money tracking — netflow, trades, holdings, perp trades. Use when finding what smart money wallets are buying/selling or tracking whale activity.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-sm-trend
2
+ name: nansen-smart-money-trend
3
3
  description: "Has SM been in this token for weeks, or did they just enter? Are they still buying?"
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-token
2
+ name: nansen-token-research
3
3
  description: Token deep dive — info, OHLCV, holders, flows, flow intelligence, who bought/sold, DEX trades, PnL, perp trades, perp positions, perp PnL leaderboard. Use when researching a specific token in depth.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-token-discovery
2
+ name: nansen-token-screener
3
3
  description: "Discover trending tokens — screener, SM holdings, Nansen indicators, and flow intelligence for promising finds. Use when scanning for new tokens or screening what's hot."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-token-forensics
2
+ name: nansen-token-transfer-analysis
3
3
  description: "Where is this token moving and why? Large transfers, flow trends over time, and breakdown by wallet label."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-trade
2
+ name: nansen-trading
3
3
  description: Execute DEX swaps on Solana or Base. Use when buying or selling a token, getting a swap quote, or executing a trade.
4
4
  metadata:
5
5
  openclaw:
@@ -86,7 +86,7 @@ nansen trade execute --quote "$quote_id"
86
86
  | `NANSEN_WALLET_PASSWORD` | **Required for `trade execute`.** Wallet encryption password — persisted in `~/.nansen/.env`. Source before executing: `source ~/.nansen/.env && nansen trade execute ...` |
87
87
  | `NANSEN_API_KEY` | API key (also set via `nansen login`) |
88
88
 
89
- > **Agents:** Never hold a wallet password only in session memory. If `NANSEN_WALLET_PASSWORD` is not in `~/.nansen/.env`, follow the setup flow in the nansen-wallet skill Password Policy to generate and persist one before proceeding.
89
+ > **Agents:** Never hold a wallet password only in session memory. If `NANSEN_WALLET_PASSWORD` is not in `~/.nansen/.env`, follow the setup flow in the nansen-wallet-manager skill Password Policy to generate and persist one before proceeding.
90
90
 
91
91
  ## Notes
92
92
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-batch-wallet
2
+ name: nansen-wallet-batch
3
3
  description: "Which of these addresses are smart money? Batch-profile a list in one call."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-wallet-attribution
2
+ name: nansen-wallet-clustering
3
3
  description: "Cluster and attribute related wallets — funding chains, shared signers, CEX deposit patterns. Use when tracing wallet ownership, comparing two wallets, finding wallet relationships, governance voters, or related address clusters."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-wallet-analysis
2
+ name: nansen-wallet-deep-dive
3
3
  description: "Who is this wallet and what have they been doing? Identity labels, balance, PnL summary, recent transactions, perp positions, and counterparties."
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-wallet-migration
2
+ name: nansen-wallet-keychain-migration
3
3
  description: Migrate an existing nansen-cli wallet from insecure password storage (env files, .credentials) to the new secure keychain-backed flow.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-wallet
2
+ name: nansen-wallet-manager
3
3
  description: Wallet management — create, list, show, export, send, delete. Use when creating wallets, checking balances, or sending tokens.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-profiler
2
+ name: nansen-wallet-profiler
3
3
  description: Wallet profiler — balance, PnL, labels, transactions, counterparties, related wallets, batch, trace, compare. Use when analysing a specific wallet address or comparing wallets.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-web-fetch
2
+ name: nansen-web-fetcher
3
3
  description: Fetch and analyze content from one or more URLs using AI (Gemini 2.5 Flash). Use when you have specific URLs and need to extract or summarize their content. Pairs well with `nansen web search` results.
4
4
  metadata:
5
5
  openclaw:
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: nansen-web-search
2
+ name: nansen-web-searcher
3
3
  description: Search the web for one or more queries in parallel. Use when you need current information, news, prices, or any web content to complement on-chain Nansen data.
4
4
  metadata:
5
5
  openclaw:
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Nansen CLI - Alerts command
3
+ * Smart alert CRUD with type-specific named flags.
4
+ */
5
+
6
+ import { NansenError, ErrorCode } from '../api.js';
7
+
8
+ // ============= Formatting =============
9
+
10
+ // Format alerts list as human-readable table
11
+ export function formatAlertsTable(alerts) {
12
+ if (!Array.isArray(alerts) || alerts.length === 0) {
13
+ return 'No alerts';
14
+ }
15
+
16
+ const formatChannels = (channels) => {
17
+ if (!channels || !Array.isArray(channels) || channels.length === 0) return '';
18
+ return channels.map(ch => ch.type).join(', ');
19
+ };
20
+
21
+ const formatEnabled = (isEnabled) => isEnabled ? '✓' : '✗';
22
+
23
+ const truncate = (str, maxLen) => {
24
+ if (!str) return '';
25
+ return str.length > maxLen ? str.slice(0, maxLen - 1) + '…' : str;
26
+ };
27
+
28
+ const idWidth = Math.max(2, Math.max(...alerts.map(a => (a.id || '').length)));
29
+ const nameWidth = Math.max(4, Math.min(30, Math.max(...alerts.map(a => (a.name || '').length))));
30
+ const typeWidth = Math.max(4, Math.min(25, Math.max(...alerts.map(a => (a.type || '').length))));
31
+ const channelsWidth = Math.max(8, Math.min(20, Math.max(...alerts.map(a => formatChannels(a.channels).length))));
32
+
33
+ const lines = [];
34
+ const header = `${'ID'.padEnd(idWidth)} │ ${'NAME'.padEnd(nameWidth)} │ ${'TYPE'.padEnd(typeWidth)} │ ${'ENABLED'.padEnd(7)} │ ${'CHANNELS'.padEnd(channelsWidth)}`;
35
+ lines.push(header);
36
+ lines.push('─'.repeat(idWidth) + '─┼─' + '─'.repeat(nameWidth) + '─┼─' + '─'.repeat(typeWidth) + '─┼─' + '─'.repeat(7) + '─┼─' + '─'.repeat(channelsWidth));
37
+
38
+ for (const alert of alerts) {
39
+ const id = (alert.id || '').padEnd(idWidth);
40
+ const name = truncate(alert.name, nameWidth).padEnd(nameWidth);
41
+ const type = truncate(alert.type, typeWidth).padEnd(typeWidth);
42
+ const enabled = formatEnabled(alert.isEnabled).padEnd(7);
43
+ const channels = truncate(formatChannels(alert.channels), channelsWidth).padEnd(channelsWidth);
44
+ lines.push(`${id} │ ${name} │ ${type} │ ${enabled} │ ${channels}`);
45
+ }
46
+
47
+ return lines.join('\n');
48
+ }
49
+
50
+ // ============= Data Builders =============
51
+
52
+ /**
53
+ * Parse a token string "address:chain" into { address, chain }.
54
+ * Handles single string or array of strings (from repeated --token flags).
55
+ */
56
+ function parseTokens(tokenArg) {
57
+ if (!tokenArg) return undefined;
58
+ const tokens = Array.isArray(tokenArg) ? tokenArg : [tokenArg];
59
+ return tokens.map(t => {
60
+ const colonIdx = t.lastIndexOf(':');
61
+ if (colonIdx === -1) throw new NansenError(`Invalid token format: "${t}". Expected address:chain`, ErrorCode.INVALID_PARAMS);
62
+ return { address: t.slice(0, colonIdx), chain: t.slice(colonIdx + 1) };
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Parse a subject string "type:value" into { type, value }.
68
+ * Handles single string or array (from repeated --subject flags).
69
+ */
70
+ function parseSubjects(subjectArg) {
71
+ if (!subjectArg) return undefined;
72
+ const subjects = Array.isArray(subjectArg) ? subjectArg : [subjectArg];
73
+ return subjects.map(s => {
74
+ const colonIdx = s.indexOf(':');
75
+ if (colonIdx === -1) throw new NansenError(`Invalid subject format: "${s}". Expected type:value`, ErrorCode.INVALID_PARAMS);
76
+ return { type: s.slice(0, colonIdx), value: s.slice(colonIdx + 1) };
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Recursively deep-merge two plain objects. Arrays and non-object values in
82
+ * `source` replace the corresponding key in `target`; plain objects are merged
83
+ * recursively so that partial updates (e.g. only `min` on a range) don't
84
+ * overwrite sibling keys (e.g. existing `max`).
85
+ */
86
+ function deepMergePlain(target, source) {
87
+ const result = { ...target };
88
+ for (const key of Object.keys(source)) {
89
+ const oldVal = target[key];
90
+ const newVal = source[key];
91
+ if (oldVal && newVal && typeof oldVal === 'object' && typeof newVal === 'object'
92
+ && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
93
+ result[key] = deepMergePlain(oldVal, newVal);
94
+ } else {
95
+ result[key] = newVal;
96
+ }
97
+ }
98
+ return result;
99
+ }
100
+
101
+ /**
102
+ * Normalise chains option to array.
103
+ */
104
+ function parseChains(chainsOpt) {
105
+ if (!chainsOpt) return undefined;
106
+ if (Array.isArray(chainsOpt)) return chainsOpt;
107
+ return chainsOpt.split(',').map(s => s.trim()).filter(Boolean);
108
+ }
109
+
110
+ /**
111
+ * Build a { min, max } range object from two option values.
112
+ * Returns undefined if neither is provided.
113
+ */
114
+ function buildRange(minVal, maxVal) {
115
+ if (minVal === undefined && maxVal === undefined) return undefined;
116
+ const r = {};
117
+ if (minVal !== undefined) r.min = Number(minVal);
118
+ if (maxVal !== undefined) r.max = Number(maxVal);
119
+ return r;
120
+ }
121
+
122
+ /**
123
+ * Normalise a repeatable string option to array, or undefined if absent.
124
+ */
125
+ function normArray(val) {
126
+ if (!val) return undefined;
127
+ return Array.isArray(val) ? val : [val];
128
+ }
129
+
130
+ /**
131
+ * Build the data payload for sm-token-flows alerts from named flags.
132
+ */
133
+ export function buildSmTokenFlowsData(options) {
134
+ const data = {};
135
+
136
+ const chains = parseChains(options.chains);
137
+ if (chains) data.chains = chains;
138
+
139
+ const flowFields = ['inflow-1h', 'inflow-1d', 'inflow-7d', 'outflow-1h', 'outflow-1d', 'outflow-7d', 'netflow-1h', 'netflow-1d', 'netflow-7d'];
140
+ for (const field of flowFields) {
141
+ const range = buildRange(options[`${field}-min`], options[`${field}-max`]);
142
+ if (range) {
143
+ // Convert CLI key "inflow-1h" → data key "inflow_1h"
144
+ data[field.replace(/-/g, '_')] = range;
145
+ }
146
+ }
147
+
148
+ const tokens = parseTokens(options.token);
149
+ const excludeTokens = parseTokens(options['exclude-token']);
150
+ if (tokens) data.inclusion = { ...data.inclusion, tokens };
151
+ if (excludeTokens) data.exclusion = { ...data.exclusion, tokens: excludeTokens };
152
+
153
+ const sectors = normArray(options['token-sector']);
154
+ if (sectors) data.inclusion = { ...data.inclusion, tokenSectors: sectors };
155
+ const excludeSectors = normArray(options['exclude-token-sector']);
156
+ if (excludeSectors) data.exclusion = { ...data.exclusion, tokenSectors: excludeSectors };
157
+
158
+ if (options['token-age-max'] !== undefined) {
159
+ data.inclusion = { ...data.inclusion, tokenAge: { max: Number(options['token-age-max']) } };
160
+ }
161
+
162
+ const marketCapRange = buildRange(options['market-cap-min'], options['market-cap-max']);
163
+ if (marketCapRange) data.inclusion = { ...data.inclusion, marketCap: marketCapRange };
164
+
165
+ const fdvRange = buildRange(options['fdv-min'], options['fdv-max']);
166
+ if (fdvRange) data.inclusion = { ...data.inclusion, fdvUsd: fdvRange };
167
+
168
+ return data;
169
+ }
170
+
171
+ /**
172
+ * Build the data payload for common-token-transfer alerts from named flags.
173
+ */
174
+ export function buildCommonTokenTransferData(options) {
175
+ const data = {};
176
+
177
+ const chains = parseChains(options.chains);
178
+ if (chains) data.chains = chains;
179
+
180
+ if (options.events) {
181
+ data.events = typeof options.events === 'string' ? options.events.split(',') : options.events;
182
+ }
183
+
184
+ const usdRange = buildRange(options['usd-min'], options['usd-max']);
185
+ if (usdRange) data.usdValue = usdRange;
186
+
187
+ const amountRange = buildRange(options['token-amount-min'], options['token-amount-max']);
188
+ if (amountRange) data.tokenAmount = amountRange;
189
+
190
+ const subjects = parseSubjects(options.subject);
191
+ if (subjects) data.subjects = subjects;
192
+
193
+ const counterparties = parseSubjects(options.counterparty);
194
+ if (counterparties) data.counterparties = counterparties;
195
+
196
+ const tokens = parseTokens(options.token);
197
+ const excludeTokens = parseTokens(options['exclude-token']);
198
+ if (tokens) data.inclusion = { ...data.inclusion, tokens };
199
+ if (excludeTokens) data.exclusion = { ...data.exclusion, tokens: excludeTokens };
200
+
201
+ const sectors = normArray(options['token-sector']);
202
+ if (sectors) data.inclusion = { ...data.inclusion, tokenSectors: sectors };
203
+ const excludeSectors = normArray(options['exclude-token-sector']);
204
+ if (excludeSectors) data.exclusion = { ...data.exclusion, tokenSectors: excludeSectors };
205
+
206
+ const tokenAgeMin = options['token-age-min'];
207
+ const tokenAgeMax = options['token-age-max'];
208
+ if (tokenAgeMin !== undefined || tokenAgeMax !== undefined) {
209
+ const tokenAge = {};
210
+ if (tokenAgeMin !== undefined) tokenAge.min = Number(tokenAgeMin);
211
+ if (tokenAgeMax !== undefined) tokenAge.max = Number(tokenAgeMax);
212
+ data.inclusion = { ...data.inclusion, tokenAge };
213
+ }
214
+
215
+ const marketCapRange = buildRange(options['market-cap-min'], options['market-cap-max']);
216
+ if (marketCapRange) data.inclusion = { ...data.inclusion, marketCap: marketCapRange };
217
+
218
+ const excludeFrom = parseSubjects(options['exclude-from']);
219
+ if (excludeFrom) data.exclusion = { ...data.exclusion, fromTargets: excludeFrom };
220
+ const excludeTo = parseSubjects(options['exclude-to']);
221
+ if (excludeTo) data.exclusion = { ...data.exclusion, toTargets: excludeTo };
222
+
223
+ return data;
224
+ }
225
+
226
+ /**
227
+ * Build the data payload for smart-contract-call alerts from named flags.
228
+ */
229
+ export function buildSmartContractCallData(options) {
230
+ const data = {};
231
+
232
+ const chains = parseChains(options.chains);
233
+ if (chains) data.chains = chains;
234
+
235
+ const usdRange = buildRange(options['usd-min'], options['usd-max']);
236
+ if (usdRange) data.usdValue = usdRange;
237
+
238
+ if (options['signature-hash']) {
239
+ data.signatureHash = Array.isArray(options['signature-hash'])
240
+ ? options['signature-hash']
241
+ : [options['signature-hash']];
242
+ }
243
+
244
+ const callers = parseSubjects(options.caller);
245
+ const contracts = parseSubjects(options.contract);
246
+ const excludeCallers = parseSubjects(options['exclude-caller']);
247
+ const excludeContracts = parseSubjects(options['exclude-contract']);
248
+
249
+ if (callers) data.inclusion = { ...data.inclusion, caller: callers };
250
+ if (contracts) data.inclusion = { ...data.inclusion, smartContract: contracts };
251
+ if (excludeCallers) data.exclusion = { ...data.exclusion, caller: excludeCallers };
252
+ if (excludeContracts) data.exclusion = { ...data.exclusion, smartContract: excludeContracts };
253
+
254
+ return data;
255
+ }
256
+
257
+ /**
258
+ * Type-specific defaults for required fields.
259
+ * Applied in buildAlertData so payloads always satisfy the backend schema.
260
+ */
261
+ const TYPE_DEFAULTS = {
262
+ 'sm-token-flows': {
263
+ chains: [],
264
+ events: ['sm-token-flows'],
265
+ inflow_1h: {},
266
+ inflow_1d: {},
267
+ inflow_7d: {},
268
+ outflow_1h: {},
269
+ outflow_1d: {},
270
+ outflow_7d: {},
271
+ netflow_1h: {},
272
+ netflow_1d: {},
273
+ netflow_7d: {},
274
+ inclusion: {},
275
+ exclusion: {},
276
+ },
277
+ 'common-token-transfer': {
278
+ chains: [],
279
+ events: [],
280
+ subjects: [],
281
+ counterparties: [],
282
+ usdValue: {},
283
+ tokenAmount: {},
284
+ inclusion: {},
285
+ exclusion: {},
286
+ },
287
+ 'smart-contract-call': {
288
+ chains: [],
289
+ events: ['smart-contract-call'],
290
+ usdValue: {},
291
+ signatureHash: [],
292
+ inclusion: { caller: [], smartContract: [] },
293
+ exclusion: { caller: [], smartContract: [] },
294
+ },
295
+ };
296
+
297
+ /**
298
+ * Build the alert data payload from named flags, dispatching on type.
299
+ * --data '<json>' is merged on top as an escape-hatch override.
300
+ * When applyDefaults is true (default), type-specific defaults are applied
301
+ * underneath so the payload always satisfies the backend schema.
302
+ * Set applyDefaults to false for sparse updates.
303
+ */
304
+ export function buildAlertData(options, { applyDefaults = true } = {}) {
305
+ let data;
306
+
307
+ if (options.type === 'sm-token-flows') {
308
+ data = buildSmTokenFlowsData(options);
309
+ } else if (options.type === 'common-token-transfer') {
310
+ data = buildCommonTokenTransferData(options);
311
+ } else if (options.type === 'smart-contract-call') {
312
+ data = buildSmartContractCallData(options);
313
+ } else {
314
+ // No type — only --chains and --data are valid; warn if type-specific flags are present
315
+ const typeSpecificFlags = [
316
+ 'inflow-1h-min', 'inflow-1h-max', 'inflow-1d-min', 'inflow-1d-max', 'inflow-7d-min', 'inflow-7d-max',
317
+ 'outflow-1h-min', 'outflow-1h-max', 'outflow-1d-min', 'outflow-1d-max', 'outflow-7d-min', 'outflow-7d-max',
318
+ 'netflow-1h-min', 'netflow-1h-max', 'netflow-1d-min', 'netflow-1d-max', 'netflow-7d-min', 'netflow-7d-max',
319
+ 'events', 'usd-min', 'usd-max', 'token-amount-min', 'token-amount-max',
320
+ 'token', 'exclude-token',
321
+ 'subject', 'counterparty', 'signature-hash', 'caller', 'contract',
322
+ 'exclude-caller', 'exclude-contract', 'exclude-from', 'exclude-to',
323
+ 'token-sector', 'exclude-token-sector', 'token-age-min', 'token-age-max',
324
+ 'market-cap-min', 'market-cap-max', 'fdv-min', 'fdv-max',
325
+ ];
326
+ const present = typeSpecificFlags.filter(f => options[f] !== undefined);
327
+ if (present.length > 0) {
328
+ throw new NansenError(
329
+ `--type is required when using type-specific flags (${present.map(f => '--' + f).join(', ')})`,
330
+ ErrorCode.MISSING_PARAM,
331
+ );
332
+ }
333
+ data = {};
334
+ const chains = parseChains(options.chains);
335
+ if (chains) data.chains = chains;
336
+ }
337
+
338
+ // Apply type defaults underneath so all required fields are present.
339
+ // Use structuredClone to avoid shared mutable references, and deep-merge
340
+ // inclusion/exclusion so partial flags (e.g. --caller without --contract)
341
+ // don't drop sibling required sub-fields.
342
+ if (applyDefaults && TYPE_DEFAULTS[options.type]) {
343
+ const defaults = structuredClone(TYPE_DEFAULTS[options.type]);
344
+ data = { ...defaults, ...data };
345
+ if (defaults.inclusion) {
346
+ data.inclusion = { ...defaults.inclusion, ...data.inclusion };
347
+ }
348
+ if (defaults.exclusion) {
349
+ data.exclusion = { ...defaults.exclusion, ...data.exclusion };
350
+ }
351
+ }
352
+
353
+ return data;
354
+ }
355
+
356
+ /**
357
+ * Parse the --data JSON escape-hatch option.
358
+ * Returns the parsed object, or undefined if not provided.
359
+ */
360
+ function parseDataOverride(options) {
361
+ if (!options.data) return undefined;
362
+ if (typeof options.data === 'string') {
363
+ try {
364
+ return JSON.parse(options.data);
365
+ } catch {
366
+ throw new NansenError('--data must be valid JSON', ErrorCode.INVALID_PARAMS);
367
+ }
368
+ }
369
+ return options.data;
370
+ }
371
+
372
+ // ============= Validation =============
373
+
374
+ /**
375
+ * Check whether a range object has at least one non-null bound.
376
+ * Uses != null (loose equality) to catch both null and undefined.
377
+ */
378
+ function isRangeSet(range) {
379
+ if (!range || typeof range !== 'object') return false;
380
+ return range.min != null || range.max != null;
381
+ }
382
+
383
+ /**
384
+ * Validate alert data payload against type-specific required fields.
385
+ * Throws NansenError with INVALID_PARAMS when the payload is missing
386
+ * mandatory information for the given alert type.
387
+ *
388
+ * Gracefully no-ops when type or data is null/undefined.
389
+ */
390
+ export function validateAlertData(type, data) {
391
+ if (!type || !data) return;
392
+
393
+ if (type === 'sm-token-flows') {
394
+ const flowKeys = [
395
+ 'inflow_1h', 'inflow_1d', 'inflow_7d',
396
+ 'outflow_1h', 'outflow_1d', 'outflow_7d',
397
+ 'netflow_1h', 'netflow_1d', 'netflow_7d',
398
+ ];
399
+ const hasAnyFlow = flowKeys.some(k => isRangeSet(data[k]));
400
+ if (!hasAnyFlow) {
401
+ throw new NansenError(
402
+ 'sm-token-flows requires at least one flow threshold (--inflow-*, --outflow-*, or --netflow-*)',
403
+ ErrorCode.INVALID_PARAMS,
404
+ );
405
+ }
406
+ }
407
+
408
+ if (type === 'common-token-transfer') {
409
+ const hasSubjects = Array.isArray(data.subjects) && data.subjects.length > 0;
410
+ const hasTokens = Array.isArray(data.inclusion?.tokens) && data.inclusion.tokens.length > 0;
411
+ if (!hasSubjects && !hasTokens) {
412
+ throw new NansenError(
413
+ 'common-token-transfer requires at least one --subject or --token',
414
+ ErrorCode.INVALID_PARAMS,
415
+ );
416
+ }
417
+ }
418
+
419
+ if (type === 'smart-contract-call') {
420
+ const hasCallers = Array.isArray(data.inclusion?.caller) && data.inclusion.caller.length > 0;
421
+ const hasContracts = Array.isArray(data.inclusion?.smartContract) && data.inclusion.smartContract.length > 0;
422
+ const hasSignatureHash = Array.isArray(data.signatureHash) && data.signatureHash.length > 0;
423
+ if (!hasCallers && !hasContracts && !hasSignatureHash) {
424
+ throw new NansenError(
425
+ 'smart-contract-call requires at least one --caller, --contract, or --signature-hash',
426
+ ErrorCode.INVALID_PARAMS,
427
+ );
428
+ }
429
+ }
430
+ }
431
+
432
+ // ============= Command Builder =============
433
+
434
+ const TIME_WINDOW_BY_TYPE = {
435
+ 'common-token-transfer': 'realtime',
436
+ 'smart-contract-call': 'realtime',
437
+ 'sm-token-flows': '1h',
438
+ };
439
+
440
+ export function buildAlertsCommands(deps = {}) {
441
+ const { log = console.log } = deps;
442
+
443
+ return {
444
+ 'alerts': async (args, apiInstance, flags, options) => {
445
+ const sub = args[0];
446
+
447
+ const HELP = {
448
+ _top: `nansen alerts — Smart alert management
449
+
450
+ SUBCOMMANDS:
451
+ list List all alerts
452
+ create Create a new alert
453
+ update Update an existing alert
454
+ toggle Enable or disable an alert
455
+ delete Delete an alert
456
+
457
+ Run: nansen alerts <subcommand> --help`,
458
+
459
+ list: `nansen alerts list — List all alerts
460
+
461
+ USAGE:
462
+ nansen alerts list [--table] [--pretty] [--type <type>] [--enabled|--disabled] [--token-address <addr>] [--chain <chain>] [--limit <n>] [--offset <n>]
463
+
464
+ OPTIONS:
465
+ --table Human-readable table output (columns: ID, NAME, TYPE, ENABLED, CHANNELS)
466
+ --pretty Indented JSON output
467
+ --type <type> Filter by alert type (sm-token-flows, common-token-transfer, smart-contract-call)
468
+ --enabled / --disabled Filter by enabled state
469
+ --token-address <addr> Filter by token address
470
+ --chain <chain> Filter by chain
471
+ --limit <n> Max results
472
+ --offset <n> Skip first N results
473
+
474
+ EXAMPLES:
475
+ nansen alerts list --table
476
+ nansen alerts list --pretty
477
+ nansen alerts list --type sm-token-flows --enabled`,
478
+
479
+ create: `nansen alerts create — Create a new alert
480
+
481
+ USAGE:
482
+ nansen alerts create --name <name> --type <type> --chains <chains> --telegram <chatId> [options]
483
+
484
+ REQUIRED:
485
+ --name <name> Alert name
486
+ --type <type> sm-token-flows | common-token-transfer | smart-contract-call
487
+ At least one channel: --telegram <chatId> | --slack <url> | --discord <url>
488
+
489
+ OPTIONS (all types):
490
+ --chains <chains> Comma-separated chains (e.g. ethereum,solana)
491
+ --token <address:chain> Include token (repeatable)
492
+ --exclude-token <addr:chain> Exclude token (repeatable)
493
+ --description '<text>' Alert description
494
+ --disabled Create in disabled state
495
+ --data '<json>' Raw JSON merged on top of named flags (escape hatch)
496
+
497
+ OPTIONS (sm-token-flows):
498
+ At least one flow threshold required (inflow, outflow, or netflow):
499
+ --inflow-1h-min/max <usd> --outflow-1h-min/max <usd> --netflow-1h-min/max <usd>
500
+ --inflow-1d-min/max <usd> --outflow-1d-min/max <usd> --netflow-1d-min/max <usd>
501
+ --inflow-7d-min/max <usd> --outflow-7d-min/max <usd> --netflow-7d-min/max <usd>
502
+ --token-sector <name> --exclude-token-sector <name> (repeatable)
503
+ --token-age-max <days> --market-cap-min/max <usd> --fdv-min/max <usd>
504
+
505
+ OPTIONS (common-token-transfer):
506
+ --events <buy,sell,swap,send,receive> Comma-separated event types
507
+ --usd-min/max <usd> --token-amount-min/max <n>
508
+ --subject <type:value> Filter by subject (repeatable, e.g. label:"Centralized Exchange")
509
+ --counterparty <type:value> Filter by counterparty (repeatable, requires --subject)
510
+ --token-sector <name> --exclude-token-sector <name> (repeatable)
511
+ --token-age-min/max <days> --market-cap-min/max <usd>
512
+ --exclude-from <type:value> --exclude-to <type:value> (repeatable)
513
+
514
+ OPTIONS (smart-contract-call):
515
+ --usd-min/max <usd> --signature-hash <hash> (repeatable)
516
+ --caller <type:value> --exclude-caller <type:value> (repeatable)
517
+ --contract <type:value> --exclude-contract <type:value> (repeatable)
518
+
519
+ SUBJECT TYPES: address, entity, label, custom-label
520
+ CHAIN ALIASES: Hyperliquid = hyperevm, BSC = bnb
521
+
522
+ NOTE: Use single quotes for names with $ or special chars: --name 'SM >$1M'
523
+
524
+ EXAMPLES:
525
+ nansen alerts create --name 'ETH SM Inflow' --type sm-token-flows --chains ethereum --telegram 5238612255 --inflow-1h-min 1000000
526
+ nansen alerts create --name 'USDC Transfers' --type common-token-transfer --chains ethereum --telegram 5238612255 --events send,receive --usd-min 1000000 --subject label:"Centralized Exchange"
527
+ nansen alerts create --name 'Contract Calls' --type smart-contract-call --chains ethereum --telegram 5238612255 --signature-hash 0xa9059cbb --caller address:0xabc`,
528
+
529
+ update: `nansen alerts update — Update an existing alert
530
+
531
+ USAGE:
532
+ nansen alerts update <id> [--name <name>] [--chains <chains>] [--enabled|--disabled] [type-specific flags...]
533
+
534
+ All create options are accepted. Only provided fields are updated.
535
+ See: nansen alerts create --help for type-specific flags.
536
+
537
+ NOTE: --type cannot change an existing alert's type (use delete + create instead).
538
+ Use single quotes for names with $ or special chars: --name 'SM >$1M'
539
+
540
+ EXAMPLES:
541
+ nansen alerts update abc123 --name 'New Name'
542
+ nansen alerts update abc123 --inflow-1h-min 2000000
543
+ nansen alerts update abc123 --chains ethereum,base --inflow-1h-min 2000000`,
544
+
545
+ toggle: `nansen alerts toggle — Enable or disable an alert
546
+
547
+ USAGE:
548
+ nansen alerts toggle <id> --enabled
549
+ nansen alerts toggle <id> --disabled`,
550
+
551
+ delete: `nansen alerts delete — Delete an alert
552
+
553
+ USAGE:
554
+ nansen alerts delete <id>`,
555
+ };
556
+
557
+ if (!sub || sub === 'help') {
558
+ log(HELP._top);
559
+ return;
560
+ }
561
+
562
+ // Build channels array from --telegram/--slack/--discord flags
563
+ function buildChannels() {
564
+ const channels = [];
565
+ if (options.telegram) channels.push({ type: 'telegram', data: { chatId: String(options.telegram) } });
566
+ if (options.slack) channels.push({ type: 'slack', data: { webhookUrl: options.slack } });
567
+ if (options.discord) channels.push({ type: 'discord', data: { webhookUrl: options.discord } });
568
+ return channels.length > 0 ? channels : null;
569
+ }
570
+
571
+ const handlers = {
572
+ 'list': async () => {
573
+ if (flags.enabled && flags.disabled) throw new NansenError('Cannot specify both --enabled and --disabled', ErrorCode.INVALID_PARAMS);
574
+
575
+ const results = await apiInstance.alertsList();
576
+ let alerts = Array.isArray(results) ? results : results?.alerts ?? results?.data ?? [];
577
+
578
+ // Client-side filtering (server returns all alerts unfiltered)
579
+ if (options.type) alerts = alerts.filter(a => a.type === options.type);
580
+ if (flags.enabled) alerts = alerts.filter(a => a.isEnabled === true);
581
+ if (flags.disabled) alerts = alerts.filter(a => a.isEnabled === false);
582
+ if (options['token-address']) {
583
+ const addr = options['token-address'].toLowerCase();
584
+ alerts = alerts.filter(a => {
585
+ const allTokens = [...(a.data?.inclusion?.tokens ?? []), ...(a.data?.exclusion?.tokens ?? [])];
586
+ return allTokens.some(t => t.address?.toLowerCase() === addr);
587
+ });
588
+ }
589
+ if (options.chain) {
590
+ const ch = options.chain.toLowerCase();
591
+ alerts = alerts.filter(a => {
592
+ const chains = a.data?.chains;
593
+ return Array.isArray(chains) && chains.some(c => c.toLowerCase() === ch || c === 'all');
594
+ });
595
+ }
596
+
597
+ // Pagination (applied after filtering)
598
+ if (options.offset) alerts = alerts.slice(Number(options.offset));
599
+ if (options.limit) alerts = alerts.slice(0, Number(options.limit));
600
+
601
+ return alerts;
602
+ },
603
+ 'create': () => {
604
+ const name = options.name;
605
+ const type = options.type;
606
+ const channels = buildChannels();
607
+ const missing = [];
608
+ if (!name) missing.push('--name');
609
+ if (!type) missing.push('--type');
610
+ if (!options.chains) missing.push('--chains');
611
+ if (!channels) missing.push('a channel (--telegram, --slack, or --discord)');
612
+ if (missing.length > 0) {
613
+ throw new NansenError(`Required: ${missing.join(', ')}`, ErrorCode.MISSING_PARAM);
614
+ }
615
+ const timeWindow = TIME_WINDOW_BY_TYPE[type] ?? 'realtime';
616
+ let data = buildAlertData(options);
617
+ const dataOverride = parseDataOverride(options);
618
+ if (dataOverride) data = deepMergePlain(data, dataOverride);
619
+ validateAlertData(type, data);
620
+ return apiInstance.alertsCreate({
621
+ name,
622
+ type,
623
+ timeWindow,
624
+ channels,
625
+ data,
626
+ ...(options.description ? { description: options.description } : {}),
627
+ isEnabled: !flags.disabled,
628
+ });
629
+ },
630
+ 'update': async () => {
631
+ const id = args[1];
632
+ if (!id) throw new NansenError('Required: <id>', ErrorCode.MISSING_PARAM);
633
+
634
+ const existing = await apiInstance.alertsGet(id);
635
+ if (!existing) throw new NansenError(`Alert not found: ${id}`, ErrorCode.NOT_FOUND);
636
+ const existingType = existing.type ?? existing.data?.type;
637
+ if (options.type && options.type !== existingType) {
638
+ throw new NansenError(
639
+ `Cannot change alert type (${existingType} → ${options.type}). Delete and recreate the alert instead.`,
640
+ ErrorCode.INVALID_PARAMS,
641
+ );
642
+ }
643
+ const type = existingType;
644
+
645
+ const params = { id };
646
+ if (options.name) params.name = options.name;
647
+ if (type) {
648
+ params.type = type;
649
+ params.timeWindow = TIME_WINDOW_BY_TYPE[type] ?? 'realtime';
650
+ }
651
+ const channels = buildChannels();
652
+ if (channels) params.channels = channels;
653
+ const effectiveOptions = type ? { ...options, type } : options;
654
+ const builtData = buildAlertData(effectiveOptions, { applyDefaults: false });
655
+ const dataOverride = parseDataOverride(options);
656
+ if (Object.keys(builtData).length > 0 || dataOverride) {
657
+ let merged = existing.data ? deepMergePlain(existing.data, builtData) : builtData;
658
+ if (dataOverride) merged = deepMergePlain(merged, dataOverride);
659
+ params.data = merged;
660
+ }
661
+ if (options.description) params.description = options.description;
662
+ if (flags.enabled && flags.disabled) throw new NansenError('Cannot specify both --enabled and --disabled', ErrorCode.INVALID_PARAMS);
663
+ if (flags.enabled) params.isEnabled = true;
664
+ if (flags.disabled) params.isEnabled = false;
665
+ // Validate merged data if present, otherwise validate existing data
666
+ validateAlertData(type, params.data ?? existing.data);
667
+ return apiInstance.alertsUpdate(params);
668
+ },
669
+ 'toggle': () => {
670
+ const id = args[1];
671
+ if (!id) throw new NansenError('Required: <id>', ErrorCode.MISSING_PARAM);
672
+ if (flags.enabled && flags.disabled) throw new NansenError('Cannot specify both --enabled and --disabled', ErrorCode.INVALID_PARAMS);
673
+ const isEnabled = flags.enabled ? true : flags.disabled ? false : undefined;
674
+ if (isEnabled === undefined) throw new NansenError('Required: --enabled or --disabled', ErrorCode.MISSING_PARAM);
675
+ return apiInstance.alertsToggle({ id, isEnabled });
676
+ },
677
+ 'delete': () => {
678
+ const id = args[1];
679
+ if (!id) throw new NansenError('Required: <id>', ErrorCode.MISSING_PARAM);
680
+ return apiInstance.alertsDelete(id);
681
+ },
682
+ };
683
+
684
+ if (!handlers[sub]) {
685
+ throw new NansenError(`Unknown alerts subcommand: ${sub}. Available: list, create, update, toggle, delete`, ErrorCode.UNKNOWN);
686
+ }
687
+
688
+ // Subcommand-level help: --help flag or "help" as second positional arg
689
+ if (flags.help || flags.h || args[1] === 'help') {
690
+ log(HELP[sub] || HELP._top);
691
+ return;
692
+ }
693
+
694
+ try {
695
+ return await handlers[sub]();
696
+ } catch (err) {
697
+ // Rewrite cryptic channel validation errors from the API into actionable messages.
698
+ // e.g. "Failed to send a welcome message to the channel index 0" → tells the user
699
+ // which channel type failed and what to check.
700
+ const match = typeof err.message === 'string' && err.message.match(/welcome message to the channel index (\d+)/i);
701
+ if (match) {
702
+ const channels = buildChannels() ?? [];
703
+ const ch = channels[Number(match[1])];
704
+ const hint = ch?.type === 'telegram'
705
+ ? `Invalid Telegram chat ID (${ch.data.chatId}). Ensure the bot has been added to the chat.`
706
+ : ch?.type === 'slack'
707
+ ? `Invalid Slack webhook URL. Check the URL and try again.`
708
+ : ch?.type === 'discord'
709
+ ? `Invalid Discord webhook URL. Check the URL and try again.`
710
+ : err.message;
711
+ throw new NansenError(hint, err.code ?? ErrorCode.INVALID_PARAMS, err.status);
712
+ }
713
+ throw err;
714
+ }
715
+ },
716
+ };
717
+ }