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.
- package/CHANGELOG.md +12 -0
- package/package.json +4 -3
- package/skills/{nansen-dca-watch → nansen-dca-tracker}/SKILL.md +1 -1
- package/skills/{nansen-defi-exposure → nansen-defi-positions}/SKILL.md +1 -1
- package/skills/{nansen-exit-signal → nansen-exit-signals}/SKILL.md +1 -1
- package/skills/{nansen-fund-watch → nansen-fund-tracker}/SKILL.md +1 -1
- package/skills/{nansen-search → nansen-general-search}/SKILL.md +1 -1
- package/skills/{nansen-holder-quality → nansen-holder-analysis}/SKILL.md +1 -1
- package/skills/{nansen-perp-scan → nansen-perp-screener}/SKILL.md +1 -1
- package/skills/{nansen-perp-trader → nansen-perp-trader-profile}/SKILL.md +1 -1
- package/skills/{nansen-pm-deep-dive → nansen-polymarket-deep-dive}/SKILL.md +1 -1
- package/skills/{nansen-pm-insider-scan → nansen-polymarket-insider-scan}/SKILL.md +1 -1
- package/skills/{nansen-polymarket-trader → nansen-polymarket-trader-profile}/SKILL.md +1 -1
- package/skills/{nansen-portfolio-history → nansen-portfolio-tracker}/SKILL.md +1 -1
- package/skills/{nansen-prediction-market → nansen-prediction-markets}/SKILL.md +1 -1
- package/skills/{nansen-cross-chain-flow → nansen-sm-cross-chain-flows}/SKILL.md +1 -1
- package/skills/{nansen-alerts → nansen-smart-alerts}/SKILL.md +1 -1
- package/skills/{nansen-alpha-discovery → nansen-smart-money-alpha}/SKILL.md +1 -1
- package/skills/{nansen-smart-money → nansen-smart-money-tracker}/SKILL.md +1 -1
- package/skills/{nansen-sm-trend → nansen-smart-money-trend}/SKILL.md +1 -1
- package/skills/{nansen-token → nansen-token-research}/SKILL.md +1 -1
- package/skills/{nansen-token-discovery → nansen-token-screener}/SKILL.md +1 -1
- package/skills/{nansen-token-forensics → nansen-token-transfer-analysis}/SKILL.md +1 -1
- package/skills/{nansen-trade → nansen-trading}/SKILL.md +2 -2
- package/skills/{nansen-batch-wallet → nansen-wallet-batch}/SKILL.md +1 -1
- package/skills/{nansen-wallet-attribution → nansen-wallet-clustering}/SKILL.md +1 -1
- package/skills/{nansen-wallet-analysis → nansen-wallet-deep-dive}/SKILL.md +1 -1
- package/skills/{nansen-wallet-migration → nansen-wallet-keychain-migration}/SKILL.md +1 -1
- package/skills/{nansen-wallet → nansen-wallet-manager}/SKILL.md +1 -1
- package/skills/{nansen-profiler → nansen-wallet-profiler}/SKILL.md +1 -1
- package/skills/{nansen-web-fetch → nansen-web-fetcher}/SKILL.md +1 -1
- package/skills/{nansen-web-search → nansen-web-searcher}/SKILL.md +1 -1
- package/src/commands/alerts.js +717 -0
- /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.
|
|
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
|
|
12
|
-
"src
|
|
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-
|
|
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-prediction-
|
|
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-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-
|
|
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-
|
|
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-wallet-
|
|
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-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-
|
|
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:
|
|
@@ -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
|
+
}
|
|
File without changes
|