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

367 lines
16 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if os(macOS)
import SweetCookieKit
#endif
#if os(macOS)
private let t3ChatCookieImportOrder: BrowserCookieImportOrder =
ProviderDefaults.metadata[.t3chat]?.browserCookieOrder ?? Browser.defaultImportOrder
public enum T3ChatCookieImporter {
private static let cookieClient = BrowserCookieClient()
private static let cookieDomains = ["t3.chat", "www.t3.chat"]
public struct SessionInfo: Sendable {
public let cookieHeader: String
public let sourceLabel: String
public init(cookieHeader: String, sourceLabel: String) {
self.cookieHeader = cookieHeader
self.sourceLabel = sourceLabel
}
}
public static func importSession(
browserDetection: BrowserDetection,
logger: ((String) -> Void)? = nil) throws -> SessionInfo
{
let log: (String) -> Void = { msg in logger?("[t3chat-cookie] \(msg)") }
let installed = t3ChatCookieImportOrder.cookieImportCandidates(using: browserDetection)
for browserSource in installed {
do {
let query = BrowserCookieQuery(domains: self.cookieDomains)
let sources = try self.cookieClient.codexBarRecords(
matching: query,
in: browserSource,
logger: log)
for source in sources where !source.records.isEmpty {
let cookies = BrowserCookieClient.makeHTTPCookies(source.records, origin: query.origin)
guard !cookies.isEmpty else { continue }
let names = cookies.map(\.name).joined(separator: ", ")
log("\(source.label) cookies: \(names)")
let header = cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
return SessionInfo(cookieHeader: header, sourceLabel: source.label)
}
} catch {
BrowserCookieAccessGate.recordIfNeeded(error)
log("\(browserSource.displayName) cookie import failed: \(error.localizedDescription)")
}
}
throw T3ChatUsageError.noSessionCookie
}
}
#endif
public struct T3ChatUsageFetcher: Sendable {
private static let log = CodexBarLog.logger(LogCategories.t3chat)
private static let baseURL = URL(string: "https://t3.chat")!
private static let refererURL = URL(string: "https://t3.chat/settings/customization")!
/// Browser fingerprint defaults are only fallbacks; full cURL captures override these forwarded headers.
private static let userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
/// Captured from T3 Chat's getCustomerData tRPC request shape in May 2026.
private static let input = #"{"0":{"json":{"sessionId":null},"meta":{"values":{"sessionId":["undefined"]}}}}"#
private static let forwardedManualHeaders = [
"accept": "Accept",
"accept-language": "Accept-Language",
"cache-control": "Cache-Control",
"pragma": "Pragma",
"priority": "Priority",
"referer": "Referer",
"sec-fetch-dest": "Sec-Fetch-Dest",
"sec-fetch-mode": "Sec-Fetch-Mode",
"sec-fetch-site": "Sec-Fetch-Site",
"trpc-accept": "trpc-accept",
"user-agent": "User-Agent",
"x-client-context": "x-client-context",
"x-deployment-id": "X-Deployment-Id",
"x-trpc-batch": "x-trpc-batch",
"x-trpc-source": "x-trpc-source",
]
public struct RequestContext: Sendable {
public let cookieHeader: String
public let headers: [String: String]
public init(cookieHeader: String, headers: [String: String] = [:]) {
self.cookieHeader = cookieHeader
self.headers = headers
}
}
public let browserDetection: BrowserDetection
public init(browserDetection: BrowserDetection) {
self.browserDetection = browserDetection
}
public func fetch(
cookieHeaderOverride: String? = nil,
timeout: TimeInterval = 15,
logger: ((String) -> Void)? = nil,
now: Date = Date(),
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
{
let log: (String) -> Void = { msg in logger?("[t3chat] \(msg)") }
let context = try await self.resolveRequestContext(override: cookieHeaderOverride, logger: log)
if let logger {
let names = CookieHeaderNormalizer.pairs(from: context.cookieHeader).map(\.name)
if !names.isEmpty {
logger("[t3chat] Cookie names: \(names.joined(separator: ", "))")
}
if !context.headers.isEmpty {
let headerNames = context.headers.keys.sorted().joined(separator: ", ")
logger("[t3chat] Forwarding captured headers: \(headerNames)")
}
}
return try await Self.fetchCustomerData(
context: context,
timeout: timeout,
now: now,
transport: transport)
}
public func debugRawProbe(cookieHeaderOverride: String? = nil) async -> String {
let stamp = ISO8601DateFormatter().string(from: Date())
var lines: [String] = []
lines.append("=== T3 Chat Debug Probe @ \(stamp) ===")
lines.append("")
do {
let snapshot = try await self.fetch(
cookieHeaderOverride: cookieHeaderOverride,
logger: { msg in lines.append(msg) })
lines.append("")
lines.append("Fetch Success")
lines.append("subTier=\(snapshot.customerData.subTier ?? "nil")")
lines.append("usageBand=\(snapshot.customerData.usageBand ?? "nil")")
lines
.append(
"usageFourHourPercentage=\(snapshot.customerData.usageFourHourPercentage?.description ?? "nil")")
lines.append("usageMonthPercentage=\(snapshot.customerData.usageMonthPercentage?.description ?? "nil")")
lines.append("usagePeriodPercentage=\(snapshot.customerData.usagePeriodPercentage?.description ?? "nil")")
lines
.append(
"usageFourHourNextResetAt=\(snapshot.customerData.usageFourHourNextResetAt?.description ?? "nil")")
lines.append("billingNextResetAt=\(snapshot.customerData.billingNextResetAt?.description ?? "nil")")
} catch {
lines.append("")
lines.append("Probe Failed: \(error.localizedDescription)")
}
return lines.joined(separator: "\n")
}
public static func fetchCustomerData(
cookieHeader: String,
timeout: TimeInterval = 15,
now: Date = Date(),
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
{
guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(cookieHeader) else {
throw T3ChatUsageError.noSessionCookie
}
return try await self.fetchCustomerData(
context: RequestContext(cookieHeader: normalizedCookieHeader),
timeout: timeout,
now: now,
transport: transport)
}
public static func fetchCustomerData(
context: RequestContext,
timeout: TimeInterval = 15,
now: Date = Date(),
transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> T3ChatUsageSnapshot
{
guard let normalizedCookieHeader = CookieHeaderNormalizer.normalize(context.cookieHeader) else {
throw T3ChatUsageError.noSessionCookie
}
let url = try self.customerDataURL()
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = timeout
self.applyDefaultHeaders(to: &request)
for (name, value) in context.headers {
request.setValue(value, forHTTPHeaderField: name)
}
request.setValue(self.baseURL.absoluteString, forHTTPHeaderField: "Origin")
request.setValue(normalizedCookieHeader, forHTTPHeaderField: "Cookie")
let response = try await transport.response(for: request)
let data = response.data
guard response.statusCode == 200 else {
let body = String(data: data.prefix(200), encoding: .utf8) ?? "<binary>"
Self.log.error("T3 Chat API returned \(response.statusCode): \(body)")
if response.statusCode == 401 || response.statusCode == 403 {
throw T3ChatUsageError.invalidCredentials
}
if response.statusCode == 429,
response.response.value(forHTTPHeaderField: "x-vercel-mitigated") == "challenge"
{
throw T3ChatUsageError.vercelChallenge
}
throw T3ChatUsageError.apiError("HTTP \(response.statusCode)")
}
do {
return try T3ChatUsageParser.parseJSONLines(data, now: now)
} catch {
let preview = String(data: data.prefix(500), encoding: .utf8) ?? "<binary>"
Self.log.error("T3 Chat parse failed: \(error.localizedDescription) response=\(preview)")
throw error
}
}
private func resolveRequestContext(
override: String?,
logger: ((String) -> Void)?) async throws -> RequestContext
{
if let override = Self.requestContext(from: override) {
let source = override.headers.isEmpty ? "manual cookie header" : "manual cURL capture"
logger?("[t3chat] Using \(source)")
return override
}
#if os(macOS)
let session = try T3ChatCookieImporter.importSession(
browserDetection: self.browserDetection,
logger: logger)
logger?("[t3chat] Using cookies from \(session.sourceLabel)")
return RequestContext(cookieHeader: session.cookieHeader)
#else
throw T3ChatUsageError.noSessionCookie
#endif
}
static func requestContext(from raw: String?) -> RequestContext? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
let headerFields = Self.headerFields(from: raw)
guard let cookieHeader = Self.cookieHeader(from: headerFields) ?? CookieHeaderNormalizer.normalize(raw) else {
return nil
}
let headers = Self.forwardedHeaders(from: headerFields)
return RequestContext(cookieHeader: cookieHeader, headers: headers)
}
private static func applyDefaultHeaders(to request: inout URLRequest) {
request.setValue("*/*", forHTTPHeaderField: "Accept")
request.setValue("application/jsonl", forHTTPHeaderField: "trpc-accept")
request.setValue("web-client", forHTTPHeaderField: "x-trpc-source")
request.setValue("true", forHTTPHeaderField: "x-trpc-batch")
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
request.setValue(self.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue(self.refererURL.absoluteString, forHTTPHeaderField: "Referer")
request.setValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.setValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.setValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.setValue("u=4", forHTTPHeaderField: "Priority")
request.setValue("no-cache", forHTTPHeaderField: "Pragma")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
}
private static func forwardedHeaders(from fields: [String]) -> [String: String] {
var headers: [String: String] = [:]
for field in fields {
guard let colon = field.firstIndex(of: ":") else { continue }
let rawName = field[..<colon].trimmingCharacters(in: .whitespacesAndNewlines)
let value = field[field.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawName.isEmpty, !value.isEmpty else { continue }
guard let canonical = self.forwardedManualHeaders[rawName.lowercased()] else { continue }
headers[canonical] = value
}
return headers
}
private static func cookieHeader(from fields: [String]) -> String? {
for field in fields {
guard let colon = field.firstIndex(of: ":") else { continue }
let rawName = field[..<colon].trimmingCharacters(in: .whitespacesAndNewlines)
guard rawName.caseInsensitiveCompare("Cookie") == .orderedSame else { continue }
let value = field[field.index(after: colon)...].trimmingCharacters(in: .whitespacesAndNewlines)
if let normalized = CookieHeaderNormalizer.normalize(String(value)) {
return normalized
}
}
return nil
}
private static func headerFields(from raw: String) -> [String] {
var fields: [String] = []
let pattern =
#"(?s)(?:^|\s)(?:-H|--header)(?:\s+|=|(?=['"$]))"# +
#"(?:\$'((?:\\.|[^'])*)'|'([^']*)'|"((?:\\.|[^"])*)"|(\S+))"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return fields }
let range = NSRange(raw.startIndex..<raw.endIndex, in: raw)
for match in regex.matches(in: raw, options: [], range: range) {
if let ansi = self.capture(1, in: match, raw: raw) {
fields.append(self.unescapeShellSegment(ansi, ansi: true))
} else if let single = self.capture(2, in: match, raw: raw) {
fields.append(single)
} else if let double = self.capture(3, in: match, raw: raw) {
fields.append(self.unescapeShellSegment(double, ansi: false))
} else if let bare = self.capture(4, in: match, raw: raw) {
fields.append(self.unescapeShellSegment(bare, ansi: false))
}
}
return fields
}
private static func capture(_ index: Int, in match: NSTextCheckingResult, raw: String) -> String? {
guard match.numberOfRanges > index,
let range = Range(match.range(at: index), in: raw)
else {
return nil
}
return String(raw[range])
}
private static func unescapeShellSegment(_ raw: String, ansi: Bool) -> String {
var output = ""
var index = raw.startIndex
while index < raw.endIndex {
guard raw[index] == "\\" else {
output.append(raw[index])
index = raw.index(after: index)
continue
}
let next = raw.index(after: index)
guard next < raw.endIndex else { return output }
switch raw[next] {
case "n" where ansi:
output.append("\n")
case "r" where ansi:
output.append("\r")
case "t" where ansi:
output.append("\t")
case "\n":
break
default:
output.append(raw[next])
}
index = raw.index(after: next)
}
return output
}
private static func customerDataURL() throws -> URL {
var components = URLComponents(string: "https://t3.chat/api/trpc/getCustomerData")!
components.queryItems = [
URLQueryItem(name: "batch", value: "1"),
URLQueryItem(name: "input", value: self.input),
]
guard let url = components.url else {
throw T3ChatUsageError.apiError("Failed to build customer data URL.")
}
return url
}
}