@openmarket/rooms-client 0.5.1 → 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/shared/rooms-protocol.d.ts +14 -2
- package/dist/social-client.d.ts +28 -0
- package/dist/social-client.js +31 -0
- package/package.json +2 -7
- package/src/agent/armed-mode.ts +13 -0
- package/src/agent/clients/common.ts +13 -2
- package/src/shared/rooms-protocol.ts +15 -2
- package/src/social-client.ts +86 -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)
|
|
@@ -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.
|
|
@@ -501,7 +513,7 @@ export interface RoomDmMessage {
|
|
|
501
513
|
createdAt?: string | number | undefined;
|
|
502
514
|
/** Sealed DM: content is '' and the opaque envelope rides here. */
|
|
503
515
|
secret?: RoomLedgerSecretPayload | null | undefined;
|
|
504
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
516
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
505
517
|
/** Legacy public chat-image URL (renders as-is, no signing). */
|
|
506
518
|
imageUrl?: string | null | undefined;
|
|
507
519
|
/** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
|
package/dist/social-client.d.ts
CHANGED
|
@@ -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;
|
|
@@ -395,6 +420,9 @@ export declare function socialRequest<T>(config: RoomSocialConfig, path: string,
|
|
|
395
420
|
signal?: AbortSignal;
|
|
396
421
|
formData?: FormData;
|
|
397
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;
|
|
398
426
|
}): Promise<T>;
|
|
399
427
|
export declare function cleanUsername(username: string): string;
|
|
400
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
|
}
|
|
@@ -636,6 +666,7 @@ export async function socialRequest(config, path, init = {}) {
|
|
|
636
666
|
const hasJsonBody = init.body !== undefined;
|
|
637
667
|
const res = await fetch(url, {
|
|
638
668
|
method: init.method ?? "GET",
|
|
669
|
+
...(init.cache ? { cache: init.cache } : {}),
|
|
639
670
|
headers: {
|
|
640
671
|
Authorization: `Bearer ${config.apiKey}`,
|
|
641
672
|
Accept: "application/json",
|
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 {
|
|
@@ -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.
|
|
@@ -593,7 +606,7 @@ export interface RoomDmMessage {
|
|
|
593
606
|
createdAt?: string | number | undefined;
|
|
594
607
|
/** Sealed DM: content is '' and the opaque envelope rides here. */
|
|
595
608
|
secret?: RoomLedgerSecretPayload | null | undefined;
|
|
596
|
-
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata>) | undefined;
|
|
609
|
+
metadata?: (Record<string, unknown> & Partial<RoomForwardMetadata> & Partial<RoomWebhookMetadata>) | undefined;
|
|
597
610
|
/** Legacy public chat-image URL (renders as-is, no signing). */
|
|
598
611
|
imageUrl?: string | null | undefined;
|
|
599
612
|
/** Private, signed, scanned DM attachments (dm-attachments/ keyspace).
|
package/src/social-client.ts
CHANGED
|
@@ -749,6 +749,88 @@ export async function deleteSpaceRole(
|
|
|
749
749
|
);
|
|
750
750
|
}
|
|
751
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
|
+
|
|
752
834
|
export async function setSpaceMemberRoles(
|
|
753
835
|
config: RoomSocialConfig,
|
|
754
836
|
spaceId: string,
|
|
@@ -1270,12 +1352,16 @@ export async function socialRequest<T>(
|
|
|
1270
1352
|
signal?: AbortSignal;
|
|
1271
1353
|
formData?: FormData;
|
|
1272
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;
|
|
1273
1358
|
} = {},
|
|
1274
1359
|
): Promise<T> {
|
|
1275
1360
|
const url = `${trimTrailingSlash(config.apiUrl)}${path}`;
|
|
1276
1361
|
const hasJsonBody = init.body !== undefined;
|
|
1277
1362
|
const res = await fetch(url, {
|
|
1278
1363
|
method: init.method ?? "GET",
|
|
1364
|
+
...(init.cache ? { cache: init.cache } : {}),
|
|
1279
1365
|
headers: {
|
|
1280
1366
|
Authorization: `Bearer ${config.apiKey}`,
|
|
1281
1367
|
Accept: "application/json",
|