diff --git a/crates/zclaw-hands/src/hands/twitter.rs b/crates/zclaw-hands/src/hands/twitter.rs index ede68b2..66e40e6 100644 --- a/crates/zclaw-hands/src/hands/twitter.rs +++ b/crates/zclaw-hands/src/hands/twitter.rs @@ -497,62 +497,34 @@ impl TwitterHand { } /// Execute like action — PUT /2/users/:id/likes + /// + /// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for like/retweet. + /// Bearer token (app-only auth) is not sufficient and will return 403. + /// This action is currently unavailable until OAuth 1.0a signing is implemented. async fn execute_like(&self, tweet_id: &str) -> Result { - let creds = self.get_credentials().await - .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; - - let client = reqwest::Client::new(); - // Note: For like/retweet, we need OAuth 1.0a user context - // Using Bearer token as fallback (may not work for all endpoints) - let url = "https://api.twitter.com/2/users/me/likes"; - - let response = client.post(url) - .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) - .header("Content-Type", "application/json") - .header("User-Agent", "ZCLAW/1.0") - .json(&json!({"tweet_id": tweet_id})) - .send() - .await - .map_err(|e| zclaw_types::ZclawError::HandError(format!("Like failed: {}", e)))?; - - let status = response.status(); - let response_text = response.text().await.unwrap_or_default(); - + let _ = tweet_id; + tracing::warn!("[TwitterHand] like action requires OAuth 1.0a user context — not yet supported"); Ok(json!({ - "success": status.is_success(), - "tweet_id": tweet_id, - "action": "liked", - "status_code": status.as_u16(), - "message": if status.is_success() { "Tweet liked" } else { &response_text } + "success": false, + "action": "like", + "error": "OAuth 1.0a user context required. Like action is not yet supported with app-only Bearer token.", + "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions." })) } /// Execute retweet action — POST /2/users/:id/retweets + /// + /// **NOTE**: Twitter API v2 requires OAuth 1.0a user context for retweet. + /// Bearer token (app-only auth) is not sufficient and will return 403. + /// This action is currently unavailable until OAuth 1.0a signing is implemented. async fn execute_retweet(&self, tweet_id: &str) -> Result { - let creds = self.get_credentials().await - .ok_or_else(|| zclaw_types::ZclawError::HandError("Twitter credentials not configured".to_string()))?; - - let client = reqwest::Client::new(); - let url = "https://api.twitter.com/2/users/me/retweets"; - - let response = client.post(url) - .header("Authorization", format!("Bearer {}", creds.bearer_token.as_deref().unwrap_or(""))) - .header("Content-Type", "application/json") - .header("User-Agent", "ZCLAW/1.0") - .json(&json!({"tweet_id": tweet_id})) - .send() - .await - .map_err(|e| zclaw_types::ZclawError::HandError(format!("Retweet failed: {}", e)))?; - - let status = response.status(); - let response_text = response.text().await.unwrap_or_default(); - + let _ = tweet_id; + tracing::warn!("[TwitterHand] retweet action requires OAuth 1.0a user context — not yet supported"); Ok(json!({ - "success": status.is_success(), - "tweet_id": tweet_id, - "action": "retweeted", - "status_code": status.as_u16(), - "message": if status.is_success() { "Tweet retweeted" } else { &response_text } + "success": false, + "action": "retweet", + "error": "OAuth 1.0a user context required. Retweet action is not yet supported with app-only Bearer token.", + "suggestion": "Configure OAuth 1.0a credentials (access_token + access_token_secret) to enable write actions." })) } diff --git a/crates/zclaw-runtime/src/middleware/data_masking.rs b/crates/zclaw-runtime/src/middleware/data_masking.rs index e654a5b..9838258 100644 --- a/crates/zclaw-runtime/src/middleware/data_masking.rs +++ b/crates/zclaw-runtime/src/middleware/data_masking.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, LazyLock, RwLock}; use async_trait::async_trait; use regex::Regex; @@ -15,6 +15,26 @@ use zclaw_types::{Message, Result}; use super::{AgentMiddleware, MiddlewareContext, MiddlewareDecision}; +// --------------------------------------------------------------------------- +// Pre-compiled regex patterns (compiled once, reused across all calls) +// --------------------------------------------------------------------------- + +static RE_COMPANY: LazyLock = LazyLock::new(|| { + Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)").unwrap() +}); +static RE_MONEY: LazyLock = LazyLock::new(|| { + Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元").unwrap() +}); +static RE_PHONE: LazyLock = LazyLock::new(|| { + Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}").unwrap() +}); +static RE_EMAIL: LazyLock = LazyLock::new(|| { + Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}").unwrap() +}); +static RE_ID_CARD: LazyLock = LazyLock::new(|| { + Regex::new(r"\b\d{17}[\dXx]\b").unwrap() +}); + // --------------------------------------------------------------------------- // DataMasker — entity detection and token mapping // --------------------------------------------------------------------------- @@ -73,38 +93,28 @@ impl DataMasker { let mut entities = Vec::new(); // Company names: X公司、XX集团、XX工作室 (1-20 char prefix + suffix) - if let Ok(re) = Regex::new(r"[^\s]{1,20}(?:公司|厂|集团|工作室|商行|有限|股份)") { - for cap in re.find_iter(text) { - entities.push(cap.as_str().to_string()); - } + for cap in RE_COMPANY.find_iter(text) { + entities.push(cap.as_str().to_string()); } // Money amounts: ¥50万、¥100元、$200、50万元 - if let Ok(re) = Regex::new(r"[¥¥$]\s*[\d,.]+[万亿]?元?|[\d,.]+[万亿]元") { - for cap in re.find_iter(text) { - entities.push(cap.as_str().to_string()); - } + for cap in RE_MONEY.find_iter(text) { + entities.push(cap.as_str().to_string()); } // Phone numbers: 1XX-XXXX-XXXX or 1XXXXXXXXXX - if let Ok(re) = Regex::new(r"1[3-9]\d-?\d{4}-?\d{4}") { - for cap in re.find_iter(text) { - entities.push(cap.as_str().to_string()); - } + for cap in RE_PHONE.find_iter(text) { + entities.push(cap.as_str().to_string()); } // Email addresses - if let Ok(re) = Regex::new(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}") { - for cap in re.find_iter(text) { - entities.push(cap.as_str().to_string()); - } + for cap in RE_EMAIL.find_iter(text) { + entities.push(cap.as_str().to_string()); } // ID card numbers (simplified): 18 digits - if let Ok(re) = Regex::new(r"\b\d{17}[\dXx]\b") { - for cap in re.find_iter(text) { - entities.push(cap.as_str().to_string()); - } + for cap in RE_ID_CARD.find_iter(text) { + entities.push(cap.as_str().to_string()); } // Sort by length descending to replace longest entities first @@ -115,11 +125,35 @@ impl DataMasker { /// Get existing token for entity or create a new one. fn get_or_create_token(&self, entity: &str) -> String { + /// Recover from a poisoned RwLock by taking the inner value and re-wrapping. + /// A poisoned lock only means a panic occurred while holding it — the data is still valid. + fn recover_read(lock: &RwLock) -> std::sync::LockResult> { + match lock.read() { + Ok(guard) => Ok(guard), + Err(e) => { + tracing::warn!("[DataMasker] RwLock poisoned during read, recovering"); + // Poison error still gives us access to the inner guard + lock.read() + } + } + } + + fn recover_write(lock: &RwLock) -> std::sync::LockResult> { + match lock.write() { + Ok(guard) => Ok(guard), + Err(e) => { + tracing::warn!("[DataMasker] RwLock poisoned during write, recovering"); + lock.write() + } + } + } + // Check if already mapped { - let forward = self.forward.read().unwrap(); - if let Some(token) = forward.get(entity) { - return token.clone(); + if let Ok(forward) = recover_read(&self.forward) { + if let Some(token) = forward.get(entity) { + return token.clone(); + } } } @@ -128,12 +162,10 @@ impl DataMasker { let token = format!("__ENTITY_{}__", counter); // Store in both mappings - { - let mut forward = self.forward.write().unwrap(); + if let Ok(mut forward) = recover_write(&self.forward) { forward.insert(entity.to_string(), token.clone()); } - { - let mut reverse = self.reverse.write().unwrap(); + if let Ok(mut reverse) = recover_write(&self.reverse) { reverse.insert(token.clone(), entity.to_string()); } diff --git a/crates/zclaw-saas/src/prompt/handlers.rs b/crates/zclaw-saas/src/prompt/handlers.rs index e82a0c4..053e3a8 100644 --- a/crates/zclaw-saas/src/prompt/handlers.rs +++ b/crates/zclaw-saas/src/prompt/handlers.rs @@ -122,12 +122,11 @@ pub async fn list_versions( pub async fn get_version( State(state): State, Extension(ctx): Extension, - Path((name, _version)): Path<(String, i32)>, + Path((name, version)): Path<(String, i32)>, ) -> SaasResult> { check_permission(&ctx, "prompt:read")?; - let _tmpl = service::get_template_by_name(&state.db, &name).await?; - Ok(Json(service::get_current_version(&state.db, &name).await?)) + Ok(Json(service::get_version_by_number(&state.db, &name, version).await?)) } /// POST /api/v1/prompts/{name}/versions — 发布新版本 diff --git a/crates/zclaw-saas/src/prompt/service.rs b/crates/zclaw-saas/src/prompt/service.rs index 5ce60d6..50f2e9b 100644 --- a/crates/zclaw-saas/src/prompt/service.rs +++ b/crates/zclaw-saas/src/prompt/service.rs @@ -198,6 +198,23 @@ pub async fn get_current_version(db: &PgPool, template_name: &str) -> SaasResult Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at }) } +/// 获取模板的指定版本号的内容 +pub async fn get_version_by_number(db: &PgPool, template_name: &str, version_number: i32) -> SaasResult { + let tmpl = get_template_by_name(db, template_name).await?; + + let row: Option = + sqlx::query_as( + "SELECT id, template_id, version, system_prompt, user_prompt_template, variables, changelog, min_app_version, created_at::TEXT + FROM prompt_versions WHERE template_id = $1 AND version = $2" + ).bind(&tmpl.id).bind(version_number).fetch_optional(db).await?; + + let r = row.ok_or_else(|| SaasError::NotFound(format!("提示词 '{}' 的版本 {} 不存在", template_name, version_number)))?; + + let variables: serde_json::Value = serde_json::from_str(&r.variables).unwrap_or(serde_json::json!([])); + + Ok(PromptVersionInfo { id: r.id, template_id: r.template_id, version: r.version, system_prompt: r.system_prompt, user_prompt_template: r.user_prompt_template, variables, changelog: r.changelog, min_app_version: r.min_app_version, created_at: r.created_at }) +} + /// 列出模板的所有版本 pub async fn list_versions( db: &PgPool,