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 external report 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 {
242 Some(s) => Some(crate::cmd::key_enums::parse_markets_csv(&s)?),
243 None => None,
244 };
245 let allowed_symbols = match allowed_symbols {
246 Some(s) => Some(crate::cmd::key_enums::parse_symbols_csv(&s)?),
247 None => None,
248 };
249
250 let limits = Limits {
251 allowed_markets,
252 allowed_symbols,
253 max_order_value,
254 max_daily_value,
255 hours_window,
256 max_orders_per_minute,
257 allowed_trd_sides,
258 allowed_acc_ids,
259 allowed_card_nums,
260 };
261
262 let allowed_machines =
264 build_allowed_machines(&id, bind_this_machine, bind_machines.as_deref())?;
265
266 let (plaintext, record) = KeyRecord::generate_with_machines(
267 id.clone(),
268 scopes.clone(),
269 Some(limits),
270 expires_at,
271 note,
272 allowed_machines.clone(),
273 );
274
275 let rate_summary = record.max_orders_per_minute;
277 let sides_summary: Option<Vec<String>> = record
278 .allowed_trd_sides
279 .as_ref()
280 .map(|s| s.iter().cloned().collect());
281
282 store::append_key(&path, record).with_context(|| format!("append to {}", path.display()))?;
283
284 print_result(KeyPrintView {
285 path: &path,
286 id: &id,
287 plaintext: &plaintext,
288 scopes: &scopes,
289 allowed_machines: allowed_machines.as_deref(),
290 max_orders_per_minute: rate_summary,
291 allowed_trd_sides: sides_summary.as_deref(),
292 });
293 Ok(())
294}
295
296fn build_allowed_machines(
311 id: &str,
312 bind_this: bool,
313 bind_others: Option<&str>,
314) -> Result<Option<Vec<String>>> {
315 let mut list: Vec<String> = Vec::new();
316 if bind_this {
317 let fp = machine::fingerprint_for(id)
318 .map_err(|e| anyhow!("cannot compute this machine's fingerprint: {e}"))?;
319 list.push(fp);
320 }
321 if let Some(raw) = bind_others {
322 let parsed = crate::cmd::key_enums::parse_fingerprints_csv(raw)?;
323 if parsed.is_empty() && !bind_this {
324 return Err(anyhow!(
328 "v1.4.106 F4: --bind-machines {raw:?} parsed to empty list \
329 (no --bind-this-machine either). 这会让 allowed_machines = None \
330 (无机器绑定限制) — 与你的意图相反. 如要 \"不启用绑定\" 请不传 \
331 --bind-machines; 如要至少 1 个机器, 传至少 1 个 64-hex 指纹 \
332 (`futucli machine-id --for-key <id>`)."
333 ));
334 }
335 list.extend(parsed);
336 }
337 if list.is_empty() {
338 return Ok(None);
339 }
340 let mut seen = HashSet::new();
342 list.retain(|x| seen.insert(x.clone()));
343 Ok(Some(list))
344}
345
346struct KeyPrintView<'a> {
347 path: &'a Path,
348 id: &'a str,
349 plaintext: &'a str,
350 scopes: &'a HashSet<Scope>,
351 allowed_machines: Option<&'a [String]>,
352 max_orders_per_minute: Option<u32>,
353 allowed_trd_sides: Option<&'a [String]>,
354}
355
356fn print_result(view: KeyPrintView<'_>) {
357 let scope_list: Vec<&str> = view.scopes.iter().map(|s| s.as_str()).collect();
358 println!();
359 println!("=== FutuOpenD-rs API Key ===");
360 println!();
361 println!(" id : {}", view.id);
362 println!(" scopes : {}", scope_list.join(", "));
363 println!(" path : {}", view.path.display());
364 if let Some(r) = view.max_orders_per_minute {
365 println!(" rate : {r} orders/min");
366 }
367 if let Some(sides) = view.allowed_trd_sides
368 && !sides.is_empty()
369 {
370 let mut v: Vec<String> = sides.to_vec();
371 v.sort();
372 println!(" sides : {}", v.join(","));
373 }
374 if let Some(ms) = view.allowed_machines {
375 println!(" bound : {} machine(s)", ms.len());
376 for fp in ms {
377 println!(" {}", &fp[..16]);
378 }
379 }
380 println!();
381 println!("Plaintext (shown once, SAVE IT NOW — file only stores SHA-256 hash):");
382 println!();
383 println!(" FUTU_MCP_API_KEY={}", view.plaintext);
384 println!();
385 println!("Add to your MCP client config, e.g. Claude Desktop claude_desktop_config.json:");
386 println!();
387 let (mcp_command, post_note) = match detect_futu_mcp_path() {
392 Some(p) => (
393 format!("\"{}\"", p.display()),
394 format!("(auto-detected: {})", p.display()),
395 ),
396 None => (
397 r#""REPLACE_WITH_ABSOLUTE_PATH_RUN_which_futu_mcp""#.to_string(),
398 "⚠️ futu-mcp not found in PATH or next to futucli — replace the \
399 command field above with the absolute path (run: `which futu-mcp`)."
400 .to_string(),
401 ),
402 };
403 println!(" {{");
404 println!(" \"mcpServers\": {{");
405 println!(" \"futu\": {{");
406 println!(" \"command\": {mcp_command},");
407 println!(
408 " \"args\": [\"--keys-file\", \"{}\"],",
409 view.path.display()
410 );
411 println!(
412 " \"env\": {{ \"FUTU_MCP_API_KEY\": \"{}\" }}",
413 view.plaintext
414 );
415 println!(" }}");
416 println!(" }}");
417 println!(" }}");
418 println!();
419 println!(" command path: {post_note}");
420 println!();
421}
422
423#[cfg(test)]
424mod tests;