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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//
// Models.swift
// AT-IRC
//
import Foundation
/// A message as returned by chat.atirc.post / chat.atirc.list / chat.atirc.subscribe.
struct AtircMessage: Identifiable, Codable, Hashable {
var seq: Int64?
var channel: String?
var did: String?
var handle: String?
var nick: String?
var text: String
var ts: String?
var source: String?
var identity: String?
var id: String {
if let seq { return "seq-\(seq)" }
return "\(did ?? "")-\(ts ?? "")-\(text.hashValue)"
}
var displayNick: String {
nick ?? identity ?? handle ?? "unknown"
}
/// Best-effort parse of the server timestamp.
var date: Date? {
guard let ts else { return nil }
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: ts) { return date }
formatter.formatOptions = [.withInternetDateTime]
return formatter.date(from: ts)
}
var isMine: Bool { source == "xrpc" }
}
struct AtircListResponse: Codable {
var messages: [AtircMessage]
var cursor: Int64
}
/// A member currently connected to a channel, from `#presence` frames.
///
/// Presence identities are `nick@host` strings. The whole string is unique
/// (and is the row id); only the nick half gets a nickname color.
struct AtircMember: Identifiable, Hashable {
let id: String
var nick: String
/// Secondary identity shown beside the nick: the host half of `nick@host`.
var identity: String?
/// Split a presence identity, accepting a bare nick defensively.
static func from(presenceIdentity raw: String) -> AtircMember? {
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
guard let at = value.firstIndex(of: "@") else {
return AtircMember(id: value, nick: value, identity: nil)
}
let nick = String(value[..<at])
let host = String(value[value.index(after: at)...])
return AtircMember(
id: value,
nick: nick.isEmpty ? value : nick,
identity: host.isEmpty ? nil : host
)
}
}
/// A join/leave presence event rendered inline in the transcript.
struct ChatNotice: Identifiable, Hashable {
enum Kind: Hashable {
case join
case leave
}
let id = UUID()
let channel: String
let nick: String
let identity: String?
let kind: Kind
let date: Date
init(channel: String, nick: String, identity: String?, kind: Kind, date: Date = Date()) {
self.channel = channel
self.nick = nick
self.identity = identity
self.kind = kind
self.date = date
}
}
/// One row of the chat transcript: a message or an inline presence notice.
enum ChatRow: Identifiable {
case message(AtircMessage)
case notice(ChatNotice)
var id: String {
switch self {
case .message(let message):
return message.id
case .notice(let notice):
return "notice-\(notice.id.uuidString)"
}
}
var date: Date? {
switch self {
case .message(let message):
return message.date
case .notice(let notice):
return notice.date
}
}
var seq: Int64? {
guard case .message(let message) = self else { return nil }
return message.seq
}
}
/// One owner question from chat.atirc.ask / chat.atirc.questions / chat.atirc.answer.
///
/// `status` is `pending`, `answered` (owner answered), or `decided` (an agent
/// decided autonomously while the owner was away; `decision`/`adr` then carry
/// the record the owner can review).
struct AskQuestion: Identifiable, Codable, Hashable {
var id: String
var account: String? = nil
/// Display-only handle; `account` is the account DID.
var accountHandle: String? = nil
var askedBy: String? = nil
var askedHandle: String? = nil
var question: String
var options: [String]? = nil
var context: String? = nil
var channel: String? = nil
/// Unix seconds.
var createdAt: Int64? = nil
var status: String
var answer: String? = nil
/// 0-based index into `options` when the owner picked one.
var choice: Int? = nil
var answeredBy: String? = nil
var answeredAt: Int64? = nil
var decision: String? = nil
var decidedBy: String? = nil
var adr: String? = nil
var isPending: Bool { status == "pending" }
var asker: String { askedHandle ?? askedBy ?? "agent" }
var date: Date? {
guard let createdAt else { return nil }
return Date(timeIntervalSince1970: TimeInterval(createdAt))
}
}
struct AskQuestionsResponse: Codable {
var mode: String
var questions: [AskQuestion]
}
struct OwnerModeResponse: Codable {
var mode: String
}
/// Response from chat.atirc.mint plus the atirc endpoint it was minted for.
struct MintResult: Codable, Hashable {
var token: String
var endpoint: String?
var did: String
var handle: String
var subhandle: String
var nick: String
var scope: [String]
enum CodingKeys: String, CodingKey {
case token, endpoint, did, handle, subhandle, nick, scope
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
token = try container.decode(String.self, forKey: .token)
endpoint = try container.decodeIfPresent(String.self, forKey: .endpoint)
did = try container.decode(String.self, forKey: .did)
handle = try container.decode(String.self, forKey: .handle)
subhandle = try container.decode(String.self, forKey: .subhandle)
nick = try container.decode(String.self, forKey: .nick)
scope = try container.decodeIfPresent([String].self, forKey: .scope) ?? []
}
}
/// OAuth session for the user's PDS (DPoP-bound public client).
struct OAuthSession: Codable {
var accessToken: String
var refreshToken: String?
var expiresAt: Date
var tokenType: String
var did: String
var handle: String?
var pdsURL: String
var authServer: String
var dpopKey: String // base64 of the P-256 private key raw representation
var scope: String?
var isExpired: Bool {
expiresAt.timeIntervalSinceNow < 30
}
var dpopKeyValue: DPoPKey? {
guard let data = Data(base64Encoded: dpopKey) else { return nil }
return try? DPoPKey(rawRepresentation: data)
}
}
/// Persisted app account: OAuth session + minted atirc token.
struct AtircAccount: Codable {
var oauth: OAuthSession
var mint: MintResult
var atircBaseURL: String = AtircService.defaultBaseURL
var channel: String
}
enum AtircService {
static let baseURL = URL(string: "https://irc.fedfork.com")!
static let defaultBaseURL = "https://irc.fedfork.com"
static let did = "did:web:irc.fedfork.com"
/// DID reference with service fragment, used as the service-auth audience
/// and in the granular OAuth permission scope.
static let serviceRef = "did:web:irc.fedfork.com#atirc"
static let xrpcPrefix = "/xrpc/"
}