1use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow};
9use chrono::{DateTime, Duration, Utc};
10use futu_auth::{KeyRecord, Limits, Scope, machine, store};
11
12fn default_keys_path() -> Result<PathBuf> {
14 let base =
15 dirs::config_dir().ok_or_else(|| anyhow!("cannot resolve config dir (set --keys-file)"))?;
16 Ok(base.join("futu").join("keys.json"))
17}
18
19fn detect_futu_mcp_path() -> Option<PathBuf> {
31 let exe_name = if cfg!(windows) {
32 "futu-mcp.exe"
33 } else {
34 "futu-mcp"
35 };
36
37 if let Ok(cli) = std::env::current_exe()
39 && let Some(dir) = cli.parent()
40 {
41 let candidate = dir.join(exe_name);
42 if candidate.is_file() {
43 return Some(candidate);
44 }
45 }
46
47 if let Some(path_env) = std::env::var_os("PATH") {
49 for dir in std::env::split_paths(&path_env) {
50 let candidate = dir.join(exe_name);
51 if candidate.is_file() {
52 return Some(candidate);
53 }
54 }
55 }
56
57 None
58}
59
60fn parse_expires(s: &str) -> Result<DateTime<Utc>> {
62 let s = s.trim();
63 if let Ok(t) = DateTime::parse_from_rfc3339(s) {
64 return Ok(t.with_timezone(&Utc));
65 }
66 let (num_part, unit) = s
68 .chars()
69 .position(|c| c.is_alphabetic())
70 .map(|i| (&s[..i], &s[i..]))
71 .ok_or_else(|| anyhow!("invalid expires {s:?}: expect Nd / Nh / Nm / RFC3339"))?;
72 let n: i64 = num_part
73 .parse()
74 .map_err(|e| anyhow!("invalid number in expires {s:?}: {e}"))?;
75 let dur = match unit {
76 "d" => Duration::days(n),
77 "h" => Duration::hours(n),
78 "m" => Duration::minutes(n),
79 other => return Err(anyhow!("unknown expires unit {other:?} (d|h|m)")),
80 };
81 Ok(Utc::now() + dur)
82}
83
84fn parse_scopes(s: &str) -> Result<HashSet<Scope>> {
85 let mut out = HashSet::new();
86 for part in s.split(',') {
87 let part = part.trim();
88 if part.is_empty() {
89 continue;
90 }
91 let sc: Scope = part
92 .parse()
93 .map_err(|e| anyhow!("parse scope {part:?}: {e}"))?;
94 out.insert(sc);
95 }
96 if out.is_empty() {
97 return Err(anyhow!("--scopes cannot be empty"));
98 }
99 Ok(out)
100}
101
102fn parse_acc_ids_csv(s: &str) -> Result<HashSet<u64>> {
112 let mut out = HashSet::new();
113 for token in s.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()) {
114 let id: u64 = token.parse().map_err(|e| {
115 anyhow::anyhow!(
116 "invalid acc_id {token:?}: expected positive integer, got {e}. \
117 Usage: --allowed-acc-ids 10001,10002,10003"
118 )
119 })?;
120 out.insert(id);
121 }
122 Ok(out)
123}
124
125pub struct GenKeyCommand {
126 pub id: String,
127 pub scopes: String,
128 pub keys_file: Option<PathBuf>,
129 pub expires: Option<String>,
130 pub note: Option<String>,
131 pub allowed_markets: Option<String>,
132 pub allowed_symbols: Option<String>,
133 pub max_order_value: Option<f64>,
134 pub max_daily_value: Option<f64>,
135 pub hours_window: Option<String>,
136 pub max_orders_per_minute: Option<u32>,
137 pub allowed_trd_sides: Option<String>,
138 pub allowed_acc_ids: Option<String>,
141 pub allowed_card_nums: Option<String>,
146 pub bind_this_machine: bool,
147 pub bind_machines: Option<String>,
148}
149
150pub async fn run(input: GenKeyCommand) -> Result<()> {
151 let GenKeyCommand {
152 id,
153 scopes,
154 keys_file,
155 expires,
156 note,
157 allowed_markets,
158 allowed_symbols,
159 max_order_value,
160 max_daily_value,
161 hours_window,
162 max_orders_per_minute,
163 allowed_trd_sides,
164 allowed_acc_ids,
165 allowed_card_nums,
166 bind_this_machine,
167 bind_machines,
168 } = input;
169
170 let path = match keys_file {
171 Some(p) => p,
172 None => default_keys_path()?,
173 };
174 let scopes = parse_scopes(&scopes)?;
175 let expires_at = match expires {
176 Some(s) => Some(parse_expires(&s)?),
177 None => None,
178 };
179
180 let allowed_trd_sides = match allowed_trd_sides {
184 Some(s) => Some(crate::cmd::key_enums::parse_trd_sides_csv(&s)?),
185 None => None,
186 };
187
188 let allowed_acc_ids = match allowed_acc_ids {
190 Some(s) => Some(parse_acc_ids_csv(&s)?),
191 None => None,
192 };
193
194 let allowed_card_nums: Option<Vec<String>> = match allowed_card_nums {
202 None => None,
203 Some(s) => {
204 let parsed: Vec<String> = s
205 .split(',')
206 .map(|p| p.trim().to_string())
207 .filter(|p| !p.is_empty())
208 .collect();
209 if parsed.is_empty() {
210 return Err(anyhow!(
211 "v1.4.104 eli P2-008 (P2) fix: --allowed-card-nums {s:?} parsed to empty \
212 list. daemon 会把空 list 当 \"无限制\" sentinel — 与你的意图相反. \
213 如要 \"不限制\" 请**不传** --allowed-card-nums; 如要 \"完全限制\" 至少 \
214 传 1 个真实 4/16 位 card_num (即使是 dummy 0000)."
215 ));
216 }
217 Some(parsed)
218 }
219 };
220 if let Some(ref nums) = allowed_card_nums {
222 for cn in nums {
223 if !cn.chars().all(|c| c.is_ascii_digit()) || (cn.len() != 4 && cn.len() != 16) {
224 return Err(anyhow!(
225 "invalid card_num {cn:?}: expected 4-digit suffix \
226 or 16-digit full card number (synthetic example: 4-digit \
227 `<card-suffix>` or 16-digit `<full-card-num>`). \
228 Got len={}, all-digits={}",
229 cn.len(),
230 cn.chars().all(|c| c.is_ascii_digit())
231 ));
232 }
233 }
234 }
235
236 let allowed_markets = match allowed_markets {
241 Some(s) => Some(crate::cmd::key_enums::parse_markets_csv(&s)?),
242 None => None,
243 };
244 let allowed_symbols = match allowed_symbols {
245 Some(s) => Some(crate::cmd::key_enums::parse_symbols_csv(&s)?),
246 None => None,
247 };
248
249 let limits = Limits {
250 allowed_markets,
251 allowed_symbols,
252 max_order_value,
253 max_daily_value,
254 hours_window,
255 max_orders_per_minute,
256 allowed_trd_sides,
257 allowed_acc_ids,
258 allowed_card_nums,
259 };
260
261 let allowed_machines =
263 build_allowed_machines(&id, bind_this_machine, bind_machines.as_deref())?;
264
265 let (plaintext, record) = KeyRecord::generate_with_machines(
266 id.clone(),
267 scopes.clone(),
268 Some(limits),
269 expires_at,
270 note,
271 allowed_machines.clone(),
272 );
273
274 let rate_summary = record.max_orders_per_minute;
276 let sides_summary: Option<Vec<String>> = record
277 .allowed_trd_sides
278 .as_ref()
279 .map(|s| s.iter().cloned().collect());
280
281 store::append_key(&path, record).with_context(|| format!("append to {}", path.display()))?;
282
283 print_result(KeyPrintView {
284 path: &path,
285 id: &id,
286 plaintext: &plaintext,
287 scopes: &scopes,
288 allowed_machines: allowed_machines.as_deref(),
289 max_orders_per_minute: rate_summary,
290 allowed_trd_sides: sides_summary.as_deref(),
291 });
292 Ok(())
293}
294
295fn build_allowed_machines(
310 id: &str,
311 bind_this: bool,
312 bind_others: Option<&str>,
313) -> Result<Option<Vec<String>>> {
314 let mut list: Vec<String> = Vec::new();
315 if bind_this {
316 let fp = machine::fingerprint_for(id)
317 .map_err(|e| anyhow!("cannot compute this machine's fingerprint: {e}"))?;
318 list.push(fp);
319 }
320 if let Some(raw) = bind_others {
321 let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
322 if parsed.is_empty() && !bind_this {
323 return Err(anyhow!(
327 "v1.4.106 F4: --bind-machines {raw:?} parsed to empty list \
328 (no --bind-this-machine either). 这会让 allowed_machines = None \
329 (无机器绑定限制) — 与你的意图相反. 如要 \"不启用绑定\" 请不传 \
330 --bind-machines; 如要至少 1 个机器, 传至少 1 个 64-hex 指纹 \
331 (`futucli machine-id --for-key <id>`)."
332 ));
333 }
334 list.extend(parsed);
335 }
336 if list.is_empty() {
337 return Ok(None);
338 }
339 let mut seen = HashSet::new();
341 list.retain(|x| seen.insert(x.clone()));
342 Ok(Some(list))
343}
344
345struct KeyPrintView<'a> {
346 path: &'a Path,
347 id: &'a str,
348 plaintext: &'a str,
349 scopes: &'a HashSet<Scope>,
350 allowed_machines: Option<&'a [String]>,
351 max_orders_per_minute: Option<u32>,
352 allowed_trd_sides: Option<&'a [String]>,
353}
354
355fn print_result(view: KeyPrintView<'_>) {
356 let scope_list: Vec<&str> = view.scopes.iter().map(|s| s.as_str()).collect();
357 println!();
358 println!("=== FutuOpenD-rs API Key ===");
359 println!();
360 println!(" id : {}", view.id);
361 println!(" scopes : {}", scope_list.join(", "));
362 println!(" path : {}", view.path.display());
363 if let Some(r) = view.max_orders_per_minute {
364 println!(" rate : {r} orders/min");
365 }
366 if let Some(sides) = view.allowed_trd_sides
367 && !sides.is_empty()
368 {
369 let mut v: Vec<String> = sides.to_vec();
370 v.sort();
371 println!(" sides : {}", v.join(","));
372 }
373 if let Some(ms) = view.allowed_machines {
374 println!(" bound : {} machine(s)", ms.len());
375 for fp in ms {
376 println!(" {}", &fp[..16]);
377 }
378 }
379 println!();
380 println!("Plaintext (shown once, SAVE IT NOW — file only stores SHA-256 hash):");
381 println!();
382 println!(" FUTU_MCP_API_KEY={}", view.plaintext);
383 println!();
384 println!("Add to your MCP client config, e.g. Claude Desktop claude_desktop_config.json:");
385 println!();
386 let (mcp_command, post_note) = match detect_futu_mcp_path() {
391 Some(p) => (
392 format!("\"{}\"", p.display()),
393 format!("(auto-detected: {})", p.display()),
394 ),
395 None => (
396 r#""REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp""#.to_string(),
397 "⚠️ futu-mcp not found in PATH or next to futucli — replace the \
398 command field above with the absolute path (run: `which futu-mcp`)."
399 .to_string(),
400 ),
401 };
402 println!(" {{");
403 println!(" \"mcpServers\": {{");
404 println!(" \"futu\": {{");
405 println!(" \"command\": {mcp_command},");
406 println!(
407 " \"args\": [\"--keys-file\", \"{}\"],",
408 view.path.display()
409 );
410 println!(
411 " \"env\": {{ \"FUTU_MCP_API_KEY\": \"{}\" }}",
412 view.plaintext
413 );
414 println!(" }}");
415 println!(" }}");
416 println!(" }}");
417 println!();
418 println!(" command path: {post_note}");
419 println!();
420}
421
422#[cfg(test)]
423mod tests;