1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import Foundation
import os
private let logger = Logger(subsystem: "social.grain.grain", category: "ProfileSeed")
/// A grain profile made for the account once it signs in, from its Bluesky
/// profile when it has one, as grain-serve makes one for the web
/// (`login/profile.rs`). The app signs in at the account's own PDS, so
/// grain-serve never sees the sign-in: the app does it, in the background,
/// so a sign-in never waits on it and never fails by it.
///
/// An account whose `social.grain.actor.profile` has a `displayName` is
/// left as it is. Otherwise its `app.bsky.actor.profile` gives the grain
/// profile its `displayName`, `description` and `avatar` (the blob as the
/// Bluesky profile names it), with the `createdAt` of the grain profile
/// there is, or now. One there is is put over, swapped on its CID; else one
/// is made at `self`. An account with no Bluesky profile and no grain
/// profile gets a bare one, its `createdAt` alone, so every account that
/// signs in has the profile that makes grain-serve track it; one with an
/// unnamed grain profile and no Bluesky profile is left as it is. Both are
/// read from the account's PDS, and the write is then indexed.
enum ProfileSeed {
static let grain = "social.grain.actor.profile"
static let bluesky = "app.bsky.actor.profile"
/// What `seed` did.
enum Outcome: Equatable {
/// The grain profile has a name already.
case named
/// There is no Bluesky profile to name it from, and the grain
/// profile there is was left as it is.
case noBluesky
/// A grain profile was made, from the Bluesky one or bare.
case made
/// The grain profile there was was put over.
case put
}
/// Seed the profile, logging only how it failed.
static func run(client: XRPCClient, auth: AuthContext) async {
do {
let outcome = try await seed(client: client, auth: auth)
logger.info("profile seed: \(String(describing: outcome), privacy: .public)")
} catch {
logger.info("seeding a grain profile failed: \(error)")
}
}
static func seed(client: XRPCClient, auth: AuthContext) async throws -> Outcome {
let existing = try await read(grain, client: client, auth: auth)
if let name = existing?.value?.dictValue?["displayName"]?.stringValue, !name.isEmpty {
return .named
}
let bsky = try await read(bluesky, client: client, auth: auth)?.value?.dictValue
if bsky == nil, existing != nil {
return .noBluesky
}
let record = AnyCodable(fromBluesky(bsky ?? [:], grain: existing?.value?.dictValue))
if let existing {
_ = try await client.putRecord(collection: grain, rkey: "self", record: record, swapRecord: existing.cid, auth: auth)
return .put
}
_ = try await client.createRecord(collection: grain, repo: auth.did, rkey: "self", record: record, auth: auth)
return .made
}
/// The grain profile `bsky`, a Bluesky profile, makes: its name,
/// description and avatar where it has them, made when `grain`, the
/// grain profile there is, says it was, or now.
static func fromBluesky(_ bsky: [String: AnyCodable], grain: [String: AnyCodable]?, now: String = DateFormatting.nowISO()) -> [String: AnyCodable] {
let created = grain?["createdAt"]?.stringValue ?? now
var record: [String: AnyCodable] = ["$type": AnyCodable(Self.grain), "createdAt": AnyCodable(created)]
for key in ["displayName", "description"] {
if let text = bsky[key]?.stringValue, !text.isEmpty {
record[key] = AnyCodable(text)
}
}
if let avatar = bsky["avatar"], avatar.dictValue != nil {
record["avatar"] = avatar
}
return record
}
/// The account's `collection/self`, or nil if its PDS has none.
private static func read(_ collection: String, client: XRPCClient, auth: AuthContext) async throws -> GetRecordResponse? {
do {
return try await client.getRecord(uri: "at://\(auth.did)/\(collection)/self", auth: auth)
} catch let XRPCError.httpError(_, body) where isNotFound(body) {
return nil
}
}
/// Whether an answer says there is no such record: atproto's
/// `RecordNotFound`, which a PDS answers with a 400.
static func isNotFound(_ body: Data?) -> Bool {
struct Refusal: Decodable { let error: String? }
return body.flatMap { try? JSONDecoder().decode(Refusal.self, from: $0) }?.error == "RecordNotFound"
}
}