Files
2026-07-13 12:22:33 +08:00

181 lines
6.8 KiB
Swift

import Foundation
public enum T3ChatUsageError: LocalizedError, Sendable {
case noSessionCookie
case invalidCredentials
case vercelChallenge
case apiError(String)
case parseFailed(String)
public var errorDescription: String? {
switch self {
case .noSessionCookie:
"No T3 Chat cookies found. Please log in to t3.chat in your browser."
case .invalidCredentials:
"T3 Chat session cookie is invalid or expired."
case .vercelChallenge:
"T3 Chat returned a Vercel security challenge. Paste the full browser cURL request, " +
"not just the Cookie header."
case let .apiError(message):
"T3 Chat API error: \(message)"
case let .parseFailed(message):
"Could not parse T3 Chat usage: \(message)"
}
}
}
public struct T3ChatSubscription: Decodable, Sendable {
public let productId: String?
public let productName: String?
public let status: String?
public let currentPeriodStart: TimeInterval?
public let currentPeriodEnd: TimeInterval?
public let canceledAt: TimeInterval?
public let trialEndsAt: TimeInterval?
}
public struct T3ChatCustomerData: Decodable, Sendable {
public let subTier: String?
public let subscription: T3ChatSubscription?
public let lifetimeBalance: Double?
public let usageBand: String?
public let billingNextResetAt: TimeInterval?
public let usageFourHourPercentage: Double?
public let usageMonthPercentage: Double?
public let usageFourHourNextResetAt: TimeInterval?
public let usagePeriodPercentage: Double?
public let usageWindowNextResetAt: TimeInterval?
public var planName: String? {
let raw = self.subscription?.productName ?? self.subTier
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
return raw.split(separator: "-").map { part in
part.prefix(1).uppercased() + String(part.dropFirst())
}.joined(separator: " ")
}
}
public struct T3ChatUsageSnapshot: Sendable {
public let customerData: T3ChatCustomerData
public let updatedAt: Date
public init(customerData: T3ChatCustomerData, updatedAt: Date) {
self.customerData = customerData
self.updatedAt = updatedAt
}
public func toUsageSnapshot() -> UsageSnapshot {
let baseReset = Self.date(fromMilliseconds: self.customerData.usageFourHourNextResetAt)
?? Self.date(fromMilliseconds: self.customerData.usageWindowNextResetAt)
// billingNextResetAt tracks the usage window reset, not the overage billing period.
// If subscription metadata is absent, leave the overage reset unknown instead of showing the base reset.
let overageReset = Self.date(fromMilliseconds: self.customerData.subscription?.currentPeriodEnd)
let primary = RateWindow(
usedPercent: Self.percent(self.customerData.usageFourHourPercentage),
windowMinutes: 4 * 60,
resetsAt: baseReset,
resetDescription: Self.description(label: "Base", usageBand: self.customerData.usageBand))
let secondaryPercent = self.customerData.usageMonthPercentage
?? self.customerData.usagePeriodPercentage
let secondary = RateWindow(
usedPercent: Self.percent(secondaryPercent),
windowMinutes: nil,
resetsAt: overageReset,
resetDescription: "Overage")
let identity = ProviderIdentitySnapshot(
providerID: .t3chat,
accountEmail: nil,
accountOrganization: nil,
loginMethod: self.customerData.planName)
return UsageSnapshot(
primary: primary,
secondary: secondary,
updatedAt: self.updatedAt,
identity: identity)
}
private static func percent(_ raw: Double?) -> Double {
min(100, max(0, raw ?? 0))
}
private static func date(fromMilliseconds raw: TimeInterval?) -> Date? {
guard let raw, raw > 0 else { return nil }
// T3 Chat currently returns JavaScript epoch milliseconds, while some subscription fields may be seconds.
let seconds = raw > 10_000_000_000 ? raw / 1000 : raw
return Date(timeIntervalSince1970: seconds)
}
private static func description(label: String, usageBand: String?) -> String {
guard let usageBand = usageBand?.trimmingCharacters(in: .whitespacesAndNewlines),
!usageBand.isEmpty
else {
return label
}
return "\(label) - \(usageBand)"
}
}
public enum T3ChatUsageParser {
public static func parseJSONLines(_ data: Data, now: Date = Date()) throws -> T3ChatUsageSnapshot {
guard let text = String(data: data, encoding: .utf8) else {
throw T3ChatUsageError.parseFailed("Response is not UTF-8.")
}
return try self.parseJSONLines(text, now: now)
}
public static func parseJSONLines(_ text: String, now: Date = Date()) throws -> T3ChatUsageSnapshot {
let lines = text.split(whereSeparator: \.isNewline)
for line in lines {
guard let data = String(line).data(using: .utf8) else { continue }
guard let object = try? JSONSerialization.jsonObject(with: data) else { continue }
guard let customerObject = self.findCustomerData(in: object) else { continue }
let customerData = try self.decodeCustomerData(customerObject)
return T3ChatUsageSnapshot(customerData: customerData, updatedAt: now)
}
throw T3ChatUsageError.parseFailed("Missing customer data object.")
}
private static func findCustomerData(in object: Any) -> [String: Any]? {
if let dictionary = object as? [String: Any] {
if dictionary["usageFourHourPercentage"] != nil ||
dictionary["usageMonthPercentage"] != nil ||
dictionary["subscription"] != nil && dictionary["usageBand"] != nil
{
return dictionary
}
for value in dictionary.values {
if let found = self.findCustomerData(in: value) {
return found
}
}
}
if let array = object as? [Any] {
for value in array {
if let found = self.findCustomerData(in: value) {
return found
}
}
}
return nil
}
private static func decodeCustomerData(_ object: [String: Any]) throws -> T3ChatCustomerData {
do {
let data = try JSONSerialization.data(withJSONObject: object, options: [])
return try JSONDecoder().decode(T3ChatCustomerData.self, from: data)
} catch {
throw T3ChatUsageError.parseFailed(error.localizedDescription)
}
}
}