1
0
Files
card/ios/AiCard/AiCard/Services/APIClient.swift
syui 9f9208e160 Integrate ai.card Rust API with ai.gpt MCP and implement daily limit system
### Major Changes:
- **Rust Migration**: Move api-rs to root directory, rename binary to 'aicard'
- **MCP Integration**: Add card tools to ai.gpt MCP server (get_user_cards, draw_card, get_draw_status)
- **Daily Limit System**: Implement 2-day interval card drawing limits in API and iOS
- **iOS Enhancements**: Add DrawStatusView, backup functionality, and limit integration

### Technical Details:
- ai.gpt MCP now has 20 tools including 3 card-related tools
- ServiceClient enhanced with missing card API methods
- iOS app includes daily limit UI and atproto OAuth backup features
- Database migration for last_draw_date field
- Complete feature parity between web and iOS implementations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-08 10:35:43 +09:00

243 lines
8.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
import Combine
enum APIError: Error {
case invalidURL
case noData
case decodingError
case networkError(String)
case unauthorized
}
// MCP Server response format
struct MCPResponse<T: Decodable>: Decodable {
let data: T?
let error: String?
}
class APIClient {
static let shared = APIClient()
#if DEBUG
private let baseURL = "http://localhost:8000/api/v1" // ai.card direct access
private let aiGptBaseURL = "http://localhost:8001" // ai.gpt MCP server (optional)
#else
private let baseURL = "https://api.card.syui.ai/api/v1" // ai.card direct access
private let aiGptBaseURL = "https://ai.gpt.syui.ai" // ai.gpt MCP server (optional)
#endif
private var cancellables = Set<AnyCancellable>()
private init() {}
private var authToken: String? {
get { UserDefaults.standard.string(forKey: "authToken") }
set { UserDefaults.standard.set(newValue, forKey: "authToken") }
}
// ai.gpt MCP
private func mcpRequest<T: Decodable>(_ endpoint: String,
parameters: [String: Any] = [:]) -> AnyPublisher<T, APIError> {
guard let url = URL(string: "\(aiGptBaseURL)\(endpoint)") else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.queryItems = parameters.map { URLQueryItem(name: $0.key, value: "\($0.value)") }
guard let finalURL = components?.url else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var request = URLRequest(url: finalURL)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if !(200...299).contains(httpResponse.statusCode) {
throw APIError.networkError("MCP Server error: \(httpResponse.statusCode)")
}
return data
}
.decode(type: MCPResponse<T>.self, decoder: JSONDecoder())
.tryMap { mcpResponse in
if let data = mcpResponse.data {
return data
} else if let error = mcpResponse.error {
throw APIError.networkError(error)
} else {
throw APIError.networkError("Invalid MCP response")
}
}
.mapError { error in
if error is DecodingError {
return APIError.decodingError
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.networkError(error.localizedDescription)
}
}
.eraseToAnyPublisher()
}
// ai.card
private func request<T: Decodable>(_ endpoint: String,
method: String = "GET",
body: Data? = nil,
authenticated: Bool = true) -> AnyPublisher<T, APIError> {
guard let url = URL(string: "\(baseURL)\(endpoint)") else {
return Fail(error: APIError.invalidURL).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if authenticated, let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = body
}
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.networkError("Invalid response")
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
if !(200...299).contains(httpResponse.statusCode) {
throw APIError.networkError("Status code: \(httpResponse.statusCode)")
}
return data
}
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
if error is DecodingError {
return APIError.decodingError
} else if let apiError = error as? APIError {
return apiError
} else {
return APIError.networkError(error.localizedDescription)
}
}
.eraseToAnyPublisher()
}
// MARK: - Auth
func login(identifier: String, password: String) -> AnyPublisher<LoginResponse, APIError> {
let loginRequest = LoginRequest(identifier: identifier, password: password)
guard let body = try? JSONEncoder().encode(loginRequest) else {
return Fail(error: APIError.decodingError).eraseToAnyPublisher()
}
return request("/auth/login", method: "POST", body: body, authenticated: false)
.handleEvents(receiveOutput: { [weak self] (response: LoginResponse) in
self?.authToken = response.accessToken
})
.eraseToAnyPublisher()
}
func logout() -> AnyPublisher<Void, APIError> {
request("/auth/logout", method: "POST")
.map { (_: [String: String]) in () }
.handleEvents(receiveCompletion: { [weak self] _ in
self?.authToken = nil
})
.eraseToAnyPublisher()
}
func verify() -> AnyPublisher<User, APIError> {
request("/auth/verify")
}
// MARK: - Cards (ai.card)
func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher<CardDrawResult, APIError> {
let body = try? JSONEncoder().encode([
"user_did": userDid,
"is_paid": isPaid
])
return request("/cards/draw", method: "POST", body: body)
}
func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> {
return request("/cards/user/\(userDid)")
}
func getCardDetails(cardId: Int) -> AnyPublisher<Card, APIError> {
return request("/cards/\(cardId)")
}
func getGachaStats() -> AnyPublisher<GachaStats, APIError> {
return request("/cards/stats")
}
func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> {
return request("/cards/unique")
}
func getSystemStatus() -> AnyPublisher<[String: Any], APIError> {
return request("/health")
}
// MARK: - Daily Draw Limit
func getDrawStatus(userDid: String) -> AnyPublisher<DrawStatus, APIError> {
return request("/cards/draw-status/\(userDid)")
}
}
// MARK: - AI Enhanced API (Optional ai.gpt integration)
extension APIClient {
func analyzeCollection(userDid: String) -> AnyPublisher<CollectionAnalysis, APIError> {
let parameters: [String: Any] = [
"did": userDid
]
return mcpRequest("/card_analyze_collection", parameters: parameters)
.catch { error -> AnyPublisher<CollectionAnalysis, APIError> in
// AI
return Fail(error: APIError.networkError("AI分析機能を利用するにはai.gptサーバーが必要です")).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func getEnhancedStats() -> AnyPublisher<GachaStats, APIError> {
return mcpRequest("/card_get_gacha_stats", parameters: [:])
.catch { [weak self] error -> AnyPublisher<GachaStats, APIError> in
// AI
print("AI統計が利用できません、基本統計に切り替えます: \(error)")
return self?.getGachaStats() ?? Fail(error: error).eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
func isAIAvailable() -> AnyPublisher<Bool, Never> {
guard let url = URL(string: "\(aiGptBaseURL)/health") else {
return Just(false).eraseToAnyPublisher()
}
return URLSession.shared.dataTaskPublisher(for: url)
.map { _ in true }
.catch { _ in Just(false) }
.eraseToAnyPublisher()
}
}