@openmarket/rooms-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +25 -0
  3. package/dist/agent/armed-mode.d.ts +187 -0
  4. package/dist/agent/armed-mode.js +306 -0
  5. package/dist/agent/clients/common.d.ts +70 -0
  6. package/dist/agent/clients/common.js +46 -0
  7. package/dist/agent/lane-draft.d.ts +15 -0
  8. package/dist/agent/lane-draft.js +43 -0
  9. package/dist/agent/lane-prompts.d.ts +102 -0
  10. package/dist/agent/lane-prompts.js +120 -0
  11. package/dist/agent/library-context.d.ts +38 -0
  12. package/dist/agent/library-context.js +57 -0
  13. package/dist/agent/library-model.d.ts +41 -0
  14. package/dist/agent/library-model.js +173 -0
  15. package/dist/agent/room-context.d.ts +151 -0
  16. package/dist/agent/room-context.js +251 -0
  17. package/dist/agent/sidebar-model.d.ts +86 -0
  18. package/dist/agent/sidebar-model.js +162 -0
  19. package/dist/agent/topic-inbox.d.ts +119 -0
  20. package/dist/agent/topic-inbox.js +266 -0
  21. package/dist/agent/topic-view-batcher.d.ts +54 -0
  22. package/dist/agent/topic-view-batcher.js +115 -0
  23. package/dist/client.d.ts +421 -0
  24. package/dist/client.js +1428 -0
  25. package/dist/doc-reconcile.d.ts +100 -0
  26. package/dist/doc-reconcile.js +110 -0
  27. package/dist/doc-uri.d.ts +29 -0
  28. package/dist/doc-uri.js +55 -0
  29. package/dist/endpoints.d.ts +9 -0
  30. package/dist/endpoints.js +13 -0
  31. package/dist/jwt.d.ts +10 -0
  32. package/dist/jwt.js +51 -0
  33. package/dist/library-publisher.d.ts +52 -0
  34. package/dist/library-publisher.js +49 -0
  35. package/dist/merge3.d.ts +50 -0
  36. package/dist/merge3.js +280 -0
  37. package/dist/names.d.ts +6 -0
  38. package/dist/names.js +35 -0
  39. package/dist/shared/emoji-data.d.ts +22 -0
  40. package/dist/shared/emoji-data.js +8834 -0
  41. package/dist/shared/mentions.d.ts +6 -0
  42. package/dist/shared/mentions.js +32 -0
  43. package/dist/shared/rooms-protocol.d.ts +1183 -0
  44. package/dist/shared/rooms-protocol.js +160 -0
  45. package/dist/social-client.d.ts +361 -0
  46. package/dist/social-client.js +686 -0
  47. package/dist/social-types.d.ts +338 -0
  48. package/dist/social-types.js +1 -0
  49. package/dist/suggestion-attribution.d.ts +10 -0
  50. package/dist/suggestion-attribution.js +56 -0
  51. package/dist/topic-uri.d.ts +26 -0
  52. package/dist/topic-uri.js +40 -0
  53. package/dist/types.d.ts +2 -0
  54. package/dist/types.js +1 -0
  55. package/dist/version.d.ts +2 -0
  56. package/dist/version.js +2 -0
  57. package/dist/ws-client.d.ts +115 -0
  58. package/dist/ws-client.js +491 -0
  59. package/package.json +180 -0
  60. package/src/agent/armed-mode.ts +368 -0
  61. package/src/agent/clients/common.ts +91 -0
  62. package/src/agent/lane-draft.ts +47 -0
  63. package/src/agent/lane-prompts.ts +267 -0
  64. package/src/agent/library-context.ts +97 -0
  65. package/src/agent/library-model.ts +210 -0
  66. package/src/agent/room-context.ts +351 -0
  67. package/src/agent/sidebar-model.ts +235 -0
  68. package/src/agent/topic-inbox.ts +297 -0
  69. package/src/agent/topic-view-batcher.ts +134 -0
  70. package/src/client.ts +2331 -0
  71. package/src/doc-reconcile.ts +160 -0
  72. package/src/doc-uri.ts +83 -0
  73. package/src/endpoints.ts +14 -0
  74. package/src/jwt.ts +59 -0
  75. package/src/library-publisher.ts +93 -0
  76. package/src/merge3.ts +326 -0
  77. package/src/names.ts +44 -0
  78. package/src/shared/emoji-data.ts +8868 -0
  79. package/src/shared/mentions.ts +32 -0
  80. package/src/shared/rooms-protocol.ts +1339 -0
  81. package/src/social-client.ts +1287 -0
  82. package/src/social-types.ts +376 -0
  83. package/src/suggestion-attribution.ts +83 -0
  84. package/src/topic-uri.ts +64 -0
  85. package/src/types.ts +83 -0
  86. package/src/version.ts +2 -0
  87. package/src/ws-client.ts +611 -0
