@openmarket/rooms-client 0.6.0 → 0.7.1
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.
- package/dist/agent/armed-mode.js +7 -3
- package/dist/agent/clients/common.d.ts +5 -0
- package/dist/agent/topic-inbox.d.ts +6 -0
- package/dist/agent/topic-inbox.js +20 -5
- package/dist/shared/rooms-protocol.d.ts +82 -0
- package/dist/social-client.d.ts +188 -5
- package/dist/social-client.js +346 -5
- package/dist/social-types.d.ts +164 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/agent/armed-mode.ts +7 -3
- package/src/agent/clients/common.ts +5 -0
- package/src/agent/topic-inbox.ts +23 -5
- package/src/shared/rooms-protocol.ts +85 -0
- package/src/social-client.ts +587 -6
- package/src/social-types.ts +175 -1
- package/src/types.ts +3 -0
package/src/social-client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { resolveRoomsWsUrl } from "./client.js";
|
|
2
2
|
import { envString, ROOM_CHAT_API_URL } from "./endpoints.js";
|
|
3
|
+
import type { RoomTopicAttention } from "./shared/rooms-protocol.js";
|
|
3
4
|
import type {
|
|
5
|
+
RoomBookmarkSaved,
|
|
6
|
+
RoomBookmarksResult,
|
|
4
7
|
RoomDmConversation,
|
|
5
8
|
RoomDmHistory,
|
|
6
9
|
RoomDmSendResult,
|
|
@@ -19,18 +22,25 @@ import type {
|
|
|
19
22
|
RoomFriend,
|
|
20
23
|
RoomFriendRequests,
|
|
21
24
|
RoomNotificationInboxResult,
|
|
25
|
+
RoomPollVotersResult,
|
|
22
26
|
RoomPublicProfile,
|
|
23
27
|
RoomRepliesReadResult,
|
|
24
28
|
RoomRepliesResult,
|
|
29
|
+
RoomRestEvent,
|
|
25
30
|
RoomSocialConfig,
|
|
26
31
|
RoomSpaceRestorePlan,
|
|
27
32
|
RoomSpaceRestoreReceipt,
|
|
28
33
|
RoomSuggestion,
|
|
29
34
|
RoomSuggestionRequest,
|
|
35
|
+
RoomUserBlock,
|
|
36
|
+
RoomUserBlocksResult,
|
|
30
37
|
} from "./social-types.js";
|
|
31
38
|
|
|
32
39
|
export type {
|
|
33
40
|
RoomAttachment,
|
|
41
|
+
RoomBookmark,
|
|
42
|
+
RoomBookmarkSaved,
|
|
43
|
+
RoomBookmarksResult,
|
|
34
44
|
RoomDmConversation,
|
|
35
45
|
RoomDmHistory,
|
|
36
46
|
RoomDmMessage,
|
|
@@ -51,11 +61,16 @@ export type {
|
|
|
51
61
|
RoomNotificationInboxResult,
|
|
52
62
|
RoomNotificationKind,
|
|
53
63
|
RoomNotificationMessageData,
|
|
64
|
+
RoomNotifyQuietHours,
|
|
65
|
+
RoomPollOptionVoters,
|
|
66
|
+
RoomPollVotersResult,
|
|
54
67
|
RoomPublicProfile,
|
|
55
68
|
RoomRepliesReadResult,
|
|
56
69
|
RoomRepliesResult,
|
|
57
70
|
RoomReplyInboxItem,
|
|
71
|
+
RoomRestEvent,
|
|
58
72
|
RoomSocialConfig,
|
|
73
|
+
RoomSpaceNotificationOverride,
|
|
59
74
|
RoomSpaceRestoreAction,
|
|
60
75
|
RoomSpaceRestoreActionKind,
|
|
61
76
|
RoomSpaceRestorePlan,
|
|
@@ -65,6 +80,9 @@ export type {
|
|
|
65
80
|
RoomSuggestionIntent,
|
|
66
81
|
RoomSuggestionRequest,
|
|
67
82
|
RoomSuggestionSource,
|
|
83
|
+
RoomsNotificationSettings,
|
|
84
|
+
RoomUserBlock,
|
|
85
|
+
RoomUserBlocksResult,
|
|
68
86
|
} from "./social-types.js";
|
|
69
87
|
|
|
70
88
|
/** Largest per-file size any tier accepts (plus tier). The server enforces
|
|
@@ -876,20 +894,54 @@ export interface RoomSpaceInvite {
|
|
|
876
894
|
invitedBy?: string;
|
|
877
895
|
role: "admin" | "member";
|
|
878
896
|
createdAt?: string;
|
|
897
|
+
/** Expiry deadline (ISO date); null or absent means the invite stands
|
|
898
|
+
* until accepted or declined. Additive (0.6.0 servers). */
|
|
899
|
+
expiresAt?: string | null;
|
|
879
900
|
}
|
|
880
901
|
|
|
881
|
-
/** Create (or refresh) a pending invitation. Membership needs their accept.
|
|
902
|
+
/** Create (or refresh) a pending invitation. Membership needs their accept.
|
|
903
|
+
* `expiresAt` (ISO date, future, at most 30 days out) puts a deadline on
|
|
904
|
+
* it; re-inviting without one clears any previous deadline. Fail-closed on
|
|
905
|
+
* servers older than 0.6.0: they persist the invite but drop the deadline,
|
|
906
|
+
* so a deadline the echo omits revokes the invite and throws. */
|
|
882
907
|
export async function createSpaceInvite(
|
|
883
908
|
config: RoomSocialConfig,
|
|
884
909
|
spaceId: string,
|
|
885
|
-
input: {
|
|
910
|
+
input: {
|
|
911
|
+
targetUserId?: string;
|
|
912
|
+
targetUsername?: string;
|
|
913
|
+
role?: "admin" | "member";
|
|
914
|
+
expiresAt?: string;
|
|
915
|
+
},
|
|
886
916
|
): Promise<RoomSpaceInvite> {
|
|
887
917
|
const result = await socialRequest<{ invite: RoomSpaceInvite }>(
|
|
888
918
|
config,
|
|
889
919
|
`/spaces/${encodeURIComponent(spaceId)}/invites`,
|
|
890
920
|
{ method: "POST", body: input },
|
|
891
921
|
);
|
|
892
|
-
|
|
922
|
+
const invite = result.invite;
|
|
923
|
+
// Presence check, not equality: servers may normalize the ISO string.
|
|
924
|
+
// declineSpaceInvite doubles as the manager-side revoke (the store
|
|
925
|
+
// deletes the row for invitee OR manager), so the inviter can retract
|
|
926
|
+
// the invite it just created.
|
|
927
|
+
if (input.expiresAt !== undefined && invite && typeof invite.expiresAt !== "string") {
|
|
928
|
+
const details = { spaceId, inviteId: invite.inviteId, requestedExpiresAt: input.expiresAt };
|
|
929
|
+
try {
|
|
930
|
+
await declineSpaceInvite(config, invite.inviteId);
|
|
931
|
+
} catch (revokeError) {
|
|
932
|
+
throw new RoomSocialError(
|
|
933
|
+
"Server does not support invite expiry, and revoking the invite failed: a standing (never-expiring) invite may be live",
|
|
934
|
+
502,
|
|
935
|
+
{ ...details, revokeError: (revokeError as Error).message },
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
throw new RoomSocialError(
|
|
939
|
+
"Server does not support invite expiry; the standing invite was revoked",
|
|
940
|
+
502,
|
|
941
|
+
details,
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
return invite;
|
|
893
945
|
}
|
|
894
946
|
|
|
895
947
|
/** The caller's own pending invitations. */
|
|
@@ -929,14 +981,65 @@ export async function declineSpaceInvite(
|
|
|
929
981
|
});
|
|
930
982
|
}
|
|
931
983
|
|
|
932
|
-
/** Mint (or rotate) the shareable join code; plaintext comes back once.
|
|
984
|
+
/** Mint (or rotate) the shareable join code; plaintext comes back once.
|
|
985
|
+
* Optional caps: `expiresAt` (ISO date, future, at most 30 days out) and
|
|
986
|
+
* `maxUses` (1..10000). Rotation is a fresh grant: the new code starts at
|
|
987
|
+
* zero uses and takes exactly the caps given here (omitted caps clear the
|
|
988
|
+
* previous ones). Servers older than 0.6.0 echo neither cap field; they
|
|
989
|
+
* mint the code but drop the caps, so a requested cap the echo omits
|
|
990
|
+
* fail-closes: the fresh code is revoked and the call throws rather than
|
|
991
|
+
* hand back a live UNLIMITED code the caller believes capped. */
|
|
933
992
|
export async function setSpaceJoinCode(
|
|
934
993
|
config: RoomSocialConfig,
|
|
935
994
|
spaceId: string,
|
|
936
|
-
|
|
937
|
-
|
|
995
|
+
options: { expiresAt?: string | undefined; maxUses?: number | undefined } = {},
|
|
996
|
+
): Promise<{
|
|
997
|
+
spaceId: string;
|
|
998
|
+
code: string;
|
|
999
|
+
expiresAt?: string | null;
|
|
1000
|
+
maxUses?: number | null;
|
|
1001
|
+
}> {
|
|
1002
|
+
const body: { expiresAt?: string; maxUses?: number } = {};
|
|
1003
|
+
if (options.expiresAt !== undefined) body.expiresAt = options.expiresAt;
|
|
1004
|
+
if (options.maxUses !== undefined) body.maxUses = options.maxUses;
|
|
1005
|
+
const result = await socialRequest<{
|
|
1006
|
+
spaceId: string;
|
|
1007
|
+
code: string;
|
|
1008
|
+
expiresAt?: string | null;
|
|
1009
|
+
maxUses?: number | null;
|
|
1010
|
+
}>(config, `/spaces/${encodeURIComponent(spaceId)}/join-code`, {
|
|
938
1011
|
method: "PUT",
|
|
1012
|
+
// Capless mints keep the historical bodiless PUT on the wire.
|
|
1013
|
+
...(Object.keys(body).length > 0 ? { body } : {}),
|
|
939
1014
|
});
|
|
1015
|
+
// Presence check for expiresAt (servers may normalize the ISO string),
|
|
1016
|
+
// numeric equality for maxUses.
|
|
1017
|
+
const droppedExpiry = body.expiresAt !== undefined && typeof result.expiresAt !== "string";
|
|
1018
|
+
const droppedMaxUses = body.maxUses !== undefined && result.maxUses !== body.maxUses;
|
|
1019
|
+
if (droppedExpiry || droppedMaxUses) {
|
|
1020
|
+
// Details deliberately omit the plaintext code: RoomSocialError.details
|
|
1021
|
+
// ends up in logs.
|
|
1022
|
+
const details = {
|
|
1023
|
+
spaceId,
|
|
1024
|
+
requested: body,
|
|
1025
|
+
echoed: { expiresAt: result.expiresAt ?? null, maxUses: result.maxUses ?? null },
|
|
1026
|
+
};
|
|
1027
|
+
try {
|
|
1028
|
+
await clearSpaceJoinCode(config, spaceId);
|
|
1029
|
+
} catch (revokeError) {
|
|
1030
|
+
throw new RoomSocialError(
|
|
1031
|
+
"Server does not support join-code caps, and revoking the code failed: an uncapped join code may be live",
|
|
1032
|
+
502,
|
|
1033
|
+
{ ...details, revokeError: (revokeError as Error).message },
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
throw new RoomSocialError(
|
|
1037
|
+
"Server does not support join-code caps; the uncapped code was revoked",
|
|
1038
|
+
502,
|
|
1039
|
+
details,
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
return result;
|
|
940
1043
|
}
|
|
941
1044
|
|
|
942
1045
|
export async function clearSpaceJoinCode(
|
|
@@ -1325,6 +1428,484 @@ export async function listRoomNotifications(
|
|
|
1325
1428
|
};
|
|
1326
1429
|
}
|
|
1327
1430
|
|
|
1431
|
+
/**
|
|
1432
|
+
* Mark a room unread from `seq` (Discord-style): the room-wide read cursor
|
|
1433
|
+
* drops to seq - 1 (server-clamped at 0) so the unread divider and pill
|
|
1434
|
+
* return. The one sanctioned cursor-regression path; mention rows an
|
|
1435
|
+
* earlier ack swept stay swept, and nothing is re-notified.
|
|
1436
|
+
*/
|
|
1437
|
+
export async function markRoomUnread(
|
|
1438
|
+
config: RoomSocialConfig,
|
|
1439
|
+
room: string,
|
|
1440
|
+
seq: number,
|
|
1441
|
+
): Promise<{ success: boolean }> {
|
|
1442
|
+
const result = await socialRequest<{ success?: boolean }>(
|
|
1443
|
+
config,
|
|
1444
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/read/unread`,
|
|
1445
|
+
{ method: "POST", body: { seq } },
|
|
1446
|
+
);
|
|
1447
|
+
return { success: result.success === true };
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* Set the caller's room-level attention over REST, the path that carries
|
|
1452
|
+
* mute-with-duration: `untilMs` (epoch ms, valid only with 'mute') stamps
|
|
1453
|
+
* the expiry that read state later surfaces as `muteUntil`; the mute lapses
|
|
1454
|
+
* server-side and reads as 'default' afterwards. Every write settles the
|
|
1455
|
+
* expiry, so follow/default (and a plain permanent mute) clear a previous
|
|
1456
|
+
* one. The pinned ROOM_SET_ATTENTION frame (setRoomAttentionOnClient)
|
|
1457
|
+
* predates `untilMs` and stays the live-session path for permanent
|
|
1458
|
+
* postures; servers older than mute-with-duration strip the field and land
|
|
1459
|
+
* the mute permanent, so a requested `untilMs` the echo omits is a DROPPED
|
|
1460
|
+
* DEADLINE. By default that fail-closes here: the attention is reset and
|
|
1461
|
+
* the call throws rather than leave an unbounded mute the caller believes
|
|
1462
|
+
* timed. Callers with a richer fail-closed seam of their own opt out via
|
|
1463
|
+
* `onDroppedDeadline: "resolve"` and own the handling (see the option).
|
|
1464
|
+
* Echoes the persisted attention; `muteUntil` is the ISO expiry, null when
|
|
1465
|
+
* none stands (which, under "resolve", is how a dropped deadline reads).
|
|
1466
|
+
*/
|
|
1467
|
+
export async function setRoomAttentionRest(
|
|
1468
|
+
config: RoomSocialConfig,
|
|
1469
|
+
room: string,
|
|
1470
|
+
attention: RoomTopicAttention,
|
|
1471
|
+
options: {
|
|
1472
|
+
untilMs?: number | undefined;
|
|
1473
|
+
/** What to do when a requested `untilMs` comes back without an echoed
|
|
1474
|
+
* `muteUntil` (the server predates timed mutes and landed the mute
|
|
1475
|
+
* PERMANENT). Default "undo-and-throw": reset attention to 'default'
|
|
1476
|
+
* and throw RoomSocialError 502, so no unbounded mute stands. Pass
|
|
1477
|
+
* "resolve" ONLY when the caller implements its own dropped-deadline
|
|
1478
|
+
* handling: the call then performs no undo write and no throw, and
|
|
1479
|
+
* resolves with `muteUntil: null` for the caller to act on. Canonical
|
|
1480
|
+
* example: the GUI session seam, which undoes to the last
|
|
1481
|
+
* WS-confirmed attention, richer than this library's
|
|
1482
|
+
* undo-to-default. */
|
|
1483
|
+
onDroppedDeadline?: "undo-and-throw" | "resolve" | undefined;
|
|
1484
|
+
} = {},
|
|
1485
|
+
): Promise<{ attention: RoomTopicAttention; muteUntil: string | null }> {
|
|
1486
|
+
const body: { attention: RoomTopicAttention; untilMs?: number } = { attention };
|
|
1487
|
+
if (options.untilMs !== undefined) body.untilMs = options.untilMs;
|
|
1488
|
+
const result = await socialRequest<{
|
|
1489
|
+
success?: boolean;
|
|
1490
|
+
roomId?: string;
|
|
1491
|
+
attention?: RoomTopicAttention;
|
|
1492
|
+
muteUntil?: string;
|
|
1493
|
+
}>(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/attention`, {
|
|
1494
|
+
method: "POST",
|
|
1495
|
+
body,
|
|
1496
|
+
});
|
|
1497
|
+
const muteUntil = typeof result.muteUntil === "string" ? result.muteUntil : null;
|
|
1498
|
+
if (
|
|
1499
|
+
options.untilMs !== undefined &&
|
|
1500
|
+
muteUntil === null &&
|
|
1501
|
+
options.onDroppedDeadline !== "resolve"
|
|
1502
|
+
) {
|
|
1503
|
+
// The undo cannot know the caller's prior attention, so it deliberately
|
|
1504
|
+
// lands 'default' (the same neutral a lapsed timed mute reads as, see
|
|
1505
|
+
// resolveTopicAttention) rather than leave the unbounded mute standing.
|
|
1506
|
+
try {
|
|
1507
|
+
await setRoomAttentionRest(config, room, "default");
|
|
1508
|
+
} catch (undoError) {
|
|
1509
|
+
throw new RoomSocialError(
|
|
1510
|
+
"Server does not support timed mutes, and undoing the mute failed: a permanent mute may be standing",
|
|
1511
|
+
502,
|
|
1512
|
+
{ room, undoError: (undoError as Error).message },
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
throw new RoomSocialError(
|
|
1516
|
+
"Server does not support timed mutes; the permanent mute was undone (attention is now 'default')",
|
|
1517
|
+
502,
|
|
1518
|
+
{ room },
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
return { attention: result.attention ?? attention, muteUntil };
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Save a message for later (personal, per-user; nothing broadcasts).
|
|
1526
|
+
* Idempotent: re-saving an already bookmarked event echoes the standing
|
|
1527
|
+
* row with its original createdAt.
|
|
1528
|
+
*/
|
|
1529
|
+
export async function addRoomBookmark(
|
|
1530
|
+
config: RoomSocialConfig,
|
|
1531
|
+
room: string,
|
|
1532
|
+
seq: number,
|
|
1533
|
+
): Promise<RoomBookmarkSaved> {
|
|
1534
|
+
const result = await socialRequest<{ success?: boolean; bookmark?: RoomBookmarkSaved }>(
|
|
1535
|
+
config,
|
|
1536
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks`,
|
|
1537
|
+
{ method: "POST", body: { seq } },
|
|
1538
|
+
);
|
|
1539
|
+
if (!result.bookmark) {
|
|
1540
|
+
throw new RoomSocialError("Bookmark save returned no row", 502, result);
|
|
1541
|
+
}
|
|
1542
|
+
return result.bookmark;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Remove a bookmark. Idempotent, and deliberately NOT gated on room
|
|
1546
|
+
* readability: cleanup keeps working after lost membership or a deleted
|
|
1547
|
+
* target (`removed` is false when no row stood). */
|
|
1548
|
+
/** Sequence numbers ride URL paths; a JavaScript caller can pass any value
|
|
1549
|
+
* despite the type annotation, and fetch normalizes dot segments, so a seq
|
|
1550
|
+
* of ".." would rewrite a bookmark DELETE onto the ROOM route (archive).
|
|
1551
|
+
* Runtime-require a nonnegative safe integer before interpolating. */
|
|
1552
|
+
function requireSeq(seq: number): number {
|
|
1553
|
+
if (!Number.isSafeInteger(seq) || seq < 0) {
|
|
1554
|
+
throw new RoomSocialError("Invalid sequence number", 400, { seq: String(seq) });
|
|
1555
|
+
}
|
|
1556
|
+
return seq;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
export async function removeRoomBookmark(
|
|
1560
|
+
config: RoomSocialConfig,
|
|
1561
|
+
room: string,
|
|
1562
|
+
seq: number,
|
|
1563
|
+
): Promise<{ removed: boolean }> {
|
|
1564
|
+
const result = await socialRequest<{ success?: boolean; removed?: boolean }>(
|
|
1565
|
+
config,
|
|
1566
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/bookmarks/${requireSeq(seq)}`,
|
|
1567
|
+
{ method: "DELETE" },
|
|
1568
|
+
);
|
|
1569
|
+
return { removed: result.removed === true };
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* The caller's bookmarks across every room (GET /users/bookmarks), newest
|
|
1574
|
+
* first with the notification inbox's composite cursor: pass a page's
|
|
1575
|
+
* lastCursor and lastCursorId back TOGETHER for the next page. Rows in
|
|
1576
|
+
* rooms the caller can no longer read are subtracted server-side.
|
|
1577
|
+
*/
|
|
1578
|
+
export async function listUserBookmarks(
|
|
1579
|
+
config: RoomSocialConfig,
|
|
1580
|
+
options: {
|
|
1581
|
+
limit?: number | undefined;
|
|
1582
|
+
/** Feed a page's lastCursor back here (explicit undefined reads as
|
|
1583
|
+
* "first page", so `page.lastCursor ?? undefined` composes under
|
|
1584
|
+
* exactOptionalPropertyTypes). */
|
|
1585
|
+
cursor?: string | undefined;
|
|
1586
|
+
cursorId?: string | undefined;
|
|
1587
|
+
signal?: AbortSignal | undefined;
|
|
1588
|
+
} = {},
|
|
1589
|
+
): Promise<RoomBookmarksResult> {
|
|
1590
|
+
const params = new URLSearchParams();
|
|
1591
|
+
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1592
|
+
if (options.cursor !== undefined) params.set("cursor", options.cursor);
|
|
1593
|
+
if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
|
|
1594
|
+
const init: { signal?: AbortSignal } = {};
|
|
1595
|
+
if (options.signal) init.signal = options.signal;
|
|
1596
|
+
const result = await socialRequest<Partial<RoomBookmarksResult>>(
|
|
1597
|
+
config,
|
|
1598
|
+
`/users/bookmarks${params.size ? `?${params.toString()}` : ""}`,
|
|
1599
|
+
init,
|
|
1600
|
+
);
|
|
1601
|
+
return {
|
|
1602
|
+
bookmarks: result.bookmarks ?? [],
|
|
1603
|
+
hasMore: result.hasMore ?? false,
|
|
1604
|
+
lastCursor: result.lastCursor ?? null,
|
|
1605
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
1606
|
+
total: result.total ?? 0,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* The caller's user-block list (GET /users/blocks), newest first with the
|
|
1612
|
+
* same composite-cursor paging as the notification inbox. Blocks are
|
|
1613
|
+
* directional and the blocker is always the verified caller.
|
|
1614
|
+
*/
|
|
1615
|
+
export async function getUserBlocks(
|
|
1616
|
+
config: RoomSocialConfig,
|
|
1617
|
+
options: {
|
|
1618
|
+
limit?: number | undefined;
|
|
1619
|
+
cursor?: string | undefined;
|
|
1620
|
+
cursorId?: string | undefined;
|
|
1621
|
+
signal?: AbortSignal | undefined;
|
|
1622
|
+
} = {},
|
|
1623
|
+
): Promise<RoomUserBlocksResult> {
|
|
1624
|
+
const params = new URLSearchParams();
|
|
1625
|
+
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1626
|
+
if (options.cursor !== undefined) params.set("cursor", options.cursor);
|
|
1627
|
+
if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
|
|
1628
|
+
const init: { signal?: AbortSignal } = {};
|
|
1629
|
+
if (options.signal) init.signal = options.signal;
|
|
1630
|
+
const result = await socialRequest<Partial<RoomUserBlocksResult>>(
|
|
1631
|
+
config,
|
|
1632
|
+
`/users/blocks${params.size ? `?${params.toString()}` : ""}`,
|
|
1633
|
+
init,
|
|
1634
|
+
);
|
|
1635
|
+
return {
|
|
1636
|
+
blocks: result.blocks ?? [],
|
|
1637
|
+
hasMore: result.hasMore ?? false,
|
|
1638
|
+
lastCursor: result.lastCursor ?? null,
|
|
1639
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/** Path-safety guard for block-target ids, NOT a format guard: the server
|
|
1644
|
+
* accepts any trimmed non-empty id up to 128 chars (legacy and agent
|
|
1645
|
+
* identities are not ObjectIds), so only the traversal class is refused
|
|
1646
|
+
* here: empty ids and pure dot segments, which encodeURIComponent leaves
|
|
1647
|
+
* alone and fetch would normalize onto /users/ itself. Everything else
|
|
1648
|
+
* rides percent-encoded. */
|
|
1649
|
+
function requireBlockUserId(userId: string): string {
|
|
1650
|
+
const trimmed = String(userId).trim();
|
|
1651
|
+
if (trimmed === "" || trimmed === "." || trimmed === ".." || trimmed.length > 128) {
|
|
1652
|
+
throw new RoomSocialError("Invalid user id", 400, { userId: String(userId) });
|
|
1653
|
+
}
|
|
1654
|
+
return trimmed;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** Block a user by userId (PUT, idempotent: re-blocking echoes the standing
|
|
1658
|
+
* row with its original createdAt). Server-enforced effects: DM doors shut
|
|
1659
|
+
* both directions, friend requests refuse, and the blocked sender produces
|
|
1660
|
+
* no notifications for the caller. Room visibility is unchanged. */
|
|
1661
|
+
export async function blockUser(config: RoomSocialConfig, userId: string): Promise<RoomUserBlock> {
|
|
1662
|
+
const result = await socialRequest<{ success?: boolean; block?: RoomUserBlock }>(
|
|
1663
|
+
config,
|
|
1664
|
+
`/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`,
|
|
1665
|
+
{ method: "PUT" },
|
|
1666
|
+
);
|
|
1667
|
+
if (!result.block) {
|
|
1668
|
+
throw new RoomSocialError("Block returned no row", 502, result);
|
|
1669
|
+
}
|
|
1670
|
+
return result.block;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/** Unblock a user (idempotent: unblocking a non-blocked user is a no-op). */
|
|
1674
|
+
export async function unblockUser(
|
|
1675
|
+
config: RoomSocialConfig,
|
|
1676
|
+
userId: string,
|
|
1677
|
+
): Promise<{ success: boolean }> {
|
|
1678
|
+
const result = await socialRequest<{ success?: boolean }>(
|
|
1679
|
+
config,
|
|
1680
|
+
`/users/blocks/${encodeURIComponent(requireBlockUserId(userId))}`,
|
|
1681
|
+
{ method: "DELETE" },
|
|
1682
|
+
);
|
|
1683
|
+
return { success: result.success === true };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Post a poll message. There is no separate create route and no WS input
|
|
1688
|
+
* path (the relay's post frame carries text only): a poll rides the normal
|
|
1689
|
+
* REST append as the strict body's `poll` block. `options` are option TEXT
|
|
1690
|
+
* (2..10 entries; ids are minted server-side) and `closesAt` is an ISO
|
|
1691
|
+
* deadline that must sit in the future. `text` is the optional caption;
|
|
1692
|
+
* absent is fine, the poll IS the content. Echoes the created store event
|
|
1693
|
+
* row with its counts-only summary; the room hears the same message
|
|
1694
|
+
* through the ordinary live echo. Additive (0.6.0 servers): older stores
|
|
1695
|
+
* reject the unknown block.
|
|
1696
|
+
*/
|
|
1697
|
+
export async function createRoomPoll(
|
|
1698
|
+
config: RoomSocialConfig,
|
|
1699
|
+
room: string,
|
|
1700
|
+
input: {
|
|
1701
|
+
question: string;
|
|
1702
|
+
options: string[];
|
|
1703
|
+
multi?: boolean | undefined;
|
|
1704
|
+
closesAt?: string | undefined;
|
|
1705
|
+
text?: string | undefined;
|
|
1706
|
+
/** Exact-case workspace id: lets a COLD relay replica materialize a
|
|
1707
|
+
* virtual workspace room without guessing the id's casing from the
|
|
1708
|
+
* lowercased room name (owners resolve server-side; non-owner first
|
|
1709
|
+
* writers need this). */
|
|
1710
|
+
workspaceId?: string | undefined;
|
|
1711
|
+
/** Route into a topic lane like any other append. */
|
|
1712
|
+
topicId?: string | undefined;
|
|
1713
|
+
/** Post the poll as a threaded reply. */
|
|
1714
|
+
replyToSeq?: number | undefined;
|
|
1715
|
+
/** Server-side idempotency: retries after an ambiguous failure reuse
|
|
1716
|
+
* the key instead of minting a duplicate poll. */
|
|
1717
|
+
clientMsgId?: string | undefined;
|
|
1718
|
+
},
|
|
1719
|
+
): Promise<RoomRestEvent> {
|
|
1720
|
+
const poll: { question: string; options: string[]; multi?: boolean; closesAt?: string } = {
|
|
1721
|
+
question: input.question,
|
|
1722
|
+
options: input.options,
|
|
1723
|
+
};
|
|
1724
|
+
if (input.multi !== undefined) poll.multi = input.multi;
|
|
1725
|
+
if (input.closesAt !== undefined) poll.closesAt = input.closesAt;
|
|
1726
|
+
const body: {
|
|
1727
|
+
roomId: string;
|
|
1728
|
+
poll: typeof poll;
|
|
1729
|
+
text?: string;
|
|
1730
|
+
workspaceId?: string;
|
|
1731
|
+
topicId?: string;
|
|
1732
|
+
replyToSeq?: number;
|
|
1733
|
+
clientMsgId?: string;
|
|
1734
|
+
} = {
|
|
1735
|
+
roomId: cleanRoomId(room),
|
|
1736
|
+
poll,
|
|
1737
|
+
};
|
|
1738
|
+
if (input.text !== undefined) body.text = input.text;
|
|
1739
|
+
if (input.workspaceId !== undefined) body.workspaceId = input.workspaceId;
|
|
1740
|
+
if (input.topicId !== undefined) body.topicId = input.topicId;
|
|
1741
|
+
if (input.replyToSeq !== undefined) body.replyToSeq = input.replyToSeq;
|
|
1742
|
+
if (input.clientMsgId !== undefined) body.clientMsgId = input.clientMsgId;
|
|
1743
|
+
const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
|
|
1744
|
+
config,
|
|
1745
|
+
"/rooms/events",
|
|
1746
|
+
{ method: "POST", body },
|
|
1747
|
+
);
|
|
1748
|
+
if (!result.event) {
|
|
1749
|
+
throw new RoomSocialError("Poll create returned no event", 502, result);
|
|
1750
|
+
}
|
|
1751
|
+
return result.event;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
/**
|
|
1755
|
+
* Vote on a poll (the poll is keyed by its carrying message's seq). Echoes
|
|
1756
|
+
* the store's public event row (RoomRestEvent, not the relay's mapped
|
|
1757
|
+
* ledger entry) with the refreshed counts-only summary; the room hears the
|
|
1758
|
+
* same update over ENTRY_UPDATED. Single-choice polls replace the caller's
|
|
1759
|
+
* previous ballot; multi-choice polls union into it.
|
|
1760
|
+
*/
|
|
1761
|
+
export async function voteRoomPoll(
|
|
1762
|
+
config: RoomSocialConfig,
|
|
1763
|
+
room: string,
|
|
1764
|
+
seq: number,
|
|
1765
|
+
optionIds: string[],
|
|
1766
|
+
): Promise<RoomRestEvent> {
|
|
1767
|
+
const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
|
|
1768
|
+
config,
|
|
1769
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`,
|
|
1770
|
+
{ method: "POST", body: { optionIds } },
|
|
1771
|
+
);
|
|
1772
|
+
if (!result.event) {
|
|
1773
|
+
throw new RoomSocialError("Poll vote returned no event", 502, result);
|
|
1774
|
+
}
|
|
1775
|
+
return result.event;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** Clear the caller's vote. Echoes the refreshed store event row. */
|
|
1779
|
+
export async function unvoteRoomPoll(
|
|
1780
|
+
config: RoomSocialConfig,
|
|
1781
|
+
room: string,
|
|
1782
|
+
seq: number,
|
|
1783
|
+
): Promise<RoomRestEvent> {
|
|
1784
|
+
const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
|
|
1785
|
+
config,
|
|
1786
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/vote`,
|
|
1787
|
+
{ method: "DELETE" },
|
|
1788
|
+
);
|
|
1789
|
+
if (!result.event) {
|
|
1790
|
+
throw new RoomSocialError("Poll unvote returned no event", 502, result);
|
|
1791
|
+
}
|
|
1792
|
+
return result.event;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Close a poll (idempotent on an already-closed poll). Echoes the store
|
|
1796
|
+
* event row with `poll.closed` set. */
|
|
1797
|
+
export async function closeRoomPoll(
|
|
1798
|
+
config: RoomSocialConfig,
|
|
1799
|
+
room: string,
|
|
1800
|
+
seq: number,
|
|
1801
|
+
): Promise<RoomRestEvent> {
|
|
1802
|
+
const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
|
|
1803
|
+
config,
|
|
1804
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/close`,
|
|
1805
|
+
{ method: "POST" },
|
|
1806
|
+
);
|
|
1807
|
+
if (!result.event) {
|
|
1808
|
+
throw new RoomSocialError("Poll close returned no event", 502, result);
|
|
1809
|
+
}
|
|
1810
|
+
return result.event;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/** Edit a poll's question/options (author-only, and locked once any vote
|
|
1814
|
+
* exists or the poll closes; at least one field required). Options are
|
|
1815
|
+
* full replacement TEXT, like creation. */
|
|
1816
|
+
/** Purge an event's frozen unfurl cards without editing or deleting the
|
|
1817
|
+
* message. Author or a delete-any moderation power; the store unsets the
|
|
1818
|
+
* cards, schedules thumbnail cleanup, and returns the mutated event for
|
|
1819
|
+
* ENTRY_UPDATED fan-out. An edit that removes a card's URL from the text
|
|
1820
|
+
* drops that card automatically; this route is the explicit purge for
|
|
1821
|
+
* everything else (a capability URL that must go without rewording the
|
|
1822
|
+
* message, a moderator acting on someone else's post). */
|
|
1823
|
+
export async function unsetRoomEventUnfurls(
|
|
1824
|
+
config: RoomSocialConfig,
|
|
1825
|
+
room: string,
|
|
1826
|
+
seq: number,
|
|
1827
|
+
): Promise<void> {
|
|
1828
|
+
await socialRequest(
|
|
1829
|
+
config,
|
|
1830
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}/unfurls`,
|
|
1831
|
+
{ method: "DELETE" },
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Edit an event's text over REST: the cold-replica-safe mirror of the WS
|
|
1836
|
+
* EDIT frame, and the ONE shape the WS path cannot express: an empty text
|
|
1837
|
+
* on a poll-bearing message legally clears the optional caption (the poll
|
|
1838
|
+
* stays the content; the store rejects empty edits on plain messages).
|
|
1839
|
+
* Viewers reconcile through the store's ENTRY_UPDATED fan-out exactly
|
|
1840
|
+
* like the REST poll verbs. */
|
|
1841
|
+
export async function editRoomEventRest(
|
|
1842
|
+
config: RoomSocialConfig,
|
|
1843
|
+
room: string,
|
|
1844
|
+
seq: number,
|
|
1845
|
+
text: string,
|
|
1846
|
+
): Promise<void> {
|
|
1847
|
+
await socialRequest(
|
|
1848
|
+
config,
|
|
1849
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/events/${requireSeq(seq)}`,
|
|
1850
|
+
{ method: "PATCH", body: { text } },
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
export async function editRoomPoll(
|
|
1855
|
+
config: RoomSocialConfig,
|
|
1856
|
+
room: string,
|
|
1857
|
+
seq: number,
|
|
1858
|
+
patch: { question?: string | undefined; options?: string[] | undefined },
|
|
1859
|
+
): Promise<RoomRestEvent> {
|
|
1860
|
+
const body: { question?: string; options?: string[] } = {};
|
|
1861
|
+
if (patch.question !== undefined) body.question = patch.question;
|
|
1862
|
+
if (patch.options !== undefined) body.options = patch.options;
|
|
1863
|
+
const result = await socialRequest<{ success?: boolean; event?: RoomRestEvent }>(
|
|
1864
|
+
config,
|
|
1865
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}`,
|
|
1866
|
+
{ method: "PATCH", body },
|
|
1867
|
+
);
|
|
1868
|
+
if (!result.event) {
|
|
1869
|
+
throw new RoomSocialError("Poll edit returned no event", 502, result);
|
|
1870
|
+
}
|
|
1871
|
+
return result.event;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Voter identities, on demand (the entry's summary carries counts only).
|
|
1876
|
+
* Grouped per option; `optionId` narrows to one option and `limit` caps the
|
|
1877
|
+
* identities per option (counts stay exact).
|
|
1878
|
+
*/
|
|
1879
|
+
export async function listRoomPollVoters(
|
|
1880
|
+
config: RoomSocialConfig,
|
|
1881
|
+
room: string,
|
|
1882
|
+
seq: number,
|
|
1883
|
+
options: {
|
|
1884
|
+
optionId?: string | undefined;
|
|
1885
|
+
limit?: number | undefined;
|
|
1886
|
+
signal?: AbortSignal | undefined;
|
|
1887
|
+
} = {},
|
|
1888
|
+
): Promise<RoomPollVotersResult> {
|
|
1889
|
+
const params = new URLSearchParams();
|
|
1890
|
+
if (options.optionId !== undefined) params.set("optionId", options.optionId);
|
|
1891
|
+
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1892
|
+
const init: { signal?: AbortSignal } = {};
|
|
1893
|
+
if (options.signal) init.signal = options.signal;
|
|
1894
|
+
const result = await socialRequest<Partial<RoomPollVotersResult>>(
|
|
1895
|
+
config,
|
|
1896
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/polls/${requireSeq(seq)}/voters${
|
|
1897
|
+
params.size ? `?${params.toString()}` : ""
|
|
1898
|
+
}`,
|
|
1899
|
+
init,
|
|
1900
|
+
);
|
|
1901
|
+
return {
|
|
1902
|
+
room: result.room ?? cleanRoomId(room),
|
|
1903
|
+
seq: result.seq ?? seq,
|
|
1904
|
+
voters: result.voters ?? [],
|
|
1905
|
+
viewerOptionIds: result.viewerOptionIds ?? [],
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1328
1909
|
export async function suggestRooms(
|
|
1329
1910
|
config: RoomSocialConfig,
|
|
1330
1911
|
request: RoomSuggestionRequest,
|