chore: sync updates
This commit is contained in:
parent
a71f1c9872
commit
3df60e70ed
@ -21,6 +21,7 @@
|
||||
--commas always
|
||||
--semicolons never
|
||||
--ranges no-space
|
||||
--voidtype void
|
||||
--trimwhitespace always
|
||||
|
||||
# Wrapping
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
@ -28,7 +28,13 @@ public enum TKProviderId: String, CaseIterable, Sendable {
|
||||
public var credentialKeys: [String] {
|
||||
switch self {
|
||||
case .openai: ["OPENAI_API_KEY", "OPENAI_ACCESS_TOKEN"]
|
||||
case .anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_ACCESS_TOKEN", "ANTHROPIC_BETA_HEADER", "ANTHROPIC_REFRESH_TOKEN", "ANTHROPIC_ACCESS_EXPIRES"]
|
||||
case .anthropic: [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_ACCESS_TOKEN",
|
||||
"ANTHROPIC_BETA_HEADER",
|
||||
"ANTHROPIC_REFRESH_TOKEN",
|
||||
"ANTHROPIC_ACCESS_EXPIRES",
|
||||
]
|
||||
case .grok: ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
|
||||
case .gemini: ["GEMINI_API_KEY"]
|
||||
}
|
||||
@ -84,12 +90,13 @@ public struct TKCredentialStore {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: self.baseDir,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700])
|
||||
attributes: [.posixPermissions: 0o700],
|
||||
)
|
||||
|
||||
let header = [
|
||||
"# Tachikoma credentials file",
|
||||
"# Sensitive; keep permissions strict",
|
||||
""
|
||||
"",
|
||||
]
|
||||
let body = credentials.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }
|
||||
let content = (header + body).joined(separator: "\n")
|
||||
@ -153,7 +160,10 @@ public final class TKAuthManager {
|
||||
case .grok:
|
||||
let envOrder = ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
|
||||
for k in envOrder {
|
||||
if !self.ignoreEnv, let env = ProcessInfo.processInfo.environment[k], !env.isEmpty { return .bearer(env, betaHeader: nil) }
|
||||
if !self.ignoreEnv, let env = ProcessInfo.processInfo.environment[k], !env.isEmpty { return .bearer(
|
||||
env,
|
||||
betaHeader: nil,
|
||||
) }
|
||||
}
|
||||
for k in envOrder {
|
||||
if let val = creds[k], !val.isEmpty { return .bearer(val, betaHeader: nil) }
|
||||
@ -184,7 +194,12 @@ public final class TKAuthManager {
|
||||
|
||||
// MARK: OAuth
|
||||
|
||||
public func oauthLogin(provider: TKProviderId, timeout: Double = 30, noBrowser: Bool = false) async -> Result<Void, TKAuthError> {
|
||||
public func oauthLogin(
|
||||
provider: TKProviderId,
|
||||
timeout: Double = 30,
|
||||
noBrowser: Bool = false,
|
||||
) async
|
||||
-> Result<Void, TKAuthError> {
|
||||
guard provider.supportsOAuth else { return .failure(.unsupported) }
|
||||
let pkce = PKCE()
|
||||
let config = self.oauthConfig(for: provider, pkce: pkce)
|
||||
@ -206,7 +221,7 @@ public final class TKAuthManager {
|
||||
config: config,
|
||||
code: code,
|
||||
pkce: pkce,
|
||||
timeout: timeout
|
||||
timeout: timeout,
|
||||
)
|
||||
return self.persistOAuthResult(tokenResult, config: config)
|
||||
}
|
||||
@ -214,7 +229,7 @@ public final class TKAuthManager {
|
||||
private func oauthConfig(for provider: TKProviderId, pkce: PKCE) -> OAuthConfig {
|
||||
switch provider {
|
||||
case .openai:
|
||||
return OAuthConfig(
|
||||
OAuthConfig(
|
||||
prefix: "OPENAI",
|
||||
authorize: "https://auth.openai.com/oauth/authorize",
|
||||
token: "https://auth.openai.com/oauth/token",
|
||||
@ -224,10 +239,10 @@ public final class TKAuthManager {
|
||||
extraAuthorize: [:],
|
||||
extraToken: [:],
|
||||
betaHeader: nil,
|
||||
pkce: pkce
|
||||
pkce: pkce,
|
||||
)
|
||||
case .anthropic:
|
||||
return OAuthConfig(
|
||||
OAuthConfig(
|
||||
prefix: "ANTHROPIC",
|
||||
authorize: "https://claude.ai/oauth/authorize",
|
||||
token: "https://console.anthropic.com/v1/oauth/token",
|
||||
@ -237,10 +252,21 @@ public final class TKAuthManager {
|
||||
extraAuthorize: ["code": "true"],
|
||||
extraToken: [:],
|
||||
betaHeader: "oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
|
||||
pkce: pkce
|
||||
pkce: pkce,
|
||||
)
|
||||
case .grok, .gemini:
|
||||
return OAuthConfig(prefix: "", authorize: "", token: "", clientId: "", scope: "", redirect: "", extraAuthorize: [:], extraToken: [:], betaHeader: nil, pkce: pkce)
|
||||
OAuthConfig(
|
||||
prefix: "",
|
||||
authorize: "",
|
||||
token: "",
|
||||
clientId: "",
|
||||
scope: "",
|
||||
redirect: "",
|
||||
extraAuthorize: [:],
|
||||
extraToken: [:],
|
||||
betaHeader: nil,
|
||||
pkce: pkce,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,7 +283,10 @@ public final class TKAuthManager {
|
||||
do {
|
||||
try self.setCredential(key: "\(config.prefix)_ACCESS_TOKEN", value: token.access)
|
||||
try self.setCredential(key: "\(config.prefix)_REFRESH_TOKEN", value: token.refresh)
|
||||
try self.setCredential(key: "\(config.prefix)_ACCESS_EXPIRES", value: String(Int(token.expires.timeIntervalSince1970)))
|
||||
try self.setCredential(
|
||||
key: "\(config.prefix)_ACCESS_EXPIRES",
|
||||
value: String(Int(token.expires.timeIntervalSince1970)),
|
||||
)
|
||||
if let beta = config.betaHeader {
|
||||
try self.setCredential(key: "\(config.prefix)_BETA_HEADER", value: beta)
|
||||
}
|
||||
@ -280,7 +309,7 @@ struct PKCE {
|
||||
init() {
|
||||
let data = Data((0..<32).map { _ in UInt8.random(in: 0...255) })
|
||||
self.verifier = data.urlSafeBase64()
|
||||
self.challenge = Data(SHA256.hash(data: verifier.data(using: .utf8)!)).urlSafeBase64()
|
||||
self.challenge = Data(SHA256.hash(data: self.verifier.data(using: .utf8)!)).urlSafeBase64()
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,8 +373,7 @@ enum OAuthTokenExchanger {
|
||||
guard
|
||||
let access = json["access_token"] as? String,
|
||||
let refresh = json["refresh_token"] as? String,
|
||||
let expiresIn = json["expires_in"] as? Double
|
||||
else { return .failure("Invalid token response") }
|
||||
let expiresIn = json["expires_in"] as? Double else { return .failure("Invalid token response") }
|
||||
let expires = Date().addingTimeInterval(expiresIn)
|
||||
return .success(OAuthToken(access: access, refresh: refresh, expires: expires))
|
||||
case let .failure(reason):
|
||||
@ -355,14 +383,18 @@ enum OAuthTokenExchanger {
|
||||
}
|
||||
}
|
||||
|
||||
static func exchangeRefresh(urlRequest: URLRequest, body: [String: Any], timeout: Double) async -> OAuthTokenResult {
|
||||
static func exchangeRefresh(
|
||||
urlRequest: URLRequest,
|
||||
body: [String: Any],
|
||||
timeout: Double,
|
||||
) async
|
||||
-> OAuthTokenResult {
|
||||
switch await HTTP.postJSON(request: urlRequest, body: body, timeoutSeconds: timeout) {
|
||||
case let .success(json):
|
||||
guard
|
||||
let access = json["access_token"] as? String,
|
||||
let refresh = json["refresh_token"] as? String,
|
||||
let expiresIn = json["expires_in"] as? Double
|
||||
else { return .failure("Invalid token response") }
|
||||
let expiresIn = json["expires_in"] as? Double else { return .failure("Invalid token response") }
|
||||
let expires = Date().addingTimeInterval(expiresIn)
|
||||
return .success(OAuthToken(access: access, refresh: refresh, expires: expires))
|
||||
case let .failure(reason):
|
||||
@ -388,7 +420,7 @@ struct TKProviderValidator {
|
||||
url: "https://api.openai.com/v1/models",
|
||||
secret: secret,
|
||||
header: "Authorization",
|
||||
valuePrefix: "Bearer "
|
||||
valuePrefix: "Bearer ",
|
||||
)
|
||||
case .anthropic:
|
||||
var request = URLRequest(url: URL(string: "https://api.anthropic.com/v1/messages")!)
|
||||
@ -400,7 +432,7 @@ struct TKProviderValidator {
|
||||
"model": "claude-3-haiku-20241022",
|
||||
"max_tokens": 1,
|
||||
"messages": [
|
||||
["role": "user", "content": "ping"]
|
||||
["role": "user", "content": "ping"],
|
||||
],
|
||||
])
|
||||
return await HTTP.perform(request: request, timeoutSeconds: self.timeoutSeconds)
|
||||
@ -409,7 +441,7 @@ struct TKProviderValidator {
|
||||
url: "https://api.x.ai/v1/models",
|
||||
secret: secret,
|
||||
header: "Authorization",
|
||||
valuePrefix: "Bearer "
|
||||
valuePrefix: "Bearer ",
|
||||
)
|
||||
case .gemini:
|
||||
let url = "https://generativelanguage.googleapis.com/v1beta/models?key=\(secret)"
|
||||
@ -419,7 +451,13 @@ struct TKProviderValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private func validateBearer(url: String, secret: String, header: String, valuePrefix: String) async -> TKValidationResult {
|
||||
private func validateBearer(
|
||||
url: String,
|
||||
secret: String,
|
||||
header: String,
|
||||
valuePrefix: String,
|
||||
) async
|
||||
-> TKValidationResult {
|
||||
var request = URLRequest(url: URL(string: url)!)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue(valuePrefix + secret, forHTTPHeaderField: header)
|
||||
@ -432,8 +470,9 @@ enum HTTP {
|
||||
static func perform(
|
||||
request: URLRequest,
|
||||
timeoutSeconds: Double,
|
||||
session: URLSession? = nil
|
||||
) async -> TKValidationResult {
|
||||
session: URLSession? = nil,
|
||||
) async
|
||||
-> TKValidationResult {
|
||||
let session = session ?? Self.makeSession(timeoutSeconds: timeoutSeconds)
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
@ -452,8 +491,9 @@ enum HTTP {
|
||||
request: URLRequest,
|
||||
body: [String: Any],
|
||||
timeoutSeconds: Double,
|
||||
session: URLSession? = nil
|
||||
) async -> TKValidationResultJSON {
|
||||
session: URLSession? = nil,
|
||||
) async
|
||||
-> TKValidationResultJSON {
|
||||
let session = session ?? Self.makeSession(timeoutSeconds: timeoutSeconds)
|
||||
var req = request
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
@ -463,8 +503,9 @@ enum HTTP {
|
||||
static func performJSON(
|
||||
request: URLRequest,
|
||||
timeoutSeconds: Double,
|
||||
session: URLSession? = nil
|
||||
) async -> TKValidationResultJSON {
|
||||
session: URLSession? = nil,
|
||||
) async
|
||||
-> TKValidationResultJSON {
|
||||
let session = session ?? Self.makeSession(timeoutSeconds: timeoutSeconds)
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
@ -491,8 +532,8 @@ enum TKValidationResultJSON {
|
||||
case timeout(Double)
|
||||
}
|
||||
|
||||
private extension HTTP {
|
||||
static func makeSession(timeoutSeconds: Double) -> URLSession {
|
||||
extension HTTP {
|
||||
fileprivate static func makeSession(timeoutSeconds: Double) -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = timeoutSeconds
|
||||
config.timeoutIntervalForResource = timeoutSeconds
|
||||
@ -500,16 +541,16 @@ private extension HTTP {
|
||||
}
|
||||
}
|
||||
|
||||
private extension URL {
|
||||
var queryItems: [String: String] {
|
||||
extension URL {
|
||||
fileprivate var queryItems: [String: String] {
|
||||
URLComponents(url: self, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func urlSafeBase64() -> String {
|
||||
extension Data {
|
||||
fileprivate func urlSafeBase64() -> String {
|
||||
self.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
|
||||
@ -15,6 +15,6 @@ public enum TKConfigMessages {
|
||||
" peekaboo config login anthropic",
|
||||
"",
|
||||
"Use 'peekaboo config show --effective' to see detected env/creds,",
|
||||
"and 'peekaboo config edit' to tweak the JSONC file if needed."
|
||||
"and 'peekaboo config edit' to tweak the JSONC file if needed.",
|
||||
]
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ public final class AzureOpenAIProvider: ModelProvider {
|
||||
apiVersion: String?,
|
||||
endpoint: String?,
|
||||
configuration: TachikomaConfiguration,
|
||||
session: URLSession = .shared
|
||||
session: URLSession = .shared,
|
||||
) throws {
|
||||
self.modelId = deploymentId
|
||||
self.configuration = configuration
|
||||
@ -95,7 +95,7 @@ public final class AzureOpenAIProvider: ModelProvider {
|
||||
queryItems: [URLQueryItem(name: "api-version", value: self.apiVersion)],
|
||||
authHeaderName: self.authHeaderName,
|
||||
authHeaderValuePrefix: self.authHeaderValuePrefix,
|
||||
session: self.session
|
||||
session: self.session,
|
||||
)
|
||||
}
|
||||
|
||||
@ -110,7 +110,7 @@ public final class AzureOpenAIProvider: ModelProvider {
|
||||
queryItems: [URLQueryItem(name: "api-version", value: self.apiVersion)],
|
||||
authHeaderName: self.authHeaderName,
|
||||
authHeaderValuePrefix: self.authHeaderValuePrefix,
|
||||
session: self.session
|
||||
session: self.session,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,9 +97,9 @@ public final class OpenAIProvider: ModelProvider {
|
||||
private func authHeader() -> (String, String, String) {
|
||||
switch self.auth {
|
||||
case let .apiKey(key):
|
||||
return ("Authorization", "Bearer ", key)
|
||||
("Authorization", "Bearer ", key)
|
||||
case let .bearer(token, _):
|
||||
return ("Authorization", "Bearer ", token)
|
||||
("Authorization", "Bearer ", token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +81,8 @@ public struct ProviderFactory {
|
||||
resource: resource,
|
||||
apiVersion: apiVersion,
|
||||
endpoint: endpoint,
|
||||
configuration: configuration)
|
||||
configuration: configuration,
|
||||
)
|
||||
|
||||
case let .custom(provider):
|
||||
// If the custom provider is a dynamic selection string (providerId/model),
|
||||
|
||||
@ -18,13 +18,13 @@ struct TKConfigCLI {
|
||||
|
||||
switch cmd {
|
||||
case "add":
|
||||
await handleAdd(Array(args.dropFirst()))
|
||||
await self.handleAdd(Array(args.dropFirst()))
|
||||
case "login":
|
||||
await handleLogin(Array(args.dropFirst()))
|
||||
await self.handleLogin(Array(args.dropFirst()))
|
||||
case "status", "show":
|
||||
await handleStatus(Array(args.dropFirst()))
|
||||
await self.handleStatus(Array(args.dropFirst()))
|
||||
case "init":
|
||||
handleInit()
|
||||
self.handleInit()
|
||||
default:
|
||||
print("Unknown command \(cmd)")
|
||||
self.printUsage()
|
||||
@ -60,7 +60,7 @@ struct TKConfigCLI {
|
||||
}
|
||||
let provider = mutable.removeFirst()
|
||||
let secret = mutable.removeFirst()
|
||||
let timeout = parseTimeout(&mutable)
|
||||
let timeout = self.parseTimeout(&mutable)
|
||||
|
||||
guard let pid = TKProviderId.normalize(provider) else {
|
||||
print("Unsupported provider. Use openai|anthropic|grok|xai|gemini")
|
||||
@ -89,7 +89,7 @@ struct TKConfigCLI {
|
||||
exit(1)
|
||||
}
|
||||
mutable.removeFirst()
|
||||
let timeout = parseTimeout(&mutable)
|
||||
let timeout = self.parseTimeout(&mutable)
|
||||
let noBrowser = mutable.contains("--no-browser")
|
||||
guard let pid = TKProviderId.normalize(provider), pid.supportsOAuth else {
|
||||
print("OAuth supported providers: openai, anthropic")
|
||||
@ -105,13 +105,16 @@ struct TKConfigCLI {
|
||||
}
|
||||
|
||||
private static func handleInit() {
|
||||
let lines = TKConfigMessages.initGuidance.map { $0.replacingOccurrences(of: "{path}", with: "~/.tachikoma/config.json") }
|
||||
let lines = TKConfigMessages.initGuidance.map { $0.replacingOccurrences(
|
||||
of: "{path}",
|
||||
with: "~/.tachikoma/config.json",
|
||||
) }
|
||||
print(lines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
private static func handleStatus(_ raw: [String]) async {
|
||||
var mutable = raw
|
||||
let timeout = parseTimeout(&mutable)
|
||||
let timeout = self.parseTimeout(&mutable)
|
||||
print("Providers:")
|
||||
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini] {
|
||||
let status = await TKConfigCLI.status(for: pid, timeout: timeout)
|
||||
@ -123,13 +126,13 @@ struct TKConfigCLI {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let source = envSource(pid: pid, env: env) {
|
||||
let validation = await TKAuthManager.shared.validate(provider: pid, secret: source.value, timeout: timeout)
|
||||
return describe(source: "env \(source.key)", validation: validation)
|
||||
return self.describe(source: "env \(source.key)", validation: validation)
|
||||
}
|
||||
let credsSource = credentialSource(pid: pid)
|
||||
let credsSource = self.credentialSource(pid: pid)
|
||||
switch credsSource {
|
||||
case let .some(source):
|
||||
let validation = await TKAuthManager.shared.validate(provider: pid, secret: source.value, timeout: timeout)
|
||||
return describe(source: "credentials \(source.key)", validation: validation)
|
||||
return self.describe(source: "credentials \(source.key)", validation: validation)
|
||||
case .none:
|
||||
return "missing"
|
||||
}
|
||||
@ -173,11 +176,11 @@ struct TKConfigCLI {
|
||||
private static func describe(source: String, validation: TKValidationResult) -> String {
|
||||
switch validation {
|
||||
case .success:
|
||||
return "ready (\(source), validated)"
|
||||
"ready (\(source), validated)"
|
||||
case let .failure(reason):
|
||||
return "stored (\(source), validation failed: \(reason))"
|
||||
"stored (\(source), validation failed: \(reason))"
|
||||
case let .timeout(sec):
|
||||
return "stored (\(source), validation timed out after \(Int(sec))s)"
|
||||
"stored (\(source), validation timed out after \(Int(sec))s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ struct OpenAIAudioProviderTests {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-api-key"]) { config in
|
||||
let provider = try TranscriptionProviderFactory.createProvider(
|
||||
for: .openai(.whisper1),
|
||||
configuration: config
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
#expect(provider.modelId == "whisper-1")
|
||||
@ -40,7 +40,7 @@ struct OpenAIAudioProviderTests {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
|
||||
let whisperProvider = try TranscriptionProviderFactory.createProvider(
|
||||
for: .openai(.whisper1),
|
||||
configuration: config
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
// Test model ID
|
||||
@ -57,7 +57,7 @@ struct OpenAIAudioProviderTests {
|
||||
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
|
||||
let provider = try TranscriptionProviderFactory.createProvider(
|
||||
for: .openai(.whisper1),
|
||||
configuration: config
|
||||
configuration: config,
|
||||
)
|
||||
|
||||
let supportedFormats = provider.capabilities.supportedFormats
|
||||
|
||||
@ -77,17 +77,19 @@ private final class AuthMockURLProtocol: URLProtocol {
|
||||
url: self.request.url!,
|
||||
statusCode: Self.statusCode,
|
||||
httpVersion: nil,
|
||||
headerFields: nil)!
|
||||
headerFields: nil,
|
||||
)!
|
||||
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
self.client?.urlProtocol(self, didLoad: Data())
|
||||
self.client?.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
private extension URLSession {
|
||||
extension URLSession {
|
||||
@MainActor
|
||||
static func mock(status: Int) -> URLSession {
|
||||
fileprivate static func mock(status: Int) -> URLSession {
|
||||
AuthMockURLProtocol.statusCode = status
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.protocolClasses = [AuthMockURLProtocol.self]
|
||||
|
||||
@ -85,7 +85,7 @@ struct AzureOpenAIProviderTests {
|
||||
apiVersion: "2025-04-01-preview",
|
||||
endpoint: nil,
|
||||
configuration: config,
|
||||
session: self.makeSession()
|
||||
session: self.makeSession(),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(messages: [ModelMessage(role: .user, content: [.text("hi")])])
|
||||
@ -122,7 +122,7 @@ struct AzureOpenAIProviderTests {
|
||||
apiVersion: "2025-04-01-preview",
|
||||
endpoint: nil,
|
||||
configuration: TachikomaConfiguration(loadFromEnvironment: true),
|
||||
session: self.makeSession()
|
||||
session: self.makeSession(),
|
||||
)
|
||||
|
||||
let request = ProviderRequest(messages: [ModelMessage(role: .user, content: [.text("hi")])])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user