@@ -0,0 +1,686 @@
1
+ import { resolveRoomsWsUrl } from "./client.js";
2
+ import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
3
+ export const ROOM_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
4
+ export class RoomSocialError extends Error {
5
+ name = "RoomSocialError";
6
+ status;
7
+ details;
8
+ constructor(message, status, details) {
9
+ super(message);
10
+ this.status = status;
11
+ if (details !== undefined)
12
+ this.details = details;
13
+ }
14
+ }
15
+ export function resolveRoomChatApiUrl() {
16
+ const explicit = envString("OM_ROOM_CHAT_API_URL")?.trim() || envString("OM_CHAT_API_URL")?.trim();
17
+ if (explicit)
18
+ return trimTrailingSlash(explicit);
19
+ return deriveRoomChatApiUrlFromRoomsUrl(resolveRoomsWsUrl()) ?? ROOM_CHAT_API_URL;
20
+ }
21
+ export function deriveRoomChatApiUrlFromRoomsUrl(wsUrl) {
22
+ try {
23
+ const url = new URL(wsUrl);
24
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
25
+ url.pathname = "/chat";
26
+ url.search = "";
27
+ url.hash = "";
28
+ return trimTrailingSlash(url.toString());
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ export async function listRoomFriends(config) {
35
+ const result = await socialRequest(config, "/friends");
36
+ return result.friends ?? [];
37
+ }
38
+ export async function sendRoomFriendRequest(config, username) {
39
+ return socialRequest(config, "/friends/request", {
40
+ method: "POST",
41
+ body: { addresseeUsername: cleanUsername(username) },
42
+ });
43
+ }
44
+ export async function listRoomFriendRequests(config) {
45
+ const result = await socialRequest(config, "/friends/requests");
46
+ return { sent: result.sent ?? [], received: result.received ?? [] };
47
+ }
48
+ export async function acceptRoomFriendRequest(config, friendshipId) {
49
+ return socialRequest(config, `/friends/accept/${encodeURIComponent(friendshipId)}`, {
50
+ method: "POST",
51
+ });
52
+ }
53
+ export async function rejectRoomFriendRequest(config, friendshipId) {
54
+ return socialRequest(config, `/friends/reject/${encodeURIComponent(friendshipId)}`, {
55
+ method: "POST",
56
+ });
57
+ }
58
+ export async function removeRoomFriend(config, friendshipId) {
59
+ return socialRequest(config, `/friends/${encodeURIComponent(friendshipId)}`, {
60
+ method: "DELETE",
61
+ });
62
+ }
63
+ export async function listRoomFollowing(config) {
64
+ const result = await socialRequest(config, "/follows/following");
65
+ return result.following ?? [];
66
+ }
67
+ export async function followRoomUser(config, username) {
68
+ return socialRequest(config, `/follows/${encodeURIComponent(cleanUsername(username))}`, {
69
+ method: "POST",
70
+ });
71
+ }
72
+ export async function unfollowRoomUser(config, username) {
73
+ return socialRequest(config, `/follows/${encodeURIComponent(cleanUsername(username))}`, {
74
+ method: "DELETE",
75
+ });
76
+ }
77
+ export async function listRoomDmInbox(config, options = {}) {
78
+ const params = new URLSearchParams();
79
+ if (options.unreadOnly !== undefined)
80
+ params.set("unreadOnly", String(options.unreadOnly));
81
+ if (options.limit !== undefined)
82
+ params.set("limit", String(options.limit));
83
+ const result = await socialRequest(config, `/dms${params.size ? `?${params.toString()}` : ""}`);
84
+ return result.conversations ?? [];
85
+ }
86
+ export async function listUnreadRoomDms(config, options = {}) {
87
+ const params = new URLSearchParams();
88
+ if (options.username)
89
+ params.set("username", cleanUsername(options.username));
90
+ if (options.limitPerConversation !== undefined) {
91
+ params.set("limitPerConversation", String(options.limitPerConversation));
92
+ }
93
+ if (options.markRead !== undefined)
94
+ params.set("markRead", String(options.markRead));
95
+ const init = {};
96
+ if (options.signal)
97
+ init.signal = options.signal;
98
+ const result = await socialRequest(config, `/dms/unread${params.size ? `?${params.toString()}` : ""}`, init);
99
+ return result.conversations ?? [];
100
+ }
101
+ export async function getRoomDmHistory(config, username, options = {}) {
102
+ const params = new URLSearchParams();
103
+ if (options.limit !== undefined)
104
+ params.set("limit", String(options.limit));
105
+ if (options.before !== undefined)
106
+ params.set("before", String(options.before));
107
+ if (options.markRead !== undefined)
108
+ params.set("markRead", String(options.markRead));
109
+ return socialRequest(config, `/dms/${encodeURIComponent(cleanUsername(username))}/history${params.size ? `?${params.toString()}` : ""}`);
110
+ }
111
+ export async function sendRoomDm(config, username, content) {
112
+ return socialRequest(config, `/dms/${encodeURIComponent(cleanUsername(username))}/messages`, {
113
+ method: "POST",
114
+ body: { content },
115
+ });
116
+ }
117
+ /** Send a sealed DM: the opaque envelope rides in place of content (the door
118
+ * accepts exactly one of the two). Callers seal first; nothing here can. */
119
+ export async function sendRoomDmSecret(config, username, secret) {
120
+ return socialRequest(config, `/dms/${encodeURIComponent(cleanUsername(username))}/messages`, {
121
+ method: "POST",
122
+ body: { secret },
123
+ });
124
+ }
125
+ /**
126
+ * CAS write conflict: the doc's head moved past the caller's baseRev. Carries
127
+ * the current head (fetched on demand when the transport dropped it) so the
128
+ * caller can rebase — typically an agent merge — and retry.
129
+ */
130
+ export class RoomDocConflictError extends RoomSocialError {
131
+ head;
132
+ constructor(message, head) {
133
+ super(message, 409);
134
+ this.head = head;
135
+ }
136
+ }
137
+ function isDocConflict(error) {
138
+ if (!(error instanceof RoomSocialError) || error.status !== 409)
139
+ return false;
140
+ const details = error.details;
141
+ return details?.tag === "DOC_CONFLICT";
142
+ }
143
+ function conflictHeadFromDetails(details) {
144
+ const head = details?.head;
145
+ return head && typeof head.rev === "number" ? head : null;
146
+ }
147
+ /** Wrap a doc write so CAS conflicts surface as RoomDocConflictError with the
148
+ * head attached (re-fetched when the relay passthrough stripped it). */
149
+ async function withDocConflict(config, docId, call) {
150
+ try {
151
+ return await call();
152
+ }
153
+ catch (error) {
154
+ if (!isDocConflict(error))
155
+ throw error;
156
+ let head = conflictHeadFromDetails(error.details);
157
+ if (!head) {
158
+ const doc = await getRoomDoc(config, docId).catch(() => null);
159
+ if (doc && doc.content !== undefined) {
160
+ head = {
161
+ rev: doc.headRev,
162
+ sha: doc.headSha,
163
+ content: doc.content,
164
+ note: doc.headNote,
165
+ authorHandle: doc.headAuthorHandle,
166
+ isAgent: doc.headIsAgent,
167
+ at: doc.headAt,
168
+ };
169
+ }
170
+ }
171
+ throw new RoomDocConflictError(error.message, head);
172
+ }
173
+ }
174
+ export async function listRoomDocs(config, options) {
175
+ const params = new URLSearchParams({ spaceId: options.spaceId });
176
+ if (options.prefix)
177
+ params.set("prefix", options.prefix);
178
+ if (options.includeArchived)
179
+ params.set("includeArchived", "true");
180
+ if (options.limit !== undefined)
181
+ params.set("limit", String(options.limit));
182
+ const result = await socialRequest(config, `/docs?${params.toString()}`);
183
+ return result.docs ?? [];
184
+ }
185
+ export async function getRoomDocChanges(config, options) {
186
+ const params = new URLSearchParams({ spaceId: options.spaceId });
187
+ if (options.since !== undefined)
188
+ params.set("since", String(options.since));
189
+ if (options.limit !== undefined)
190
+ params.set("limit", String(options.limit));
191
+ const init = {};
192
+ if (options.signal)
193
+ init.signal = options.signal;
194
+ const result = await socialRequest(config, `/docs/changes?${params.toString()}`, init);
195
+ return {
196
+ changes: result.changes ?? [],
197
+ cursor: result.cursor ?? options.since ?? 0,
198
+ latestSeq: result.latestSeq ?? result.cursor ?? 0,
199
+ };
200
+ }
201
+ export async function searchRoomDocs(config, options) {
202
+ const params = new URLSearchParams({ spaceId: options.spaceId, q: options.q });
203
+ if (options.limit !== undefined)
204
+ params.set("limit", String(options.limit));
205
+ const result = await socialRequest(config, `/docs/search?${params.toString()}`);
206
+ return result.docs ?? [];
207
+ }
208
+ export async function resolveRoomDoc(config, spaceId, path) {
209
+ const params = new URLSearchParams({ spaceId, path });
210
+ const result = await socialRequest(config, `/docs/resolve?${params.toString()}`);
211
+ return result.doc;
212
+ }
213
+ export async function getRoomDoc(config, docId) {
214
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}`);
215
+ return result.doc;
216
+ }
217
+ export async function createRoomDoc(config, input) {
218
+ const result = await socialRequest(config, "/docs", {
219
+ method: "POST",
220
+ body: input,
221
+ });
222
+ return result.doc;
223
+ }
224
+ export async function updateRoomDoc(config, docId, input) {
225
+ return withDocConflict(config, docId, () => socialRequest(config, `/docs/${encodeURIComponent(docId)}`, {
226
+ method: "PATCH",
227
+ body: input,
228
+ }));
229
+ }
230
+ export async function updateRoomDocMeta(config, docId, input) {
231
+ const result = await withDocConflict(config, docId, () => socialRequest(config, `/docs/${encodeURIComponent(docId)}/meta`, {
232
+ method: "PATCH",
233
+ body: input,
234
+ }));
235
+ return result.doc;
236
+ }
237
+ export async function listRoomDocRevisions(config, docId, options = {}) {
238
+ const params = new URLSearchParams();
239
+ if (options.beforeRev !== undefined)
240
+ params.set("beforeRev", String(options.beforeRev));
241
+ if (options.limit !== undefined)
242
+ params.set("limit", String(options.limit));
243
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/revisions${params.size ? `?${params.toString()}` : ""}`);
244
+ return result.revisions ?? [];
245
+ }
246
+ export async function getRoomDocRevision(config, docId, rev) {
247
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/revisions/${rev}`);
248
+ return result.revision;
249
+ }
250
+ export async function revertRoomDoc(config, docId, input) {
251
+ return withDocConflict(config, docId, () => socialRequest(config, `/docs/${encodeURIComponent(docId)}/revert`, {
252
+ method: "POST",
253
+ body: input,
254
+ }));
255
+ }
256
+ /**
257
+ * Selective run revert: undo every revision one agent run produced, as ONE
258
+ * new attributed revision computed server-side (inverse three-way merge
259
+ * against the current head; later edits survive). A diverging merge comes
260
+ * back as {status:"conflict"} OUTCOME data carrying the three contents and
261
+ * regions for the reconcile surface; nothing was written. A mid-commit CAS
262
+ * race still throws RoomDocConflictError like every other doc write.
263
+ */
264
+ export async function revertRoomDocRun(config, docId, input) {
265
+ return withDocConflict(config, docId, () => socialRequest(config, `/docs/${encodeURIComponent(docId)}/revert-run`, {
266
+ method: "POST",
267
+ body: input,
268
+ }));
269
+ }
270
+ export async function createSpaceRestorePlan(config, input) {
271
+ const result = await socialRequest(config, "/docs/restore-plans", { method: "POST", body: input });
272
+ return result.plan;
273
+ }
274
+ export async function getSpaceRestorePlan(config, planId) {
275
+ const result = await socialRequest(config, `/docs/restore-plans/${encodeURIComponent(planId)}`);
276
+ return result.plan;
277
+ }
278
+ export async function applySpaceRestorePlan(config, planId, confirmSpaceId) {
279
+ const result = await socialRequest(config, `/docs/restore-plans/${encodeURIComponent(planId)}/apply`, {
280
+ method: "POST",
281
+ body: { confirmSpaceId },
282
+ });
283
+ if (result.receipt)
284
+ return result.receipt;
285
+ if (!result.plan) {
286
+ throw new RoomSocialError("Restore start returned no plan or receipt", 502, result);
287
+ }
288
+ let plan = result.plan;
289
+ const deadline = Date.now() + 6 * 60_000;
290
+ while (Date.now() < deadline) {
291
+ const receipt = spaceRestoreReceiptFromPlan(plan);
292
+ if (receipt)
293
+ return receipt;
294
+ if (plan.status === "failed" || plan.status === "stale") {
295
+ throw new RoomSocialError(plan.error ??
296
+ `Restore is ${plan.status} after ${plan.appliedCount}/${plan.summary.totalActions} actions`, 409, { plan });
297
+ }
298
+ if (plan.status !== "applying") {
299
+ throw new RoomSocialError(`Restore start returned plan status ${plan.status}`, 409, {
300
+ plan,
301
+ });
302
+ }
303
+ await new Promise((resolve) => setTimeout(resolve, 500));
304
+ plan = await getSpaceRestorePlan(config, planId);
305
+ }
306
+ throw new RoomSocialError("Restore is still applying; recheck the durable plan", 202, { plan });
307
+ }
308
+ /** Recover the durable receipt shape from an authoritative applied plan.
309
+ * Useful when the apply response was lost after the server committed. */
310
+ export function spaceRestoreReceiptFromPlan(plan) {
311
+ if (plan.status !== "applied" || plan.appliedAt === null || plan.resultingCursor === null) {
312
+ return null;
313
+ }
314
+ return {
315
+ planId: plan.planId,
316
+ spaceId: plan.spaceId,
317
+ restoreAt: plan.restoreAt,
318
+ reason: plan.reason,
319
+ status: "applied",
320
+ appliedActions: plan.appliedCount,
321
+ resultingCursor: plan.resultingCursor,
322
+ appliedAt: plan.appliedAt,
323
+ summary: plan.summary,
324
+ };
325
+ }
326
+ export async function archiveRoomDoc(config, docId, input = {}) {
327
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/archive`, { method: "POST", body: input });
328
+ return result.doc;
329
+ }
330
+ /** Renew (or create) the caller's "editing" lease on a doc: I hold a dirty
331
+ * working copy. Heartbeat well inside the server TTL; content never rides. */
332
+ export async function heartbeatDocEditing(config, docId, options = {}) {
333
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/editing`, { method: "PUT", body: options });
334
+ return result.lease ?? null;
335
+ }
336
+ /** Drop the caller's own editing lease (publish or discard). */
337
+ export async function clearDocEditing(config, docId) {
338
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/editing`, { method: "DELETE" });
339
+ return { cleared: result.cleared === true };
340
+ }
341
+ /** Active editing leases across a space (rides the existing library poll). */
342
+ export async function listDocEditing(config, spaceId) {
343
+ const params = new URLSearchParams({ spaceId });
344
+ const result = await socialRequest(config, `/docs/editing?${params.toString()}`);
345
+ return result.editing ?? [];
346
+ }
347
+ export async function listRoomSpacesViaChat(config) {
348
+ const result = await socialRequest(config, "/spaces");
349
+ return result.spaces ?? [];
350
+ }
351
+ export async function getUserPrefs(config) {
352
+ const result = await socialRequest(config, "/users/prefs");
353
+ return result.prefs ?? {};
354
+ }
355
+ /** Whole-blob replace (16KB cap server-side; last writer wins). */
356
+ export async function setUserPrefs(config, prefs) {
357
+ await socialRequest(config, "/users/prefs", { method: "PUT", body: { prefs } });
358
+ }
359
+ export async function listSpaceRoles(config, spaceId) {
360
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles`);
361
+ return result.roles ?? [];
362
+ }
363
+ export async function createSpaceRole(config, spaceId, input) {
364
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles`, { method: "POST", body: input });
365
+ if (!result.role)
366
+ throw new Error("Role create returned no role");
367
+ return result.role;
368
+ }
369
+ export async function updateSpaceRole(config, spaceId, roleId, patch) {
370
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles/${encodeURIComponent(roleId)}`, { method: "PATCH", body: patch });
371
+ if (!result.role)
372
+ throw new Error("Role update returned no role");
373
+ return result.role;
374
+ }
375
+ export async function deleteSpaceRole(config, spaceId, roleId) {
376
+ await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles/${encodeURIComponent(roleId)}`, { method: "DELETE" });
377
+ }
378
+ export async function setSpaceMemberRoles(config, spaceId, targetUserId, roleIds) {
379
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/roles`, { method: "PUT", body: { roleIds } });
380
+ }
381
+ export async function listSpaceMembers(config, spaceId) {
382
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members`);
383
+ return result.members ?? [];
384
+ }
385
+ export async function addSpaceMember(config, spaceId, input) {
386
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members`, { method: "POST", body: input });
387
+ return result.member;
388
+ }
389
+ /** Create (or refresh) a pending invitation. Membership needs their accept. */
390
+ export async function createSpaceInvite(config, spaceId, input) {
391
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/invites`, { method: "POST", body: input });
392
+ return result.invite;
393
+ }
394
+ /** The caller's own pending invitations. */
395
+ export async function listMySpaceInvites(config) {
396
+ const result = await socialRequest(config, "/spaces/invites");
397
+ return result.invites ?? [];
398
+ }
399
+ /** Pending invitations for a space (managers only). */
400
+ export async function listSpaceInvites(config, spaceId) {
401
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/invites`);
402
+ return result.invites ?? [];
403
+ }
404
+ export async function acceptSpaceInvite(config, inviteId) {
405
+ const result = await socialRequest(config, `/spaces/invites/${encodeURIComponent(inviteId)}/accept`, { method: "POST" });
406
+ return result.space;
407
+ }
408
+ export async function declineSpaceInvite(config, inviteId) {
409
+ return socialRequest(config, `/spaces/invites/${encodeURIComponent(inviteId)}/decline`, {
410
+ method: "POST",
411
+ });
412
+ }
413
+ /** Mint (or rotate) the shareable join code; plaintext comes back once. */
414
+ export async function setSpaceJoinCode(config, spaceId) {
415
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
416
+ method: "PUT",
417
+ });
418
+ }
419
+ export async function clearSpaceJoinCode(config, spaceId) {
420
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
421
+ method: "DELETE",
422
+ });
423
+ }
424
+ /** Redeem a shareable `<spaceId>:<code>` invite. */
425
+ export async function joinSpaceByCode(config, spaceId, code) {
426
+ const result = await socialRequest(config, "/spaces/join", { method: "POST", body: { spaceId, code } });
427
+ return result.space;
428
+ }
429
+ /**
430
+ * Bulk identity read (avatar/status/custom text) for tapes and rosters: ONE
431
+ * request, cap 100 ids. Identity lives on the platform user record
432
+ * (user_settings.profileUrl / presenceStatus / statusText) — the wire names
433
+ * map onto the chat-facing shape here. Status WRITES ride the ws STATUS
434
+ * frame (the relay persists them), avatar writes go through uploadAvatar.
435
+ */
436
+ export async function getBulkProfiles(config, userIds) {
437
+ if (userIds.length === 0)
438
+ return [];
439
+ const result = await socialRequest(config, "/rooms/profiles/bulk", {
440
+ method: "POST",
441
+ body: { userIds },
442
+ });
443
+ return userIds.map((userId) => {
444
+ const wire = result.profiles?.[userId];
445
+ const status = wire?.presenceStatus;
446
+ return {
447
+ userId,
448
+ status: status === "online" || status === "idle" || status === "dnd" ? status : null,
449
+ statusText: wire?.statusText ?? null,
450
+ avatarUrl: wire?.profileUrl ?? null,
451
+ };
452
+ });
453
+ }
454
+ /** Rename / re-describe / re-order a channel (owner/admin). */
455
+ export async function updateRoomChannel(config, room, input) {
456
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}`, { method: "PATCH", body: input });
457
+ return result.room;
458
+ }
459
+ /** Authoritative single-room meta: the store's resolve route through the
460
+ * relay BFF (existence-hiding 404 preserved). The task engine's policy
461
+ * read rides this, never the ranked room search, because a fresh
462
+ * zero-heat room can be crowded out of a search page and an enforcement
463
+ * read must be deterministic. */
464
+ export async function resolveRoomChannel(config, room) {
465
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/resolve`, { method: "GET" });
466
+ return result.room;
467
+ }
468
+ /** Archive a channel (owner/admin): drops from listings, refuses posts. */
469
+ export async function archiveRoomChannel(config, room) {
470
+ return socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}`, {
471
+ method: "DELETE",
472
+ });
473
+ }
474
+ /** Rename / re-access / re-icon a server (managers). */
475
+ export async function updateRoomSpace(config, spaceId, input) {
476
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}`, { method: "PATCH", body: input });
477
+ return result.space;
478
+ }
479
+ /** Archive a server (owner only). */
480
+ export async function archiveRoomSpace(config, spaceId) {
481
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}`, { method: "DELETE" });
482
+ }
483
+ /** Full channel roster (paged): role + lastSeen for every membership row. */
484
+ export async function listRoomChannelMembers(config, roomId, options = {}) {
485
+ const params = new URLSearchParams();
486
+ if (options.offset !== undefined)
487
+ params.set("offset", String(options.offset));
488
+ if (options.limit !== undefined)
489
+ params.set("limit", String(options.limit));
490
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(roomId)}/members${params.size ? `?${params}` : ""}`);
491
+ return {
492
+ total: result.total ?? result.members?.length ?? 0,
493
+ offset: result.offset ?? 0,
494
+ members: result.members ?? [],
495
+ };
496
+ }
497
+ export async function removeSpaceMember(config, spaceId, targetUserId) {
498
+ return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}`, { method: "DELETE" });
499
+ }
500
+ export async function changeSpaceMemberRole(config, spaceId, targetUserId, role) {
501
+ const result = await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/role`, { method: "PATCH", body: { role } });
502
+ return result.member;
503
+ }
504
+ export async function createRoomSpace(config, input) {
505
+ const result = await socialRequest(config, "/spaces", {
506
+ method: "POST",
507
+ body: input,
508
+ });
509
+ return result.space ?? {};
510
+ }
511
+ export async function restoreRoomDoc(config, docId, input = {}) {
512
+ const result = await socialRequest(config, `/docs/${encodeURIComponent(docId)}/restore`, { method: "POST", body: input });
513
+ return result.doc;
514
+ }
515
+ export async function getRoomAttachmentUrl(config, room, key) {
516
+ const params = new URLSearchParams({ key });
517
+ const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attachment-url?${params.toString()}`, { method: "GET" });
518
+ if (!result.url) {
519
+ throw new RoomSocialError("Attachment sign failed", 502, result);
520
+ }
521
+ return { url: result.url, expiresAt: result.expiresAt ?? null };
522
+ }
523
+ export async function getRoomPublicProfile(config, userId, hints = {}) {
524
+ const params = new URLSearchParams();
525
+ if (hints.handle)
526
+ params.set("handle", hints.handle);
527
+ if (hints.username)
528
+ params.set("username", hints.username);
529
+ if (hints.displayName)
530
+ params.set("displayName", hints.displayName);
531
+ const suffix = params.size ? `?${params.toString()}` : "";
532
+ const result = await socialRequest(config, `/rooms/profiles/${encodeURIComponent(userId)}${suffix}`);
533
+ return result.profile ?? { userId };
534
+ }
535
+ export async function listRoomReplies(config, options = {}) {
536
+ const params = new URLSearchParams();
537
+ if (options.unreadOnly !== undefined)
538
+ params.set("unreadOnly", String(options.unreadOnly));
539
+ if (options.limit !== undefined)
540
+ params.set("limit", String(options.limit));
541
+ const init = {};
542
+ if (options.signal)
543
+ init.signal = options.signal;
544
+ const result = await socialRequest(config, `/rooms/replies${params.size ? `?${params.toString()}` : ""}`, init);
545
+ return {
546
+ replies: result.replies ?? [],
547
+ unreadCount: result.unreadCount ?? 0,
548
+ };
549
+ }
550
+ export async function markRoomRepliesRead(config, options = {}) {
551
+ const body = {};
552
+ if (options.room)
553
+ body.roomId = cleanRoomId(options.room);
554
+ if (options.before instanceof Date) {
555
+ body.before = options.before.toISOString();
556
+ }
557
+ else if (options.before) {
558
+ body.before = options.before;
559
+ }
560
+ const result = await socialRequest(config, "/rooms/replies/read", {
561
+ method: "POST",
562
+ body,
563
+ });
564
+ return { markedRead: result.markedRead ?? 0 };
565
+ }
566
+ export async function suggestRooms(config, request, options = {}) {
567
+ const init = {
568
+ method: "POST",
569
+ body: request,
570
+ };
571
+ if (options.signal)
572
+ init.signal = options.signal;
573
+ const result = await socialRequest(config, "/rooms/suggestions", init);
574
+ return result.suggestions ?? [];
575
+ }
576
+ export async function socialRequest(config, path, init = {}) {
577
+ const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
578
+ const hasJsonBody = init.body !== undefined;
579
+ const res = await fetch(url, {
580
+ method: init.method ?? "GET",
581
+ headers: {
582
+ Authorization: `Bearer ${config.apiKey}`,
583
+ Accept: "application/json",
584
+ ...(hasJsonBody ? { "Content-Type": "application/json" } : {}),
585
+ },
586
+ ...(hasJsonBody
587
+ ? { body: JSON.stringify(init.body) }
588
+ : init.formData
589
+ ? { body: init.formData }
590
+ : {}),
591
+ ...(init.signal !== undefined ? { signal: init.signal } : {}),
592
+ });
593
+ let body;
594
+ const text = await res.text();
595
+ if (text) {
596
+ try {
597
+ body = JSON.parse(text);
598
+ }
599
+ catch {
600
+ body = { message: nonJsonErrorMessage(text, res.status, url) };
601
+ }
602
+ }
603
+ if (!res.ok) {
604
+ let message = errorMessageFromBody(body) || `chat service returned ${res.status}`;
605
+ // Express's bare route-miss ("Cannot POST /chat/spaces") reaches us as
606
+ // JSON on some deployments and says nothing useful. Translate it the
607
+ // same way as the HTML variant: the server is older than this CLI.
608
+ if (res.status === 404 && /^Cannot (GET|POST|PUT|PATCH|DELETE)\b/i.test(message)) {
609
+ message = `chat service route not found: ${requestPath(url)} — the server is running an older build than this CLI (check OM_ROOM_CHAT_API_URL / OM_ROOMS_WS_URL)`;
610
+ }
611
+ throw new RoomSocialError(message, res.status, body);
612
+ }
613
+ return (body ?? {});
614
+ }
615
+ export function cleanUsername(username) {
616
+ return username.trim().replace(/^@/, "");
617
+ }
618
+ export function cleanRoomId(room) {
619
+ return room.trim().replace(/^#/, "");
620
+ }
621
+ function errorMessageFromBody(body) {
622
+ if (!body || typeof body !== "object")
623
+ return null;
624
+ const record = body;
625
+ return typeof record.message === "string"
626
+ ? record.message
627
+ : typeof record.error === "string"
628
+ ? record.error
629
+ : null;
630
+ }
631
+ function nonJsonErrorMessage(text, status, url) {
632
+ const rendered = text
633
+ .replace(/<[^>]*>/g, " ")
634
+ .replace(/\s+/g, " ")
635
+ .trim();
636
+ const path = requestPath(url);
637
+ if (status === 404 && /^Cannot (GET|POST|PUT|PATCH|DELETE)\b/i.test(rendered)) {
638
+ return `chat service route not found: ${path}. Check OM_ROOM_CHAT_API_URL and that chat-service is running the OM Rooms social routes.`;
639
+ }
640
+ if (!rendered)
641
+ return `chat service returned ${status}`;
642
+ return rendered.length > 240 ? `${rendered.slice(0, 237)}...` : rendered;
643
+ }
644
+ function requestPath(url) {
645
+ try {
646
+ const parsed = new URL(url);
647
+ return `${parsed.pathname}${parsed.search}`;
648
+ }
649
+ catch {
650
+ return url;
651
+ }
652
+ }
653
+ function trimTrailingSlash(value) {
654
+ return value.replace(/\/+$/, "");
655
+ }
656
+ export function mimeFromFilename(filename) {
657
+ const lower = filename.toLowerCase();
658
+ if (lower.endsWith(".png"))
659
+ return "image/png";
660
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
661
+ return "image/jpeg";
662
+ if (lower.endsWith(".webp"))
663
+ return "image/webp";
664
+ if (lower.endsWith(".gif"))
665
+ return "image/gif";
666
+ if (lower.endsWith(".pdf"))
667
+ return "application/pdf";
668
+ if (lower.endsWith(".txt"))
669
+ return "text/plain";
670
+ if (lower.endsWith(".md") || lower.endsWith(".markdown"))
671
+ return "text/markdown";
672
+ if (lower.endsWith(".csv"))
673
+ return "text/csv";
674
+ if (lower.endsWith(".json"))
675
+ return "application/json";
676
+ if (lower.endsWith(".docx")) {
677
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
678
+ }
679
+ if (lower.endsWith(".xlsx")) {
680
+ return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
681
+ }
682
+ if (lower.endsWith(".pptx")) {
683
+ return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
684
+ }
685
+ return "application/octet-stream";
686
+ }