@openmarket/rooms-client 0.5.0 → 0.6.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.
- package/dist/agent/armed-mode.d.ts +0 -8
- package/dist/agent/armed-mode.js +12 -0
- package/dist/agent/clients/common.d.ts +8 -1
- package/dist/agent/clients/common.js +11 -1
- package/dist/agent/sidebar-model.d.ts +25 -1
- package/dist/agent/sidebar-model.js +32 -1
- package/dist/shared/rooms-protocol.d.ts +24 -2
- package/dist/social-client.d.ts +48 -2
- package/dist/social-client.js +60 -0
- package/dist/social-types.d.ts +38 -0
- package/package.json +2 -7
- package/src/agent/armed-mode.ts +13 -0
- package/src/agent/clients/common.ts +13 -2
- package/src/agent/sidebar-model.ts +66 -2
- package/src/shared/rooms-protocol.ts +25 -2
- package/src/social-client.ts +132 -0
- package/src/social-types.ts +42 -0
|
@@ -100,14 +100,6 @@ export declare function rearmedRoomState(roomId: string, retired: ArmedRoomState
|
|
|
100
100
|
/** Seqs of the user's own posts in a window of entries; the reply-trigger
|
|
101
101
|
* check tests `inReplyTo` against this set. Pure. */
|
|
102
102
|
export declare function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: string): Set<number>;
|
|
103
|
-
/**
|
|
104
|
-
* Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
|
|
105
|
-
* the room that @mentions the user's own handle, OR (when `ownSeqs` is given)
|
|
106
|
-
* directly replies to one of the user's own messages. Reply semantics match a
|
|
107
|
-
* mention (replying pings the author), so armed mode treats both as addressed
|
|
108
|
-
* to the user. Agent posts, system entries, the user's own posts, and
|
|
109
|
-
* non-mentions never trigger. Pure.
|
|
110
|
-
*/
|
|
111
103
|
export declare function isTriggeringEntry(entry: RoomLedgerEntry, ownHandle: string, ownUserId: string, ownSeqs?: ReadonlySet<number>): boolean;
|
|
112
104
|
/**
|
|
113
105
|
* The newest qualifying trigger among entries strictly newer than
|
package/dist/agent/armed-mode.js
CHANGED
|
@@ -117,11 +117,21 @@ export function ownSeqsOf(entries, ownUserId) {
|
|
|
117
117
|
* to the user. Agent posts, system entries, the user's own posts, and
|
|
118
118
|
* non-mentions never trigger. Pure.
|
|
119
119
|
*/
|
|
120
|
+
/** Webhook-ingested entries are machine posts even though isAgent is
|
|
121
|
+
* false (the flag marks agent USERS). They must never count as the human
|
|
122
|
+
* in any autonomy gate: a webhook @mention cannot start an unreviewed
|
|
123
|
+
* autonomous turn, and webhook chatter cannot satisfy the
|
|
124
|
+
* human-activity brake. */
|
|
125
|
+
function isWebhookEntry(entry) {
|
|
126
|
+
return Boolean(entry.metadata?.webhook);
|
|
127
|
+
}
|
|
120
128
|
export function isTriggeringEntry(entry, ownHandle, ownUserId, ownSeqs) {
|
|
121
129
|
if (entry.type !== RoomLedgerEntryType.POST)
|
|
122
130
|
return false;
|
|
123
131
|
if (entry.isAgent)
|
|
124
132
|
return false;
|
|
133
|
+
if (isWebhookEntry(entry))
|
|
134
|
+
return false;
|
|
125
135
|
if (!entry.text?.trim())
|
|
126
136
|
return false;
|
|
127
137
|
// Never trigger on our own message (we post as this handle/userId).
|
|
@@ -312,6 +322,8 @@ export function isHumanResetEntry(entry, ownHandle, ownUserId) {
|
|
|
312
322
|
return false;
|
|
313
323
|
if (entry.isAgent)
|
|
314
324
|
return false;
|
|
325
|
+
if (isWebhookEntry(entry))
|
|
326
|
+
return false;
|
|
315
327
|
if (entry.userId === ownUserId)
|
|
316
328
|
return false;
|
|
317
329
|
const fromHandle = normalizeHandle(entry.handle).toLowerCase();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { type KnownProvider } from "@earendil-works/pi-ai";
|
|
2
|
-
|
|
2
|
+
/** Every chat surface, as a runtime list. The `SurfaceId` union derives from
|
|
3
|
+
* this literal so per-surface policy checklists (packages/cli/test/
|
|
4
|
+
* harness-budgets.test.ts) can iterate the surfaces at runtime while a new
|
|
5
|
+
* entry still breaks type-exhaustive tables at compile time. Adding a
|
|
6
|
+
* surface here requires classifying it in EVERY policy set (see AGENTS.md,
|
|
7
|
+
* "Harness invariants"). */
|
|
8
|
+
export declare const SURFACE_IDS: readonly ["cli", "telegram", "discord", "slack", "webui"];
|
|
9
|
+
export type SurfaceId = (typeof SURFACE_IDS)[number];
|
|
3
10
|
export interface AgentToolCall {
|
|
4
11
|
id: string;
|
|
5
12
|
name: string;
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { getProviders } from "@earendil-works/pi-ai";
|
|
2
|
+
/** Every chat surface, as a runtime list. The `SurfaceId` union derives from
|
|
3
|
+
* this literal so per-surface policy checklists (packages/cli/test/
|
|
4
|
+
* harness-budgets.test.ts) can iterate the surfaces at runtime while a new
|
|
5
|
+
* entry still breaks type-exhaustive tables at compile time. Adding a
|
|
6
|
+
* surface here requires classifying it in EVERY policy set (see AGENTS.md,
|
|
7
|
+
* "Harness invariants"). */
|
|
8
|
+
export const SURFACE_IDS = ["cli", "telegram", "discord", "slack", "webui"];
|
|
2
9
|
/** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
|
|
3
10
|
* (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
|
|
4
11
|
* KnownProvider: the model object is constructed at turn time from the
|
|
@@ -32,7 +39,10 @@ export function stringifyToolContent(value) {
|
|
|
32
39
|
return "";
|
|
33
40
|
if (typeof value === "string")
|
|
34
41
|
return value;
|
|
35
|
-
|
|
42
|
+
// Compact on purpose: this string is LLM input, and 2-space pretty-printing
|
|
43
|
+
// inflated every structured tool result by roughly a fifth in tokens for
|
|
44
|
+
// zero model benefit.
|
|
45
|
+
return JSON.stringify(value);
|
|
36
46
|
}
|
|
37
47
|
export function parseToolArguments(raw) {
|
|
38
48
|
if (raw === undefined || raw === null)
|
|
@@ -22,6 +22,9 @@ export interface SidebarRoom {
|
|
|
22
22
|
headSeq: number | null;
|
|
23
23
|
lastReadSeq: number | null;
|
|
24
24
|
unread: number;
|
|
25
|
+
/** Unread mention count from the latest read-state snapshot: the server's
|
|
26
|
+
* number verbatim, 0 when the snapshot carried none for this room. */
|
|
27
|
+
mentionUnread: number;
|
|
25
28
|
joined: boolean;
|
|
26
29
|
/** Topics dial from room meta. Absent reads as "allowed" since the
|
|
27
30
|
* 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
|
|
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
|
|
|
40
43
|
dms: SidebarDm[];
|
|
41
44
|
popular: SidebarRoom[];
|
|
42
45
|
globalReplyUnread: number;
|
|
46
|
+
/** Per-room unread mention counts exactly as the server sent them in the
|
|
47
|
+
* latest read-state snapshot: sparse (zero-count rooms absent) and
|
|
48
|
+
* global (rooms outside the current listings may appear). Empty when the
|
|
49
|
+
* snapshot carried none; a fresh snapshot replaces it wholesale. */
|
|
50
|
+
mentionCounts: Record<string, number>;
|
|
51
|
+
/** User-wide unread mention total from the same snapshot. */
|
|
52
|
+
mentionTotal: number;
|
|
43
53
|
fetchedAt: number;
|
|
44
54
|
}
|
|
45
55
|
export declare const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot;
|
|
@@ -48,10 +58,22 @@ export declare function computeRoomUnread(input: {
|
|
|
48
58
|
headSeq: number | null;
|
|
49
59
|
lastReadSeq: number | null;
|
|
50
60
|
}): number;
|
|
61
|
+
/** A READ_STATE reply that carries the additive mention decorations beside
|
|
62
|
+
* the cursor map (RoomReadStatePayload satisfies this shape). Old
|
|
63
|
+
* fetchReadState implementations returning the bare cursor Record keep
|
|
64
|
+
* working; mention fields then read as zero. */
|
|
65
|
+
export interface SidebarReadState {
|
|
66
|
+
readState: Record<string, number>;
|
|
67
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
68
|
+
mentionTotal?: number | undefined;
|
|
69
|
+
}
|
|
51
70
|
export declare function buildSidebarSnapshot(input: {
|
|
52
71
|
rooms: RoomMeta[];
|
|
53
72
|
spaces: RoomSpace[];
|
|
54
73
|
readState: Record<string, number>;
|
|
74
|
+
/** Server numbers pass through verbatim; merge policy is the caller's. */
|
|
75
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
76
|
+
mentionTotal?: number | undefined;
|
|
55
77
|
dms: SidebarDm[];
|
|
56
78
|
globalReplyUnread: number;
|
|
57
79
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -60,7 +82,9 @@ export declare function buildSidebarSnapshot(input: {
|
|
|
60
82
|
export interface SidebarModelDeps {
|
|
61
83
|
fetchRooms: () => Promise<RoomMeta[]>;
|
|
62
84
|
fetchSpaces: () => Promise<RoomSpace[]>;
|
|
63
|
-
|
|
85
|
+
/** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
|
|
86
|
+
* whose mention decorations feed the snapshot's mention fields. */
|
|
87
|
+
fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
|
|
64
88
|
fetchDms: () => Promise<SidebarDm[]>;
|
|
65
89
|
fetchGlobalReplyUnread: () => Promise<number>;
|
|
66
90
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -4,6 +4,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT = {
|
|
|
4
4
|
dms: [],
|
|
5
5
|
popular: [],
|
|
6
6
|
globalReplyUnread: 0,
|
|
7
|
+
mentionCounts: {},
|
|
8
|
+
mentionTotal: 0,
|
|
7
9
|
fetchedAt: 0,
|
|
8
10
|
};
|
|
9
11
|
const POPULAR_MAX = 6;
|
|
@@ -34,6 +36,11 @@ export function buildSidebarSnapshot(input) {
|
|
|
34
36
|
headSeq,
|
|
35
37
|
lastReadSeq,
|
|
36
38
|
unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
|
|
39
|
+
// Own-key guard like the readState read above: rooms named after
|
|
40
|
+
// Object.prototype members must read 0 from a sparse map, not NaN.
|
|
41
|
+
mentionUnread: Math.max(0, input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
|
|
42
|
+
? (input.mentionCounts[name] ?? 0)
|
|
43
|
+
: 0),
|
|
37
44
|
// Joined = an open context OR a server read cursor (you only get a
|
|
38
45
|
// cursor by having been in the room). This is what decides whether a
|
|
39
46
|
// room lives in your channel tree or in the POPULAR discovery shelf.
|
|
@@ -92,9 +99,30 @@ export function buildSidebarSnapshot(input) {
|
|
|
92
99
|
dms: input.dms,
|
|
93
100
|
popular,
|
|
94
101
|
globalReplyUnread: Math.max(0, input.globalReplyUnread),
|
|
102
|
+
mentionCounts: input.mentionCounts ?? {},
|
|
103
|
+
mentionTotal: Math.max(0, input.mentionTotal ?? 0),
|
|
95
104
|
fetchedAt: input.now,
|
|
96
105
|
};
|
|
97
106
|
}
|
|
107
|
+
/** A bare cursor Record maps room name to seq (numbers only), so an object
|
|
108
|
+
* under the "readState" key can only be the snapshot shape, even for a
|
|
109
|
+
* room literally named "readState" (its cursor would be a number). */
|
|
110
|
+
function normalizeSidebarReadState(reply) {
|
|
111
|
+
const nested = reply.readState;
|
|
112
|
+
if (typeof nested === "object" && nested !== null) {
|
|
113
|
+
const snapshot = reply;
|
|
114
|
+
return {
|
|
115
|
+
readState: snapshot.readState,
|
|
116
|
+
mentionCounts: snapshot.mentionCounts,
|
|
117
|
+
mentionTotal: snapshot.mentionTotal,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
readState: reply,
|
|
122
|
+
mentionCounts: undefined,
|
|
123
|
+
mentionTotal: undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
98
126
|
export class SidebarModel {
|
|
99
127
|
deps;
|
|
100
128
|
snapshotValue = EMPTY_SIDEBAR_SNAPSHOT;
|
|
@@ -174,13 +202,16 @@ export class SidebarModel {
|
|
|
174
202
|
this.deps.fetchDms().catch(() => []),
|
|
175
203
|
this.deps.fetchGlobalReplyUnread().catch(() => 0),
|
|
176
204
|
]);
|
|
177
|
-
const
|
|
205
|
+
const readStateReply = await this.deps
|
|
178
206
|
.fetchReadState(rooms.map((room) => room.name))
|
|
179
207
|
.catch(() => ({}));
|
|
208
|
+
const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
|
|
180
209
|
return buildSidebarSnapshot({
|
|
181
210
|
rooms,
|
|
182
211
|
spaces,
|
|
183
212
|
readState,
|
|
213
|
+
mentionCounts,
|
|
214
|
+
mentionTotal,
|
|
184
215
|
dms,
|
|
185
216
|
globalReplyUnread,
|
|
186
217
|
joinedUnread: this.deps.joinedUnread,
|
|
@@ -231,7 +231,7 @@ export interface RoomLedgerEntry {
|
|
|
231
231
|
pinnedAt?: string | number | Date | null | undefined;
|
|
232
232
|
attachments?: RoomAttachment[] | undefined;
|
|
233
233
|
roomRole?: RoomRole | null | undefined;
|
|
234
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
234
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
235
235
|
}
|
|
236
236
|
export type RoomForwardedFrom = {
|
|
237
237
|
kind: "room";
|
|
@@ -245,6 +245,18 @@ export type RoomForwardedFrom = {
|
|
|
245
245
|
export interface RoomForwardMetadata {
|
|
246
246
|
forwardedFrom: RoomForwardedFrom;
|
|
247
247
|
}
|
|
248
|
+
/** Sender identity for messages posted through a channel webhook (the
|
|
249
|
+
* Discord-compatible ingest). Entries carrying it render the webhook's
|
|
250
|
+
* name and avatar with an APP-style badge; the entry's handle mirrors
|
|
251
|
+
* the display name and its userId is the synthetic `webhook:<id>`, so a
|
|
252
|
+
* webhook can never impersonate a member. */
|
|
253
|
+
export interface RoomWebhookMetadata {
|
|
254
|
+
webhook: {
|
|
255
|
+
id: string;
|
|
256
|
+
name: string;
|
|
257
|
+
avatarUrl?: string | undefined;
|
|
258
|
+
};
|
|
259
|
+
}
|
|
248
260
|
export interface RoomAttachment {
|
|
249
261
|
/**
|
|
250
262
|
* Object key under room-attachments/; clients sign it at render time.
|
|
@@ -330,6 +342,16 @@ export interface RoomReadStatePayload {
|
|
|
330
342
|
* Present when the store surfaces it and on ROOM_SET_ATTENTION acks
|
|
331
343
|
* (which reply READ_STATE scoped to the one room, readState empty). */
|
|
332
344
|
attention?: Record<string, RoomTopicAttention> | undefined;
|
|
345
|
+
/** Unread mention counts by room, server-computed from the notify
|
|
346
|
+
* worker's ROOM_MENTION rows so badges survive reload and clear through
|
|
347
|
+
* the ack path. Unlike readState/attention the map is SPARSE AND
|
|
348
|
+
* GLOBAL: every room with a nonzero count appears (requested or not),
|
|
349
|
+
* zero-count rooms are absent. Missing on servers older than the
|
|
350
|
+
* mentions-completion store. */
|
|
351
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
352
|
+
/** User-wide unread mention total (drives the app badge). Rides beside
|
|
353
|
+
* mentionCounts; missing on older servers. */
|
|
354
|
+
mentionTotal?: number | undefined;
|
|
333
355
|
requestId?: string | undefined;
|
|
334
356
|
}
|
|
335
357
|
export interface RoomSearchResultsPayload {
|
|
@@ -491,7 +513,7 @@ export interface RoomDmMessage {
|
|
|
491
513
|
createdAt?: string | number | undefined;
|
|
492
514
|
/** Sealed DM: content is '' and the opaque envelope rides here. */
|
|
493
515
|
secret?: RoomLedgerSecretPayload | null | undefined;
|
|
494
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
516
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
495
517
|
/** Legacy public chat-image URL (renders as-is, no signing). */
|
|
496
518
|
imageUrl?: string | null | undefined;
|
|
497
519
|
/** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
|
package/dist/social-client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
|
|
2
|
-
export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
|
|
1
|
+
import type { RoomDmConversation, RoomDmHistory, RoomDmSendResult, RoomDmUnreadConversation, RoomDoc, RoomDocAttribution, RoomDocChanges, RoomDocConflictHead, RoomDocEditingLease, RoomDocProvenance, RoomDocRevision, RoomDocRunRevertResult, RoomDocWriteResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequests, RoomNotificationInboxResult, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomSocialConfig, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSuggestion, RoomSuggestionRequest } from "./social-types.js";
|
|
2
|
+
export type { RoomAttachment, RoomDmConversation, RoomDmHistory, RoomDmMessage, RoomDmParticipant, RoomDmSendResult, RoomDmUnreadConversation, RoomDocAttribution, RoomDocEditingLease, RoomDocMergeRegion, RoomDocRevisionSource, RoomDocRunRevertResult, RoomFollow, RoomFollowResult, RoomFriend, RoomFriendRequest, RoomFriendRequests, RoomNotificationInboxItem, RoomNotificationInboxResult, RoomNotificationKind, RoomNotificationMessageData, RoomPublicProfile, RoomRepliesReadResult, RoomRepliesResult, RoomReplyInboxItem, RoomSocialConfig, RoomSpaceRestoreAction, RoomSpaceRestoreActionKind, RoomSpaceRestorePlan, RoomSpaceRestoreReceipt, RoomSpaceRestoreSummary, RoomSuggestion, RoomSuggestionIntent, RoomSuggestionRequest, RoomSuggestionSource, } from "./social-types.js";
|
|
3
3
|
/** Largest per-file size any tier accepts (plus tier). The server enforces
|
|
4
4
|
* the per-tier caps (free 10MB/file, plus 25MB/file) and daily quotas; this
|
|
5
5
|
* constant only pre-flights uploads no tier could ever accept. */
|
|
@@ -210,6 +210,31 @@ export declare function updateSpaceRole(config: RoomSocialConfig, spaceId: strin
|
|
|
210
210
|
position?: number;
|
|
211
211
|
}): Promise<RoomSpaceRole>;
|
|
212
212
|
export declare function deleteSpaceRole(config: RoomSocialConfig, spaceId: string, roleId: string): Promise<void>;
|
|
213
|
+
/** A channel webhook: the Discord-compatible ingest capability. The
|
|
214
|
+
* posting URL is `<chat-api>/webhooks/<webhookId>/<token>`; the token is
|
|
215
|
+
* the whole credential, so treat listed webhooks like secrets. */
|
|
216
|
+
export interface RoomWebhook {
|
|
217
|
+
webhookId: string;
|
|
218
|
+
room: string;
|
|
219
|
+
name: string;
|
|
220
|
+
avatarUrl: string | null;
|
|
221
|
+
token: string;
|
|
222
|
+
createdBy: string;
|
|
223
|
+
/** Creator's handle at create time (display; createdBy is the id). */
|
|
224
|
+
createdByHandle?: string | null;
|
|
225
|
+
createdAt: string;
|
|
226
|
+
lastUsedAt: string | null;
|
|
227
|
+
deliveryCount: number;
|
|
228
|
+
disabled: boolean;
|
|
229
|
+
}
|
|
230
|
+
export declare function listRoomWebhooks(config: RoomSocialConfig, room: string): Promise<RoomWebhook[]>;
|
|
231
|
+
export declare function createRoomWebhook(config: RoomSocialConfig, room: string, input: {
|
|
232
|
+
name: string;
|
|
233
|
+
avatarUrl?: string | null;
|
|
234
|
+
}): Promise<RoomWebhook>;
|
|
235
|
+
export declare function deleteRoomWebhook(config: RoomSocialConfig, room: string, webhookId: string): Promise<void>;
|
|
236
|
+
/** Rotate the capability: the old URL dies immediately. */
|
|
237
|
+
export declare function regenerateRoomWebhookToken(config: RoomSocialConfig, room: string, webhookId: string): Promise<RoomWebhook>;
|
|
213
238
|
export declare function setSpaceMemberRoles(config: RoomSocialConfig, spaceId: string, targetUserId: string, roleIds: string[]): Promise<{
|
|
214
239
|
spaceId: string;
|
|
215
240
|
userId: string;
|
|
@@ -368,6 +393,24 @@ export declare function markRoomRepliesRead(config: RoomSocialConfig, options?:
|
|
|
368
393
|
room?: string;
|
|
369
394
|
before?: string | Date;
|
|
370
395
|
}): Promise<RoomRepliesReadResult>;
|
|
396
|
+
/**
|
|
397
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
398
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
399
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
400
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
401
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
402
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
403
|
+
*/
|
|
404
|
+
export declare function listRoomNotifications(config: RoomSocialConfig, options?: {
|
|
405
|
+
limit?: number | undefined;
|
|
406
|
+
/** Feed a page's lastCursor back here (explicit undefined reads as
|
|
407
|
+
* "first page", so `page.lastCursor ?? undefined` composes under
|
|
408
|
+
* exactOptionalPropertyTypes). */
|
|
409
|
+
cursor?: string | undefined;
|
|
410
|
+
cursorId?: string | undefined;
|
|
411
|
+
unread?: boolean | undefined;
|
|
412
|
+
signal?: AbortSignal | undefined;
|
|
413
|
+
}): Promise<RoomNotificationInboxResult>;
|
|
371
414
|
export declare function suggestRooms(config: RoomSocialConfig, request: RoomSuggestionRequest, options?: {
|
|
372
415
|
signal?: AbortSignal;
|
|
373
416
|
}): Promise<RoomSuggestion[]>;
|
|
@@ -377,6 +420,9 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
|
|
|
377
420
|
signal?: AbortSignal;
|
|
378
421
|
formData?: FormData;
|
|
379
422
|
headers?: Record<string, string>;
|
|
423
|
+
/** Fetch cache mode; token-bearing reads pass "no-store" so posting
|
|
424
|
+
* credentials never persist in a browser HTTP cache. */
|
|
425
|
+
cache?: RequestCache;
|
|
380
426
|
}): Promise<T>;
|
|
381
427
|
export declare function cleanUsername(username: string): string;
|
|
382
428
|
export declare function cleanRoomId(room: string): string;
|
package/dist/social-client.js
CHANGED
|
@@ -378,6 +378,36 @@ export async function updateSpaceRole(config, spaceId, roleId, patch) {
|
|
|
378
378
|
export async function deleteSpaceRole(config, spaceId, roleId) {
|
|
379
379
|
await socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/roles/${encodeURIComponent(roleId)}`, { method: "DELETE" });
|
|
380
380
|
}
|
|
381
|
+
export async function listRoomWebhooks(config, room) {
|
|
382
|
+
const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`, { cache: "no-store" });
|
|
383
|
+
return result.webhooks ?? [];
|
|
384
|
+
}
|
|
385
|
+
export async function createRoomWebhook(config, room, input) {
|
|
386
|
+
const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`, { method: "POST", body: input });
|
|
387
|
+
if (!result.webhook)
|
|
388
|
+
throw new Error("Webhook create returned no webhook");
|
|
389
|
+
return result.webhook;
|
|
390
|
+
}
|
|
391
|
+
/** Store-minted webhook ids are `wh_<hex>`. Anything else is rejected
|
|
392
|
+
* BEFORE it reaches a URL: encodeURIComponent does not escape dots, and
|
|
393
|
+
* a `..` segment would normalize `/webhooks/..` onto the parent room
|
|
394
|
+
* route, turning a webhook delete into a channel archive. */
|
|
395
|
+
function assertWebhookId(webhookId) {
|
|
396
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(webhookId)) {
|
|
397
|
+
throw new Error(`Invalid webhook id: ${JSON.stringify(webhookId)}`);
|
|
398
|
+
}
|
|
399
|
+
return webhookId;
|
|
400
|
+
}
|
|
401
|
+
export async function deleteRoomWebhook(config, room, webhookId) {
|
|
402
|
+
await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}`, { method: "DELETE" });
|
|
403
|
+
}
|
|
404
|
+
/** Rotate the capability: the old URL dies immediately. */
|
|
405
|
+
export async function regenerateRoomWebhookToken(config, room, webhookId) {
|
|
406
|
+
const result = await socialRequest(config, `/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}/regenerate`, { method: "POST" });
|
|
407
|
+
if (!result.webhook)
|
|
408
|
+
throw new Error("Webhook regenerate returned no webhook");
|
|
409
|
+
return result.webhook;
|
|
410
|
+
}
|
|
381
411
|
export async function setSpaceMemberRoles(config, spaceId, targetUserId, roleIds) {
|
|
382
412
|
return socialRequest(config, `/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(targetUserId)}/roles`, { method: "PUT", body: { roleIds } });
|
|
383
413
|
}
|
|
@@ -592,6 +622,35 @@ export async function markRoomRepliesRead(config, options = {}) {
|
|
|
592
622
|
});
|
|
593
623
|
return { markedRead: result.markedRead ?? 0 };
|
|
594
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
627
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
628
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
629
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
630
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
631
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
632
|
+
*/
|
|
633
|
+
export async function listRoomNotifications(config, options = {}) {
|
|
634
|
+
const params = new URLSearchParams();
|
|
635
|
+
if (options.limit !== undefined)
|
|
636
|
+
params.set("limit", String(options.limit));
|
|
637
|
+
if (options.cursor !== undefined)
|
|
638
|
+
params.set("cursor", options.cursor);
|
|
639
|
+
if (options.cursorId !== undefined)
|
|
640
|
+
params.set("cursorId", options.cursorId);
|
|
641
|
+
if (options.unread !== undefined)
|
|
642
|
+
params.set("unread", String(options.unread));
|
|
643
|
+
const init = {};
|
|
644
|
+
if (options.signal)
|
|
645
|
+
init.signal = options.signal;
|
|
646
|
+
const result = await socialRequest(config, `/users/notifications${params.size ? `?${params.toString()}` : ""}`, init);
|
|
647
|
+
return {
|
|
648
|
+
notifications: result.notifications ?? [],
|
|
649
|
+
hasMore: result.hasMore ?? false,
|
|
650
|
+
lastCursor: result.lastCursor ?? null,
|
|
651
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
595
654
|
export async function suggestRooms(config, request, options = {}) {
|
|
596
655
|
const init = {
|
|
597
656
|
method: "POST",
|
|
@@ -607,6 +666,7 @@ export async function socialRequest(config, path, init = {}) {
|
|
|
607
666
|
const hasJsonBody = init.body !== undefined;
|
|
608
667
|
const res = await fetch(url, {
|
|
609
668
|
method: init.method ?? "GET",
|
|
669
|
+
...(init.cache ? { cache: init.cache } : {}),
|
|
610
670
|
headers: {
|
|
611
671
|
Authorization: `Bearer ${config.apiKey}`,
|
|
612
672
|
Accept: "application/json",
|
package/dist/social-types.d.ts
CHANGED
|
@@ -171,6 +171,44 @@ export interface RoomRepliesResult {
|
|
|
171
171
|
export interface RoomRepliesReadResult {
|
|
172
172
|
markedRead: number;
|
|
173
173
|
}
|
|
174
|
+
/** The room kinds the chat notification inbox returns (the store's
|
|
175
|
+
* ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
|
|
176
|
+
* follow is a push channel, not a badge source. */
|
|
177
|
+
export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
|
|
178
|
+
/** Shared room payload on a notification row, wire names verbatim from the
|
|
179
|
+
* store's notification schema (room_message_data). `preview` follows the
|
|
180
|
+
* push body rules (140 chars, empty for sealed content); `hash` is the
|
|
181
|
+
* client deep link. */
|
|
182
|
+
export interface RoomNotificationMessageData {
|
|
183
|
+
kind: "dm" | "mention" | "reply" | "follow" | "keyword";
|
|
184
|
+
room_id?: string;
|
|
185
|
+
room_name?: string;
|
|
186
|
+
space_id?: string;
|
|
187
|
+
conversation_id?: string;
|
|
188
|
+
seq?: number;
|
|
189
|
+
from_handle: string;
|
|
190
|
+
preview: string;
|
|
191
|
+
hash: string;
|
|
192
|
+
}
|
|
193
|
+
/** One chat notification inbox row: the store's explicit field pick
|
|
194
|
+
* (snake_case wire names, dates as ISO strings, room_message_data only
|
|
195
|
+
* when the row carries one). */
|
|
196
|
+
export interface RoomNotificationInboxItem {
|
|
197
|
+
id: string;
|
|
198
|
+
type: RoomNotificationKind;
|
|
199
|
+
created_at: string;
|
|
200
|
+
read_at: string | null;
|
|
201
|
+
room_message_data?: RoomNotificationMessageData;
|
|
202
|
+
}
|
|
203
|
+
/** A newest-first inbox page plus the composite cursor naming the last row
|
|
204
|
+
* returned; feed lastCursor and lastCursorId back together to request the
|
|
205
|
+
* next page. Both are null on an empty page. */
|
|
206
|
+
export interface RoomNotificationInboxResult {
|
|
207
|
+
notifications: RoomNotificationInboxItem[];
|
|
208
|
+
hasMore: boolean;
|
|
209
|
+
lastCursor: string | null;
|
|
210
|
+
lastCursorId: string | null;
|
|
211
|
+
}
|
|
174
212
|
/** A living markdown doc scoped to a space (head state; content on demand). */
|
|
175
213
|
export interface RoomDoc {
|
|
176
214
|
docId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmarket/rooms-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "OM Rooms protocol client: wire types, WebSocket + REST clients, and chat view-models. Browser-safe (no node:/bun: imports).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -17,12 +17,7 @@
|
|
|
17
17
|
"bun": ">=1.3.0",
|
|
18
18
|
"node": ">=22"
|
|
19
19
|
},
|
|
20
|
-
"files": [
|
|
21
|
-
"src",
|
|
22
|
-
"dist",
|
|
23
|
-
"README.md",
|
|
24
|
-
"LICENSE"
|
|
25
|
-
],
|
|
20
|
+
"files": ["src", "dist", "README.md", "LICENSE"],
|
|
26
21
|
"publishConfig": {
|
|
27
22
|
"access": "public"
|
|
28
23
|
},
|
package/src/agent/armed-mode.ts
CHANGED
|
@@ -164,6 +164,17 @@ export function ownSeqsOf(entries: ReadonlyArray<RoomLedgerEntry>, ownUserId: st
|
|
|
164
164
|
* to the user. Agent posts, system entries, the user's own posts, and
|
|
165
165
|
* non-mentions never trigger. Pure.
|
|
166
166
|
*/
|
|
167
|
+
/** Webhook-ingested entries are machine posts even though isAgent is
|
|
168
|
+
* false (the flag marks agent USERS). They must never count as the human
|
|
169
|
+
* in any autonomy gate: a webhook @mention cannot start an unreviewed
|
|
170
|
+
* autonomous turn, and webhook chatter cannot satisfy the
|
|
171
|
+
* human-activity brake. */
|
|
172
|
+
function isWebhookEntry(entry: RoomLedgerEntry): boolean {
|
|
173
|
+
return Boolean(
|
|
174
|
+
(entry.metadata as { webhook?: unknown } | undefined)?.webhook,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
167
178
|
export function isTriggeringEntry(
|
|
168
179
|
entry: RoomLedgerEntry,
|
|
169
180
|
ownHandle: string,
|
|
@@ -172,6 +183,7 @@ export function isTriggeringEntry(
|
|
|
172
183
|
): boolean {
|
|
173
184
|
if (entry.type !== RoomLedgerEntryType.POST) return false;
|
|
174
185
|
if (entry.isAgent) return false;
|
|
186
|
+
if (isWebhookEntry(entry)) return false;
|
|
175
187
|
if (!entry.text?.trim()) return false;
|
|
176
188
|
// Never trigger on our own message (we post as this handle/userId).
|
|
177
189
|
const fromHandle = normalizeHandle(entry.handle).toLowerCase();
|
|
@@ -379,6 +391,7 @@ export function isHumanResetEntry(
|
|
|
379
391
|
): boolean {
|
|
380
392
|
if (entry.type !== RoomLedgerEntryType.POST) return false;
|
|
381
393
|
if (entry.isAgent) return false;
|
|
394
|
+
if (isWebhookEntry(entry)) return false;
|
|
382
395
|
if (entry.userId === ownUserId) return false;
|
|
383
396
|
const fromHandle = normalizeHandle(entry.handle).toLowerCase();
|
|
384
397
|
const me = normalizeHandle(ownHandle).toLowerCase();
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { getProviders, type KnownProvider } from "@earendil-works/pi-ai";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/** Every chat surface, as a runtime list. The `SurfaceId` union derives from
|
|
4
|
+
* this literal so per-surface policy checklists (packages/cli/test/
|
|
5
|
+
* harness-budgets.test.ts) can iterate the surfaces at runtime while a new
|
|
6
|
+
* entry still breaks type-exhaustive tables at compile time. Adding a
|
|
7
|
+
* surface here requires classifying it in EVERY policy set (see AGENTS.md,
|
|
8
|
+
* "Harness invariants"). */
|
|
9
|
+
export const SURFACE_IDS = ["cli", "telegram", "discord", "slack", "webui"] as const;
|
|
10
|
+
|
|
11
|
+
export type SurfaceId = (typeof SURFACE_IDS)[number];
|
|
4
12
|
|
|
5
13
|
export interface AgentToolCall {
|
|
6
14
|
id: string;
|
|
@@ -79,7 +87,10 @@ export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
|
|
|
79
87
|
export function stringifyToolContent(value: unknown): string {
|
|
80
88
|
if (value === undefined) return "";
|
|
81
89
|
if (typeof value === "string") return value;
|
|
82
|
-
|
|
90
|
+
// Compact on purpose: this string is LLM input, and 2-space pretty-printing
|
|
91
|
+
// inflated every structured tool result by roughly a fifth in tokens for
|
|
92
|
+
// zero model benefit.
|
|
93
|
+
return JSON.stringify(value);
|
|
83
94
|
}
|
|
84
95
|
|
|
85
96
|
export function parseToolArguments(raw: unknown): unknown {
|
|
@@ -23,6 +23,9 @@ export interface SidebarRoom {
|
|
|
23
23
|
headSeq: number | null;
|
|
24
24
|
lastReadSeq: number | null;
|
|
25
25
|
unread: number;
|
|
26
|
+
/** Unread mention count from the latest read-state snapshot: the server's
|
|
27
|
+
* number verbatim, 0 when the snapshot carried none for this room. */
|
|
28
|
+
mentionUnread: number;
|
|
26
29
|
joined: boolean;
|
|
27
30
|
/** Topics dial from room meta. Absent reads as "allowed" since the
|
|
28
31
|
* 2026-07-12 dial-default flip; explicit "off" keeps topics inert. */
|
|
@@ -40,6 +43,13 @@ export interface SidebarSnapshot {
|
|
|
40
43
|
dms: SidebarDm[];
|
|
41
44
|
popular: SidebarRoom[];
|
|
42
45
|
globalReplyUnread: number;
|
|
46
|
+
/** Per-room unread mention counts exactly as the server sent them in the
|
|
47
|
+
* latest read-state snapshot: sparse (zero-count rooms absent) and
|
|
48
|
+
* global (rooms outside the current listings may appear). Empty when the
|
|
49
|
+
* snapshot carried none; a fresh snapshot replaces it wholesale. */
|
|
50
|
+
mentionCounts: Record<string, number>;
|
|
51
|
+
/** User-wide unread mention total from the same snapshot. */
|
|
52
|
+
mentionTotal: number;
|
|
43
53
|
fetchedAt: number;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -49,6 +59,8 @@ export const EMPTY_SIDEBAR_SNAPSHOT: SidebarSnapshot = {
|
|
|
49
59
|
dms: [],
|
|
50
60
|
popular: [],
|
|
51
61
|
globalReplyUnread: 0,
|
|
62
|
+
mentionCounts: {},
|
|
63
|
+
mentionTotal: 0,
|
|
52
64
|
fetchedAt: 0,
|
|
53
65
|
};
|
|
54
66
|
|
|
@@ -65,10 +77,23 @@ export function computeRoomUnread(input: {
|
|
|
65
77
|
return Math.max(0, input.headSeq - input.lastReadSeq);
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
/** A READ_STATE reply that carries the additive mention decorations beside
|
|
81
|
+
* the cursor map (RoomReadStatePayload satisfies this shape). Old
|
|
82
|
+
* fetchReadState implementations returning the bare cursor Record keep
|
|
83
|
+
* working; mention fields then read as zero. */
|
|
84
|
+
export interface SidebarReadState {
|
|
85
|
+
readState: Record<string, number>;
|
|
86
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
87
|
+
mentionTotal?: number | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
68
90
|
export function buildSidebarSnapshot(input: {
|
|
69
91
|
rooms: RoomMeta[];
|
|
70
92
|
spaces: RoomSpace[];
|
|
71
93
|
readState: Record<string, number>;
|
|
94
|
+
/** Server numbers pass through verbatim; merge policy is the caller's. */
|
|
95
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
96
|
+
mentionTotal?: number | undefined;
|
|
72
97
|
dms: SidebarDm[];
|
|
73
98
|
globalReplyUnread: number;
|
|
74
99
|
joinedUnread: (roomName: string) => number | null;
|
|
@@ -91,6 +116,14 @@ export function buildSidebarSnapshot(input: {
|
|
|
91
116
|
headSeq,
|
|
92
117
|
lastReadSeq,
|
|
93
118
|
unread: computeRoomUnread({ joinedUnread, headSeq, lastReadSeq }),
|
|
119
|
+
// Own-key guard like the readState read above: rooms named after
|
|
120
|
+
// Object.prototype members must read 0 from a sparse map, not NaN.
|
|
121
|
+
mentionUnread: Math.max(
|
|
122
|
+
0,
|
|
123
|
+
input.mentionCounts && Object.hasOwn(input.mentionCounts, name)
|
|
124
|
+
? (input.mentionCounts[name] ?? 0)
|
|
125
|
+
: 0,
|
|
126
|
+
),
|
|
94
127
|
// Joined = an open context OR a server read cursor (you only get a
|
|
95
128
|
// cursor by having been in the room). This is what decides whether a
|
|
96
129
|
// room lives in your channel tree or in the POPULAR discovery shelf.
|
|
@@ -154,6 +187,8 @@ export function buildSidebarSnapshot(input: {
|
|
|
154
187
|
dms: input.dms,
|
|
155
188
|
popular,
|
|
156
189
|
globalReplyUnread: Math.max(0, input.globalReplyUnread),
|
|
190
|
+
mentionCounts: input.mentionCounts ?? {},
|
|
191
|
+
mentionTotal: Math.max(0, input.mentionTotal ?? 0),
|
|
157
192
|
fetchedAt: input.now,
|
|
158
193
|
};
|
|
159
194
|
}
|
|
@@ -161,13 +196,39 @@ export function buildSidebarSnapshot(input: {
|
|
|
161
196
|
export interface SidebarModelDeps {
|
|
162
197
|
fetchRooms: () => Promise<RoomMeta[]>;
|
|
163
198
|
fetchSpaces: () => Promise<RoomSpace[]>;
|
|
164
|
-
|
|
199
|
+
/** Either the bare cursor Record (the 0.2.0 shape) or a SidebarReadState
|
|
200
|
+
* whose mention decorations feed the snapshot's mention fields. */
|
|
201
|
+
fetchReadState: (roomNames: string[]) => Promise<Record<string, number> | SidebarReadState>;
|
|
165
202
|
fetchDms: () => Promise<SidebarDm[]>;
|
|
166
203
|
fetchGlobalReplyUnread: () => Promise<number>;
|
|
167
204
|
joinedUnread: (roomName: string) => number | null;
|
|
168
205
|
now?: () => number;
|
|
169
206
|
}
|
|
170
207
|
|
|
208
|
+
/** A bare cursor Record maps room name to seq (numbers only), so an object
|
|
209
|
+
* under the "readState" key can only be the snapshot shape, even for a
|
|
210
|
+
* room literally named "readState" (its cursor would be a number). */
|
|
211
|
+
function normalizeSidebarReadState(reply: Record<string, number> | SidebarReadState): {
|
|
212
|
+
readState: Record<string, number>;
|
|
213
|
+
mentionCounts: Record<string, number> | undefined;
|
|
214
|
+
mentionTotal: number | undefined;
|
|
215
|
+
} {
|
|
216
|
+
const nested = (reply as SidebarReadState).readState;
|
|
217
|
+
if (typeof nested === "object" && nested !== null) {
|
|
218
|
+
const snapshot = reply as SidebarReadState;
|
|
219
|
+
return {
|
|
220
|
+
readState: snapshot.readState,
|
|
221
|
+
mentionCounts: snapshot.mentionCounts,
|
|
222
|
+
mentionTotal: snapshot.mentionTotal,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
readState: reply as Record<string, number>,
|
|
227
|
+
mentionCounts: undefined,
|
|
228
|
+
mentionTotal: undefined,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
171
232
|
export class SidebarModel {
|
|
172
233
|
private snapshotValue: SidebarSnapshot = EMPTY_SIDEBAR_SNAPSHOT;
|
|
173
234
|
private inflight: Promise<SidebarSnapshot> | null = null;
|
|
@@ -243,13 +304,16 @@ export class SidebarModel {
|
|
|
243
304
|
this.deps.fetchDms().catch(() => [] as SidebarDm[]),
|
|
244
305
|
this.deps.fetchGlobalReplyUnread().catch(() => 0),
|
|
245
306
|
]);
|
|
246
|
-
const
|
|
307
|
+
const readStateReply = await this.deps
|
|
247
308
|
.fetchReadState(rooms.map((room) => room.name))
|
|
248
309
|
.catch(() => ({}) as Record<string, number>);
|
|
310
|
+
const { readState, mentionCounts, mentionTotal } = normalizeSidebarReadState(readStateReply);
|
|
249
311
|
return buildSidebarSnapshot({
|
|
250
312
|
rooms,
|
|
251
313
|
spaces,
|
|
252
314
|
readState,
|
|
315
|
+
mentionCounts,
|
|
316
|
+
mentionTotal,
|
|
253
317
|
dms,
|
|
254
318
|
globalReplyUnread,
|
|
255
319
|
joinedUnread: this.deps.joinedUnread,
|
|
@@ -259,7 +259,7 @@ export interface RoomLedgerEntry {
|
|
|
259
259
|
pinnedAt?: string | number | Date | null | undefined;
|
|
260
260
|
attachments?: RoomAttachment[] | undefined;
|
|
261
261
|
roomRole?: RoomRole | null | undefined;
|
|
262
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
262
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
export type RoomForwardedFrom =
|
|
@@ -278,6 +278,19 @@ export interface RoomForwardMetadata {
|
|
|
278
278
|
forwardedFrom: RoomForwardedFrom;
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/** Sender identity for messages posted through a channel webhook (the
|
|
282
|
+
* Discord-compatible ingest). Entries carrying it render the webhook's
|
|
283
|
+
* name and avatar with an APP-style badge; the entry's handle mirrors
|
|
284
|
+
* the display name and its userId is the synthetic `webhook:<id>`, so a
|
|
285
|
+
* webhook can never impersonate a member. */
|
|
286
|
+
export interface RoomWebhookMetadata {
|
|
287
|
+
webhook: {
|
|
288
|
+
id: string;
|
|
289
|
+
name: string;
|
|
290
|
+
avatarUrl?: string | undefined;
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
281
294
|
export interface RoomAttachment {
|
|
282
295
|
/**
|
|
283
296
|
* Object key under room-attachments/; clients sign it at render time.
|
|
@@ -370,6 +383,16 @@ export interface RoomReadStatePayload {
|
|
|
370
383
|
* Present when the store surfaces it and on ROOM_SET_ATTENTION acks
|
|
371
384
|
* (which reply READ_STATE scoped to the one room, readState empty). */
|
|
372
385
|
attention?: Record<string, RoomTopicAttention> | undefined;
|
|
386
|
+
/** Unread mention counts by room, server-computed from the notify
|
|
387
|
+
* worker's ROOM_MENTION rows so badges survive reload and clear through
|
|
388
|
+
* the ack path. Unlike readState/attention the map is SPARSE AND
|
|
389
|
+
* GLOBAL: every room with a nonzero count appears (requested or not),
|
|
390
|
+
* zero-count rooms are absent. Missing on servers older than the
|
|
391
|
+
* mentions-completion store. */
|
|
392
|
+
mentionCounts?: Record<string, number> | undefined;
|
|
393
|
+
/** User-wide unread mention total (drives the app badge). Rides beside
|
|
394
|
+
* mentionCounts; missing on older servers. */
|
|
395
|
+
mentionTotal?: number | undefined;
|
|
373
396
|
requestId?: string | undefined;
|
|
374
397
|
}
|
|
375
398
|
|
|
@@ -583,7 +606,7 @@ export interface RoomDmMessage {
|
|
|
583
606
|
createdAt?: string | number | undefined;
|
|
584
607
|
/** Sealed DM: content is '' and the opaque envelope rides here. */
|
|
585
608
|
secret?: RoomLedgerSecretPayload | null | undefined;
|
|
586
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
609
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
587
610
|
/** Legacy public chat-image URL (renders as-is, no signing). */
|
|
588
611
|
imageUrl?: string | null | undefined;
|
|
589
612
|
/** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
|
package/src/social-client.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
RoomFollowResult,
|
|
19
19
|
RoomFriend,
|
|
20
20
|
RoomFriendRequests,
|
|
21
|
+
RoomNotificationInboxResult,
|
|
21
22
|
RoomPublicProfile,
|
|
22
23
|
RoomRepliesReadResult,
|
|
23
24
|
RoomRepliesResult,
|
|
@@ -46,6 +47,10 @@ export type {
|
|
|
46
47
|
RoomFriend,
|
|
47
48
|
RoomFriendRequest,
|
|
48
49
|
RoomFriendRequests,
|
|
50
|
+
RoomNotificationInboxItem,
|
|
51
|
+
RoomNotificationInboxResult,
|
|
52
|
+
RoomNotificationKind,
|
|
53
|
+
RoomNotificationMessageData,
|
|
49
54
|
RoomPublicProfile,
|
|
50
55
|
RoomRepliesReadResult,
|
|
51
56
|
RoomRepliesResult,
|
|
@@ -744,6 +749,88 @@ export async function deleteSpaceRole(
|
|
|
744
749
|
);
|
|
745
750
|
}
|
|
746
751
|
|
|
752
|
+
/** A channel webhook: the Discord-compatible ingest capability. The
|
|
753
|
+
* posting URL is `<chat-api>/webhooks/<webhookId>/<token>`; the token is
|
|
754
|
+
* the whole credential, so treat listed webhooks like secrets. */
|
|
755
|
+
export interface RoomWebhook {
|
|
756
|
+
webhookId: string;
|
|
757
|
+
room: string;
|
|
758
|
+
name: string;
|
|
759
|
+
avatarUrl: string | null;
|
|
760
|
+
token: string;
|
|
761
|
+
createdBy: string;
|
|
762
|
+
/** Creator's handle at create time (display; createdBy is the id). */
|
|
763
|
+
createdByHandle?: string | null;
|
|
764
|
+
createdAt: string;
|
|
765
|
+
lastUsedAt: string | null;
|
|
766
|
+
deliveryCount: number;
|
|
767
|
+
disabled: boolean;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
export async function listRoomWebhooks(
|
|
771
|
+
config: RoomSocialConfig,
|
|
772
|
+
room: string,
|
|
773
|
+
): Promise<RoomWebhook[]> {
|
|
774
|
+
const result = await socialRequest<{ webhooks?: RoomWebhook[] }>(
|
|
775
|
+
config,
|
|
776
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`,
|
|
777
|
+
{ cache: "no-store" },
|
|
778
|
+
);
|
|
779
|
+
return result.webhooks ?? [];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
export async function createRoomWebhook(
|
|
783
|
+
config: RoomSocialConfig,
|
|
784
|
+
room: string,
|
|
785
|
+
input: { name: string; avatarUrl?: string | null },
|
|
786
|
+
): Promise<RoomWebhook> {
|
|
787
|
+
const result = await socialRequest<{ webhook?: RoomWebhook }>(
|
|
788
|
+
config,
|
|
789
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks`,
|
|
790
|
+
{ method: "POST", body: input },
|
|
791
|
+
);
|
|
792
|
+
if (!result.webhook) throw new Error("Webhook create returned no webhook");
|
|
793
|
+
return result.webhook;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Store-minted webhook ids are `wh_<hex>`. Anything else is rejected
|
|
797
|
+
* BEFORE it reaches a URL: encodeURIComponent does not escape dots, and
|
|
798
|
+
* a `..` segment would normalize `/webhooks/..` onto the parent room
|
|
799
|
+
* route, turning a webhook delete into a channel archive. */
|
|
800
|
+
function assertWebhookId(webhookId: string): string {
|
|
801
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(webhookId)) {
|
|
802
|
+
throw new Error(`Invalid webhook id: ${JSON.stringify(webhookId)}`);
|
|
803
|
+
}
|
|
804
|
+
return webhookId;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
export async function deleteRoomWebhook(
|
|
808
|
+
config: RoomSocialConfig,
|
|
809
|
+
room: string,
|
|
810
|
+
webhookId: string,
|
|
811
|
+
): Promise<void> {
|
|
812
|
+
await socialRequest(
|
|
813
|
+
config,
|
|
814
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}`,
|
|
815
|
+
{ method: "DELETE" },
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/** Rotate the capability: the old URL dies immediately. */
|
|
820
|
+
export async function regenerateRoomWebhookToken(
|
|
821
|
+
config: RoomSocialConfig,
|
|
822
|
+
room: string,
|
|
823
|
+
webhookId: string,
|
|
824
|
+
): Promise<RoomWebhook> {
|
|
825
|
+
const result = await socialRequest<{ webhook?: RoomWebhook }>(
|
|
826
|
+
config,
|
|
827
|
+
`/rooms/${encodeURIComponent(cleanRoomId(room))}/webhooks/${assertWebhookId(webhookId)}/regenerate`,
|
|
828
|
+
{ method: "POST" },
|
|
829
|
+
);
|
|
830
|
+
if (!result.webhook) throw new Error("Webhook regenerate returned no webhook");
|
|
831
|
+
return result.webhook;
|
|
832
|
+
}
|
|
833
|
+
|
|
747
834
|
export async function setSpaceMemberRoles(
|
|
748
835
|
config: RoomSocialConfig,
|
|
749
836
|
spaceId: string,
|
|
@@ -1197,6 +1284,47 @@ export async function markRoomRepliesRead(
|
|
|
1197
1284
|
return { markedRead: result.markedRead ?? 0 };
|
|
1198
1285
|
}
|
|
1199
1286
|
|
|
1287
|
+
/**
|
|
1288
|
+
* The chat-scoped notification inbox (GET /users/notifications): the durable
|
|
1289
|
+
* rows behind mention/reply/dm/keyword pushes, newest first, room kinds only.
|
|
1290
|
+
* Voucher-reachable like the rest of the chat surface (the bearer token may
|
|
1291
|
+
* be an API key or a chat voucher). Pagination is the composite cursor: pass
|
|
1292
|
+
* the previous page's lastCursor and lastCursorId back TOGETHER (the store
|
|
1293
|
+
* rejects one without the other); unread=true filters to unread rows.
|
|
1294
|
+
*/
|
|
1295
|
+
export async function listRoomNotifications(
|
|
1296
|
+
config: RoomSocialConfig,
|
|
1297
|
+
options: {
|
|
1298
|
+
limit?: number | undefined;
|
|
1299
|
+
/** Feed a page's lastCursor back here (explicit undefined reads as
|
|
1300
|
+
* "first page", so `page.lastCursor ?? undefined` composes under
|
|
1301
|
+
* exactOptionalPropertyTypes). */
|
|
1302
|
+
cursor?: string | undefined;
|
|
1303
|
+
cursorId?: string | undefined;
|
|
1304
|
+
unread?: boolean | undefined;
|
|
1305
|
+
signal?: AbortSignal | undefined;
|
|
1306
|
+
} = {},
|
|
1307
|
+
): Promise<RoomNotificationInboxResult> {
|
|
1308
|
+
const params = new URLSearchParams();
|
|
1309
|
+
if (options.limit !== undefined) params.set("limit", String(options.limit));
|
|
1310
|
+
if (options.cursor !== undefined) params.set("cursor", options.cursor);
|
|
1311
|
+
if (options.cursorId !== undefined) params.set("cursorId", options.cursorId);
|
|
1312
|
+
if (options.unread !== undefined) params.set("unread", String(options.unread));
|
|
1313
|
+
const init: { signal?: AbortSignal } = {};
|
|
1314
|
+
if (options.signal) init.signal = options.signal;
|
|
1315
|
+
const result = await socialRequest<Partial<RoomNotificationInboxResult>>(
|
|
1316
|
+
config,
|
|
1317
|
+
`/users/notifications${params.size ? `?${params.toString()}` : ""}`,
|
|
1318
|
+
init,
|
|
1319
|
+
);
|
|
1320
|
+
return {
|
|
1321
|
+
notifications: result.notifications ?? [],
|
|
1322
|
+
hasMore: result.hasMore ?? false,
|
|
1323
|
+
lastCursor: result.lastCursor ?? null,
|
|
1324
|
+
lastCursorId: result.lastCursorId ?? null,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1200
1328
|
export async function suggestRooms(
|
|
1201
1329
|
config: RoomSocialConfig,
|
|
1202
1330
|
request: RoomSuggestionRequest,
|
|
@@ -1224,12 +1352,16 @@ export async function socialRequest<T>(
|
|
|
1224
1352
|
signal?: AbortSignal;
|
|
1225
1353
|
formData?: FormData;
|
|
1226
1354
|
headers?: Record<string, string>;
|
|
1355
|
+
/** Fetch cache mode; token-bearing reads pass "no-store" so posting
|
|
1356
|
+
* credentials never persist in a browser HTTP cache. */
|
|
1357
|
+
cache?: RequestCache;
|
|
1227
1358
|
} = {},
|
|
1228
1359
|
): Promise<T> {
|
|
1229
1360
|
const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
|
|
1230
1361
|
const hasJsonBody = init.body !== undefined;
|
|
1231
1362
|
const res = await fetch(url, {
|
|
1232
1363
|
method: init.method ?? "GET",
|
|
1364
|
+
...(init.cache ? { cache: init.cache } : {}),
|
|
1233
1365
|
headers: {
|
|
1234
1366
|
Authorization: `Bearer ${config.apiKey}`,
|
|
1235
1367
|
Accept: "application/json",
|
package/src/social-types.ts
CHANGED
|
@@ -192,6 +192,48 @@ export interface RoomRepliesReadResult {
|
|
|
192
192
|
markedRead: number;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/** The room kinds the chat notification inbox returns (the store's
|
|
196
|
+
* ROOM_INBOX_NOTIFICATION_TYPES). ROOM_FOLLOW rows stay out of the inbox:
|
|
197
|
+
* follow is a push channel, not a badge source. */
|
|
198
|
+
export type RoomNotificationKind = "ROOM_MENTION" | "ROOM_REPLY" | "ROOM_DM" | "ROOM_KEYWORD";
|
|
199
|
+
|
|
200
|
+
/** Shared room payload on a notification row, wire names verbatim from the
|
|
201
|
+
* store's notification schema (room_message_data). `preview` follows the
|
|
202
|
+
* push body rules (140 chars, empty for sealed content); `hash` is the
|
|
203
|
+
* client deep link. */
|
|
204
|
+
export interface RoomNotificationMessageData {
|
|
205
|
+
kind: "dm" | "mention" | "reply" | "follow" | "keyword";
|
|
206
|
+
room_id?: string;
|
|
207
|
+
room_name?: string;
|
|
208
|
+
space_id?: string;
|
|
209
|
+
conversation_id?: string;
|
|
210
|
+
seq?: number;
|
|
211
|
+
from_handle: string;
|
|
212
|
+
preview: string;
|
|
213
|
+
hash: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One chat notification inbox row: the store's explicit field pick
|
|
217
|
+
* (snake_case wire names, dates as ISO strings, room_message_data only
|
|
218
|
+
* when the row carries one). */
|
|
219
|
+
export interface RoomNotificationInboxItem {
|
|
220
|
+
id: string;
|
|
221
|
+
type: RoomNotificationKind;
|
|
222
|
+
created_at: string;
|
|
223
|
+
read_at: string | null;
|
|
224
|
+
room_message_data?: RoomNotificationMessageData;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A newest-first inbox page plus the composite cursor naming the last row
|
|
228
|
+
* returned; feed lastCursor and lastCursorId back together to request the
|
|
229
|
+
* next page. Both are null on an empty page. */
|
|
230
|
+
export interface RoomNotificationInboxResult {
|
|
231
|
+
notifications: RoomNotificationInboxItem[];
|
|
232
|
+
hasMore: boolean;
|
|
233
|
+
lastCursor: string | null;
|
|
234
|
+
lastCursorId: string | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
195
237
|
/** A living markdown doc scoped to a space (head state; content on demand). */
|
|
196
238
|
export interface RoomDoc {
|
|
197
239
|
docId: string;
|