1use std::collections::HashMap;
4use std::sync::Arc;
5use std::sync::atomic::Ordering;
6
7use anyhow::{Context, Result, anyhow};
8use futu_auth::{KeyRecord, KeyStore, RuntimeCounters};
9use futu_core::qot_symbol;
10use futu_net::client::{ClientConfig, FutuClient, ReconnectingClient};
11use futu_net::reconnect::ReconnectPolicy;
12use futu_qot::types::Security;
13use rmcp::{RoleServer, service::Peer};
14use tokio::sync::{Mutex, Semaphore};
15
16mod push_filter;
17mod push_subscribers;
18pub(crate) use push_subscribers::{LegacyPushServiceLease, PushDeliveryTarget};
19#[cfg(test)]
20pub(crate) use push_subscribers::{MAX_MODERN_PUSH_HANDLES, MODERN_PUSH_QUEUE_CAPACITY};
21pub(crate) use push_subscribers::{parse_push_resource_uri, push_resource_uri};
22#[cfg(test)]
23mod tests;
24
25use push_filter::{TradePushDecode, classify_trade_push, trd_market_int_to_str};
26#[cfg(test)]
27use push_filter::{
28 extract_acc_id_and_market_from_push, is_trade_push_proto_id, subscriber_should_receive,
29 subscriber_should_receive_with_market, subscriber_visible_to_caller,
30};
31use push_subscribers::{PushSubscriber, SubscriberDelivery};
32
33use crate::qot_sdk_adapter;
34
35const MCP_CONNECT_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
36const MCP_CONNECT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(200);
37pub(crate) const LEGACY_PUSH_INFO_LEVEL_RANK: u8 = 1;
38
39fn base64_encode_bytes(bytes: &[u8]) -> String {
41 use base64::Engine as _;
42 base64::engine::general_purpose::STANDARD.encode(bytes)
43}
44
45struct PendingPushDelivery {
46 target: SubscriberDelivery,
47 data: serde_json::Value,
48 session_id: String,
49 owner_key_id: Option<String>,
50 proto_id: u32,
51}
52
53fn push_delivery_is_authorized(
58 subscriber: &PushSubscriber,
59 key_store: &KeyStore,
60 filter_registry: &futu_auth_pipeline::FilterRegistry,
61 decode_result: &TradePushDecode,
62 event_type: &'static str,
63 push_acc_id: Option<u64>,
64 push_trd_market_str: Option<&'static str>,
65 proto_id: u32,
66) -> bool {
67 let (allowed_acc_ids, allowed_markets) = match &subscriber.delivery {
68 SubscriberDelivery::LegacyPeer(_, _) => (
69 subscriber.allowed_acc_ids_snapshot.clone(),
70 subscriber.allowed_markets_snapshot.clone(),
71 ),
72 SubscriberDelivery::ModernResource(_) => {
73 let Some(owner) = subscriber.owner_key_id.as_deref() else {
74 return false;
75 };
76 let Some(current) = key_store
77 .get_by_id_for_current_machine(owner)
78 .filter(|record| {
79 !record.is_expired(chrono::Utc::now())
80 && record.scopes.contains(&futu_auth::Scope::AccRead)
81 })
82 else {
83 return false;
84 };
85 (
86 current.allowed_acc_ids.clone(),
87 current.allowed_markets.clone(),
88 )
89 }
90 };
91 if matches!(decode_result, TradePushDecode::DecodeFailed) {
92 let restricted = allowed_acc_ids
93 .as_ref()
94 .is_some_and(|allowed| !allowed.is_empty());
95 if restricted {
96 let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
97 futu_auth::metrics::bump_ws_filtered("trade_decode_failed", key_id);
98 tracing::warn!(
99 proto_id,
100 key_id,
101 "MCP trade push body decode failed; dropped before legacy/modern delivery for restricted key"
102 );
103 return false;
104 }
105 }
106 let sub_state = (!subscriber.acc_ids.is_empty()).then_some(&subscriber.acc_ids);
107 let ctx = futu_auth_pipeline::PushEventCtx {
108 event_type,
109 event_acc: push_acc_id,
110 allowed_acc_ids: allowed_acc_ids.as_ref(),
111 sub_state,
112 event_trd_market: push_trd_market_str,
113 allowed_markets: allowed_markets.as_ref(),
114 };
115 if filter_registry.should_drop_event(&ctx) {
116 let key_id = subscriber.owner_key_id.as_deref().unwrap_or("<none>");
117 futu_auth::metrics::bump_ws_filtered("trade_market", key_id);
118 return false;
119 }
120 true
121}
122
123#[allow(deprecated)]
124async fn notify_legacy_push(
125 peer: &Peer<RoleServer>,
126 minimum_level: &std::sync::atomic::AtomicU8,
127 data: serde_json::Value,
128) -> Result<(), rmcp::service::ServiceError> {
129 if minimum_level.load(Ordering::Acquire) > LEGACY_PUSH_INFO_LEVEL_RANK {
130 return Ok(());
131 }
132 let params =
133 rmcp::model::LoggingMessageNotificationParam::new(rmcp::model::LoggingLevel::Info, data)
134 .with_logger("futu_push");
135 peer.notify_logging_message(params).await
136}
137
138#[derive(Clone)]
140pub struct ServerState {
141 inner: Arc<Mutex<Inner>>,
143 enable_trading: bool,
146 allow_real_trading: bool,
148 key_store: Arc<KeyStore>,
150 authed_key: Option<Arc<KeyRecord>>,
152 trade_pwd_account: Option<String>,
155 counters: Arc<RuntimeCounters>,
157 push_subscribers: Arc<Mutex<HashMap<String, PushSubscriber>>>,
162 modern_push_slots: Arc<Semaphore>,
168}
169
170struct Inner {
172 gateway: String,
174 client: Option<Arc<FutuClient>>,
176}
177
178impl ServerState {
179 pub fn new(gateway: String) -> Self {
182 Self {
183 inner: Arc::new(Mutex::new(Inner {
184 gateway,
185 client: None,
186 })),
187 enable_trading: false,
188 allow_real_trading: false,
189 key_store: Arc::new(KeyStore::empty()),
190 authed_key: None,
191 trade_pwd_account: None,
192 counters: Arc::new(RuntimeCounters::new()),
193 push_subscribers: Arc::new(Mutex::new(HashMap::new())),
194 modern_push_slots: Arc::new(Semaphore::new(push_subscribers::MAX_MODERN_PUSH_HANDLES)),
195 }
196 }
197
198 pub fn with_trading(mut self, enable_trading: bool, allow_real_trading: bool) -> Self {
200 self.enable_trading = enable_trading;
201 self.allow_real_trading = allow_real_trading;
202 self
203 }
204
205 pub fn with_key_store(mut self, store: Arc<KeyStore>) -> Self {
207 self.key_store = store;
208 self
209 }
210
211 pub fn with_authed_key(mut self, key: Option<Arc<KeyRecord>>) -> Self {
213 self.authed_key = key;
214 self
215 }
216
217 pub fn with_trade_pwd_account(mut self, account: Option<String>) -> Self {
220 self.trade_pwd_account = account;
221 self
222 }
223
224 pub fn is_scope_mode(&self) -> bool {
226 self.key_store.is_configured()
227 }
228
229 pub fn enable_trading(&self) -> bool {
231 self.enable_trading
232 }
233
234 pub fn allow_real_trading(&self) -> bool {
236 self.allow_real_trading
237 }
238
239 pub fn key_store(&self) -> &Arc<KeyStore> {
241 &self.key_store
242 }
243
244 pub fn authed_key(&self) -> Option<Arc<KeyRecord>> {
246 self.authed_key.clone()
247 }
248
249 pub fn trade_pwd_account(&self) -> Option<&str> {
251 self.trade_pwd_account.as_deref()
252 }
253
254 pub fn counters(&self) -> &Arc<RuntimeCounters> {
256 &self.counters
257 }
258
259 pub async fn gateway(&self) -> String {
261 self.inner.lock().await.gateway.clone()
262 }
263
264 pub async fn client(&self) -> Result<Arc<FutuClient>> {
266 let gateway = {
267 let guard = self.inner.lock().await;
268 if let Some(c) = &guard.client {
269 return Ok(c.clone());
270 }
271 guard.gateway.clone()
272 };
273
274 let config = ClientConfig {
275 addr: gateway.clone(),
276 client_ver: env!("CARGO_PKG_VERSION").to_string(),
277 client_id: "futu-mcp".to_string(),
278 recv_notify: false,
279 rsa_key: None,
280 };
281 let policy =
282 ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
283 let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
284 let connect_result =
285 tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
286 let (client, mut push_rx, _info) = match connect_result {
287 Ok(result) => {
288 result.with_context(|| format!("connect to futu gateway at {gateway}"))?
289 }
290 Err(_) => {
291 return Err(anyhow!(
292 "connect to futu gateway at {gateway} timed out after {}s",
293 MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
294 ));
295 }
296 };
297
298 let arc = Arc::new(client);
299 {
300 let mut guard = self.inner.lock().await;
301 if let Some(c) = &guard.client {
302 return Ok(c.clone());
303 }
304 guard.client = Some(arc.clone());
305 }
306
307 let subs_for_push = Arc::downgrade(&self.push_subscribers);
319 let key_store_for_push = Arc::downgrade(&self.key_store);
320 let filter_registry =
326 std::sync::Arc::new(futu_auth_pipeline::FilterRegistry::with_defaults());
327 tokio::spawn(async move {
328 while let Some(push) = push_rx.recv().await {
329 let Some(subs_for_push) = subs_for_push.upgrade() else {
330 break;
331 };
332 let Some(key_store_for_push) = key_store_for_push.upgrade() else {
333 break;
334 };
335 let subscribers = {
336 let subs = subs_for_push.lock().await;
337 if subs.is_empty() {
338 Vec::new()
339 } else {
340 subs.iter()
341 .map(|(session_id, sub)| (session_id.clone(), sub.clone()))
342 .collect::<Vec<_>>()
343 }
344 };
345 if subscribers.is_empty() {
346 continue; }
348 let decode_result = classify_trade_push(push.proto_id, &push.body);
354 let (push_acc_id, push_trd_market, decode_status, event_type) = match &decode_result
355 {
356 TradePushDecode::NotTrade => (None, None, "ok", "quote"),
357 TradePushDecode::Decoded { acc_id, trd_market } => {
358 (Some(*acc_id), Some(*trd_market), "ok", "trade")
359 }
360 TradePushDecode::DecodeFailed => (None, None, "failed", "trade"),
361 };
362 let push_trd_market_str = push_trd_market.map(trd_market_int_to_str);
363 let payload = serde_json::json!({
367 "kind": "futu_push",
368 "proto_id": push.proto_id,
369 "acc_id": push_acc_id,
370 "event_type": event_type,
371 "trd_market": push_trd_market_str,
372 "decode_status": decode_status,
373 "body_base64": base64_encode_bytes(&push.body),
374 });
375 let deliveries = {
376 let mut deliveries = Vec::with_capacity(subscribers.len());
377 for (session_id, sub) in subscribers.iter() {
378 if !push_delivery_is_authorized(
379 sub,
380 &key_store_for_push,
381 &filter_registry,
382 &decode_result,
383 event_type,
384 push_acc_id,
385 push_trd_market_str,
386 push.proto_id,
387 ) {
388 continue;
389 }
390 deliveries.push(PendingPushDelivery {
391 target: sub.delivery.clone(),
392 data: payload.clone(),
393 session_id: session_id.clone(),
394 owner_key_id: sub.owner_key_id.clone(),
395 proto_id: push.proto_id,
396 });
397 }
398 deliveries
399 };
400 for delivery in deliveries {
401 match delivery.target {
402 SubscriberDelivery::LegacyPeer(peer, minimum_level) => {
403 tokio::spawn(async move {
404 let result =
405 notify_legacy_push(&peer, &minimum_level, delivery.data)
406 .await
407 .map_err(|error| error.to_string());
408 if let Err(err) = result {
409 tracing::warn!(
410 proto_id = delivery.proto_id,
411 session_id = delivery.session_id,
412 owner_key_id = delivery.owner_key_id.as_deref().unwrap_or("<none>"),
413 error = %err,
414 "mcp push notification send failed"
415 );
416 }
417 });
418 }
419 SubscriberDelivery::ModernResource(resource) => {
420 if let Some(work) = resource.enqueue(delivery.data) {
421 tokio::spawn(async move {
422 resource.send_pending_notification(work).await;
423 });
424 }
425 }
426 }
427 }
428 }
429 });
430
431 let subs_for_purge = Arc::downgrade(&self.push_subscribers);
435 tokio::spawn(async move {
436 use std::time::Duration;
437 const PURGE_INTERVAL: Duration = Duration::from_secs(5 * 60);
438 let mut ticker = tokio::time::interval(PURGE_INTERVAL);
439 ticker.tick().await; loop {
441 ticker.tick().await;
442 let Some(subs_for_purge) = subs_for_purge.upgrade() else {
443 break;
444 };
445 let now = std::time::Instant::now();
446 let removed = {
447 let mut subs = subs_for_purge.lock().await;
448 let expired = subs
449 .iter()
450 .filter_map(|(handle, sub)| {
451 now.checked_duration_since(sub.registered_at)
452 .is_some_and(|age| age >= push_subscribers::PUSH_SUBSCRIBER_MAX_AGE)
453 .then_some(handle.clone())
454 })
455 .collect::<Vec<_>>();
456 expired
457 .into_iter()
458 .filter_map(|handle| subs.remove(&handle))
459 .collect::<Vec<_>>()
460 };
461 let purged = removed.len();
462 for subscriber in removed {
463 subscriber.close_modern_resource();
464 }
465 if purged > 0 {
466 let remaining = subs_for_purge.lock().await.len();
467 tracing::info!(
468 purged,
469 remaining,
470 max_age_secs = push_subscribers::PUSH_SUBSCRIBER_MAX_AGE.as_secs(),
471 "v1.4.39 Phase 5: purged stale push subscribers (> 4h registered)"
472 );
473 }
474 }
475 });
476
477 Ok(arc)
478 }
479
480 pub async fn verification_client(&self) -> Result<Arc<FutuClient>> {
484 let gateway = self.gateway().await;
485 let config = ClientConfig {
486 addr: gateway.clone(),
487 client_ver: env!("CARGO_PKG_VERSION").to_string(),
488 client_id: futu_core::INTERNAL_UI_CLIENT_ID.to_string(),
489 recv_notify: false,
490 rsa_key: None,
491 };
492 let policy =
493 ReconnectPolicy::new(MCP_CONNECT_RETRY_DELAY, MCP_CONNECT_RETRY_DELAY, Some(1));
494 let mut reconnector = ReconnectingClient::new(config).with_policy(policy);
495 let connect_result =
496 tokio::time::timeout(MCP_CONNECT_TOTAL_TIMEOUT, reconnector.connect()).await;
497 let (client, _push_rx, _info) = match connect_result {
498 Ok(result) => result.with_context(|| {
499 format!("connect Verification transport to futu gateway at {gateway}")
500 })?,
501 Err(_) => {
502 return Err(anyhow!(
503 "connect Verification transport to futu gateway at {gateway} timed out after {}s",
504 MCP_CONNECT_TOTAL_TIMEOUT.as_secs()
505 ));
506 }
507 };
508 Ok(Arc::new(client))
509 }
510}
511
512pub fn parse_symbol(s: &str) -> Result<Security> {
515 let parsed = qot_symbol::parse_qot_symbol_parts(s).map_err(|err| anyhow!("{err}"))?;
516 Ok(qot_sdk_adapter::security_from_parsed_symbol(parsed))
517}
518
519pub fn format_symbol(sec: &Security) -> String {
521 qot_symbol::format_qot_symbol(sec.market as i32, &sec.code)
522}
523
524pub mod audit_fmt {
540 #[inline]
542 pub fn opt_f64(v: Option<f64>) -> f64 {
543 v.unwrap_or(f64::NAN)
544 }
545
546 #[inline]
549 pub fn opt_i32(v: Option<i32>) -> f64 {
550 v.map(f64::from).unwrap_or(f64::NAN)
551 }
552
553 #[inline]
556 pub fn opt_str(v: Option<&str>) -> &str {
557 v.unwrap_or("")
558 }
559}