181 lines
6.8 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|