@recursiv/sdk 0.7.45 → 0.7.48
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/README.md +5 -3
- package/dist/client.d.ts +4 -1
- package/dist/client.js +22 -14
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +27 -0
- package/dist/index.d.ts +2 -2
- package/dist/resources/deployments.d.ts +8 -0
- package/dist/resources/discovery-learning.d.ts +2 -2
- package/dist/resources/discovery-learning.js +2 -1
- package/dist/resources/discovery.d.ts +88 -4
- package/dist/resources/discovery.js +217 -4
- package/dist/resources/e2ee.d.ts +20 -1
- package/dist/resources/e2ee.js +12 -1
- package/dist/resources/protocols.d.ts +144 -4
- package/dist/resources/protocols.js +31 -7
- package/llm.md +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
75
75
|
| Resource | Key methods |
|
|
76
76
|
|----------|------------|
|
|
77
77
|
| `r.agents` | `create`, `update`, `chat`, `chatStream`, `grantProjectAccess`, `conversations` |
|
|
78
|
-
| `r.e2ee` | `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
|
|
78
|
+
| `r.e2ee` | `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
|
|
79
79
|
| `r.brain` | `sendMessage` |
|
|
80
80
|
| `r.commands` | `execute`, `gatePrompt` |
|
|
81
81
|
| `r.projectBrain` | `tasks`, `decisions`, `milestones`, `completeTask`, `usage` |
|
|
@@ -514,8 +514,10 @@ const { data: preview } = await r.protocols.prepareNativeReply({
|
|
|
514
514
|
parent: { protocol: 'bluesky', nativeId: selectedPost.nativeId },
|
|
515
515
|
accountIds: [blueskyAccount.id, activityPubAccount.id],
|
|
516
516
|
linkedPostAccountIds: [activityPubAccount.id],
|
|
517
|
+
// Optional, needs each account's `publish_media` capability:
|
|
518
|
+
media: [{ url: uploadedImageUrl, alt: memberWrittenAltText }],
|
|
517
519
|
})
|
|
518
|
-
// Review each delivery's account, protocol, kind, parent and exact wireText.
|
|
520
|
+
// Review each delivery's account, protocol, kind, parent, media and exact wireText.
|
|
519
521
|
// After the user approves selected delivery IDs:
|
|
520
522
|
await r.protocols.submitNativeReply(preview.id, { approve: true, deliveryIds: approvedDeliveryIds })
|
|
521
523
|
```
|
|
@@ -590,7 +592,7 @@ connections or signing authority. Missing configuration or unavailable safety ch
|
|
|
590
592
|
closed. Signed-in members continue using `getPublicConversation` to apply personal
|
|
591
593
|
blocks/mutes as well. All reply and account endpoints still require authentication.
|
|
592
594
|
|
|
593
|
-
Standalone public
|
|
595
|
+
Standalone public publication uses `protocols.prepareNativePost({ clientMutationId, text, accountIds, media? })`, then explicitly approved `submitNativePost`. Keep the same mutation ID after uncertain preparation; previously attempted deliveries are reconciled without resending. `nativePostStatus` and paginated `nativePostHistory` expose private app/user-owned outcomes. Review current own posts with `prepareNativePostRemoval`, then approve exact removal IDs through `removeNativePost`. Check connected-account `publish_text` capabilities; only explicitly enabled apps may publish. Text is sent unchanged, public only, with each provider’s actual limits. Optional `media` is a `NativeMediaInput[]` of at most four already-uploaded Minds images (`{ url, alt?, width?, height?, mimeType? }`, JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment). It additionally requires the account's `publish_media` capability; accounts connected before image permissions existed must reconnect. Pass the member's own `alt` — an omitted one is sent as an empty description, never an invented one — and read `receipt.media` (`{ count, alt_missing }`) after sending. These methods still do not accept a reply parent or caller-supplied authority.
|
|
594
596
|
|
|
595
597
|
For Bluesky accounts advertising `quote`, prepare the same publication with `quote: { protocol: 'bluesky', nativeId: sourceAtUri }`. The server resolves the exact source CID using each connected account, checks quote permission and Minds safety, and rechecks before writing. Review each delivery’s `quote` alongside the account and comment before submitting. Changed or unavailable sources cannot publish; uncertain deliveries reconcile the original record key. ActivityPub/Nostr quotes are not provided by this method. Public post reads include at most one filtered `quote` with `state: 'available'` and `post`, or `state: 'unavailable'` without source content. Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
|
|
596
598
|
|
package/dist/client.d.ts
CHANGED
|
@@ -29,7 +29,10 @@ export declare class HttpClient {
|
|
|
29
29
|
apiKeyAuthHeader(): Record<string, string>;
|
|
30
30
|
private getAuthHeaders;
|
|
31
31
|
private handleResponse;
|
|
32
|
-
|
|
32
|
+
/** `retry: false` answers after one attempt: for a caller with its own deadline, a retried 5xx only adds wait. */
|
|
33
|
+
get<T>(path: string, params?: Record<string, unknown>, options?: {
|
|
34
|
+
retry?: boolean;
|
|
35
|
+
}): Promise<T>;
|
|
33
36
|
post<T>(path: string, body?: unknown, options?: {
|
|
34
37
|
retry?: boolean;
|
|
35
38
|
}): Promise<T>;
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
|
|
1
|
+
import { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, errorBodyFromResponse, httpStatusMessage, } from './errors.js';
|
|
2
2
|
const DEFAULT_BASE_URL = 'https://api.recursiv.io/api/v1';
|
|
3
3
|
export class HttpClient {
|
|
4
4
|
#apiKey;
|
|
@@ -118,8 +118,20 @@ export class HttpClient {
|
|
|
118
118
|
code: 'no_response',
|
|
119
119
|
});
|
|
120
120
|
}
|
|
121
|
+
// From here on the server answered. Every error below carries its HTTP
|
|
122
|
+
// status, so callers can tell a refusal from an unreachable server; only a
|
|
123
|
+
// fetch that produced no response (network failure, abort) is statusless.
|
|
121
124
|
if (response.ok) {
|
|
122
|
-
|
|
125
|
+
try {
|
|
126
|
+
return (await response.json());
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new RecursivError(response.status, {
|
|
130
|
+
type: 'api_error',
|
|
131
|
+
message: `${httpStatusMessage(response.status, response.statusText)}: response body is not valid JSON`,
|
|
132
|
+
code: 'invalid_response',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
123
135
|
}
|
|
124
136
|
let body;
|
|
125
137
|
try {
|
|
@@ -128,16 +140,13 @@ export class HttpClient {
|
|
|
128
140
|
catch {
|
|
129
141
|
throw new RecursivError(response.status, {
|
|
130
142
|
type: 'api_error',
|
|
131
|
-
message:
|
|
143
|
+
message: httpStatusMessage(response.status, response.statusText),
|
|
132
144
|
code: 'unknown_error',
|
|
133
145
|
});
|
|
134
146
|
}
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
-
const err = typeof rawErr === 'string'
|
|
139
|
-
? { type: 'api_error', message: rawErr, code: 'unknown_error' }
|
|
140
|
-
: rawErr;
|
|
147
|
+
// Handles { error: { message, type, code } } (standard), { error: "string" }
|
|
148
|
+
// (legacy dispatcher routes) and any other JSON, which gets a generic body.
|
|
149
|
+
const err = errorBodyFromResponse(response.status, response.statusText, body);
|
|
141
150
|
switch (response.status) {
|
|
142
151
|
case 400:
|
|
143
152
|
throw new ValidationError(err);
|
|
@@ -157,12 +166,11 @@ export class HttpClient {
|
|
|
157
166
|
throw new RecursivError(response.status, err);
|
|
158
167
|
}
|
|
159
168
|
}
|
|
160
|
-
|
|
169
|
+
/** `retry: false` answers after one attempt: for a caller with its own deadline, a retried 5xx only adds wait. */
|
|
170
|
+
async get(path, params, options) {
|
|
161
171
|
const url = this.buildUrl(path, params);
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
headers: this.getAuthHeaders(),
|
|
165
|
-
});
|
|
172
|
+
const request = { method: 'GET', headers: this.getAuthHeaders() };
|
|
173
|
+
const response = await (options?.retry === false ? this.fetchWithTimeout(url, request) : this.fetchWithRetry(url, request));
|
|
166
174
|
return this.handleResponse(response);
|
|
167
175
|
}
|
|
168
176
|
async post(path, body, options) {
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { ApiErrorBody } from './types.js';
|
|
2
|
+
/** `HTTP 403: Forbidden`, or `HTTP 403` when the response has no reason phrase (HTTP/2). */
|
|
3
|
+
export declare function httpStatusMessage(status: number, statusText?: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* Turn whatever JSON an error response carried into a complete error body.
|
|
6
|
+
* The standard shape passes through unchanged; the legacy `{ error: "text" }`
|
|
7
|
+
* shape keeps its text; anything else (no `error`, a null/number/array error,
|
|
8
|
+
* a non-object body) becomes a generic body. A partial error object keeps the
|
|
9
|
+
* fields it has and gains defaults for `type`, `code` and `message`, so the
|
|
10
|
+
* thrown error always has the response status and never a TypeError.
|
|
11
|
+
*/
|
|
12
|
+
export declare function errorBodyFromResponse(status: number, statusText: string | undefined, body: unknown): ApiErrorBody['error'];
|
|
2
13
|
export declare class RecursivError extends Error {
|
|
3
14
|
readonly status: number;
|
|
4
15
|
readonly type: string;
|
package/dist/errors.js
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
/** `HTTP 403: Forbidden`, or `HTTP 403` when the response has no reason phrase (HTTP/2). */
|
|
2
|
+
export function httpStatusMessage(status, statusText) {
|
|
3
|
+
return statusText ? `HTTP ${status}: ${statusText}` : `HTTP ${status}`;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Turn whatever JSON an error response carried into a complete error body.
|
|
7
|
+
* The standard shape passes through unchanged; the legacy `{ error: "text" }`
|
|
8
|
+
* shape keeps its text; anything else (no `error`, a null/number/array error,
|
|
9
|
+
* a non-object body) becomes a generic body. A partial error object keeps the
|
|
10
|
+
* fields it has and gains defaults for `type`, `code` and `message`, so the
|
|
11
|
+
* thrown error always has the response status and never a TypeError.
|
|
12
|
+
*/
|
|
13
|
+
export function errorBodyFromResponse(status, statusText, body) {
|
|
14
|
+
const fallback = { type: 'api_error', code: 'unknown_error', message: httpStatusMessage(status, statusText) };
|
|
15
|
+
const raw = body && typeof body === 'object' && !Array.isArray(body) ? body.error : undefined;
|
|
16
|
+
if (typeof raw === 'string')
|
|
17
|
+
return { ...fallback, message: raw };
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
19
|
+
return fallback;
|
|
20
|
+
const error = raw;
|
|
21
|
+
return {
|
|
22
|
+
...error,
|
|
23
|
+
type: typeof error.type === 'string' ? error.type : fallback.type,
|
|
24
|
+
code: typeof error.code === 'string' ? error.code : fallback.code,
|
|
25
|
+
message: typeof error.message === 'string' ? error.message : fallback.message,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
1
28
|
function composeMessage(body) {
|
|
2
29
|
// Surface the hint inline so LLM callers see the resolution path without
|
|
3
30
|
// having to inspect a structured field. The base message stays first so
|
package/dist/index.d.ts
CHANGED
|
@@ -144,7 +144,7 @@ export { UsersResource } from './resources/users.js';
|
|
|
144
144
|
export { CommunitiesResource } from './resources/communities.js';
|
|
145
145
|
export { ChatResource } from './resources/chat.js';
|
|
146
146
|
export { E2eeResource } from './resources/e2ee.js';
|
|
147
|
-
export type { E2eeDevice, E2eeEnvelope, E2eeEvent } from './resources/e2ee.js';
|
|
147
|
+
export type { E2eeDevice, E2eeEnvelope, E2eeEvent, E2eeKeyPackageStatus } from './resources/e2ee.js';
|
|
148
148
|
export { ProjectsResource } from './resources/projects.js';
|
|
149
149
|
export { AgentsResource, SUPPORTED_MODELS } from './resources/agents.js';
|
|
150
150
|
export { EvidenceResource } from './resources/evidence.js';
|
|
@@ -198,7 +198,7 @@ export type { UploadMediaInput, UploadUrlInput, UploadUrlResult } from './resour
|
|
|
198
198
|
export { WalletResource } from './resources/wallet.js';
|
|
199
199
|
export { ProtocolsResource } from './resources/protocols.js';
|
|
200
200
|
export { PROTOCOL_ADAPTER_MATURITIES, PROTOCOL_CAPABILITY_OPERATIONS, PROTOCOL_CAPABILITY_STATUSES, } from './resources/protocols.js';
|
|
201
|
-
export type { SocialProfile, SocialSubscription, SocialSubscriptionChange, SocialProfilePage, SocialNetwork, SocialPost, SocialConversation, SocialTrend, SocialTrendProvenance, SocialTrends, SocialTrendFeed, SocialPopularProfiles, SocialAuthorFeed, SocialFollowingFeed, SocialFollowingSource, SearchPublicProfilesParams, SearchPublicPostsParams, SocialPostSearch, HostedProtocolConnections, HostedProtocol, HostedProtocolIdentity, HostedNostrChallenge, HostedNostrIdentityProof, NativeNostrPrepareInput, NativeNostrPrepared, NativeNostrAccount, NativeNostrPostState, NativeNostrReceipt, NativeSocialProtocol, FederationIdentity, FederationIdentityView, NativeSocialAccount, NativeSocialCallbackInput, NativeReplyPrepareInput, NativePostPrepareInput, NativePostBatch, NativePostHistory, NativeFollowSourceInput, NativeFollowPrepareInput, NativeFollowTarget, NativeFollowReceipt, NativeFollowState, NativeFollowReview, NativeLikeSourceInput, NativeRepostSourceInput, NativeLikePrepareInput, NativeRepostPrepareInput, NativeLikeTarget, NativeRepostTarget, NativeLikeReceipt, NativeRepostReceipt, NativeLikeState, NativeRepostState, NativeLikeReview, NativeRepostReview, NativeReplyReceipt, NativeReplyBatch, NativeReplyHistory, NativeDelegatedReplyHistoryItem, NativeNostrReplyResult, NativeNostrReplyHistoryItem, NativeReplyRemovalReceipt, NativeReplyRemovalPreview, SocialSourceRef, SocialSourceControl, SocialSourceControlChange, HostedPostPreview, ConnectHostedIdentityInput, PreviewHostedPostInput, PublishHostedPostInput, ListProtocolCandidatesParams, ProtocolAdapterMaturity, ProtocolAdapterMetadata, ProtocolCandidateListResponse, ProtocolCandidatePageMeta, ProtocolCapabilityOperation, ProtocolCapabilityStatus, ProtocolCollectionStatus, ProtocolIngestionReplayResult, ProtocolIngestionStatus, ProtocolQuarantinedCandidate, ProtocolRefreshResult, ProtocolSettings, ProtocolSettingsUpdateResult, ReplayProtocolIngestionJobInput, UpdateSettingsInput as UpdateProtocolSettingsInput, } from './resources/protocols.js';
|
|
201
|
+
export type { SocialProfile, SocialSubscription, SocialSubscriptionChange, SocialProfilePage, SocialNetwork, SocialPost, SocialConversation, SocialTrend, SocialTrendProvenance, SocialTrends, SocialTrendFeed, SocialPopularProfiles, SocialAuthorFeed, SocialFollowingFeed, SocialFollowingSource, SearchPublicProfilesParams, SearchPublicPostsParams, SocialPostSearch, HostedProtocolConnections, HostedProtocol, HostedProtocolIdentity, HostedNostrChallenge, HostedNostrIdentityProof, NativeNostrPrepareInput, NativeNostrPrepared, NativeNostrAccount, NativeNostrPostState, NativeNostrReceipt, NativeSocialProtocol, FederationIdentity, FederationIdentityView, NativeSocialAccount, NativeSocialCallbackInput, NativeReplyPrepareInput, NativePostPrepareInput, NativeMediaInput, NativeMediaItem, NativePostBatch, NativePostHistory, NativeFollowSourceInput, NativeFollowPrepareInput, NativeFollowTarget, NativeFollowReceipt, NativeFollowState, NativeFollowReview, NativeLikeSourceInput, NativeRepostSourceInput, NativeLikePrepareInput, NativeRepostPrepareInput, NativeLikeTarget, NativeRepostTarget, NativeLikeReceipt, NativeRepostReceipt, NativeLikeState, NativeRepostState, NativeLikeReview, NativeRepostReview, NativeReplyReceipt, NativeReplyBatch, NativeReplyHistory, NativeDelegatedReplyHistoryItem, NativeNostrReplyResult, NativeNostrReplyHistoryItem, NativeReplyRemovalReceipt, NativeReplyRemovalPreview, SocialSourceRef, SocialSourceControl, SocialSourceControlChange, HostedPostPreview, ConnectHostedIdentityInput, PreviewHostedPostInput, PublishHostedPostInput, ListProtocolCandidatesParams, ProtocolAdapterMaturity, ProtocolAdapterMetadata, ProtocolCandidateListResponse, ProtocolCandidatePageMeta, ProtocolCapabilityOperation, ProtocolCapabilityStatus, ProtocolCollectionStatus, ProtocolIngestionReplayResult, ProtocolIngestionStatus, ProtocolQuarantinedCandidate, ProtocolRefreshResult, ProtocolSettings, ProtocolSettingsUpdateResult, ReplayProtocolIngestionJobInput, UpdateSettingsInput as UpdateProtocolSettingsInput, } from './resources/protocols.js';
|
|
202
202
|
export { InboxResource } from './resources/inbox.js';
|
|
203
203
|
export { NetworkResource } from './resources/network.js';
|
|
204
204
|
export { SimulatorResource } from './resources/simulator.js';
|
|
@@ -41,6 +41,12 @@ export interface DeleteDeploymentParams {
|
|
|
41
41
|
export interface PrepareProtocolServersInput {
|
|
42
42
|
project_id: string;
|
|
43
43
|
network_id: string;
|
|
44
|
+
/**
|
|
45
|
+
* `production` reserves the stack in the operator's production environment
|
|
46
|
+
* with the API's HOSTED_* settings (handle domain, SMTP, invites, relay name);
|
|
47
|
+
* hostnames must be allow-listed. Default `staging`.
|
|
48
|
+
*/
|
|
49
|
+
target?: 'staging' | 'production';
|
|
44
50
|
hosts: {
|
|
45
51
|
pds: string;
|
|
46
52
|
activitypub: string;
|
|
@@ -52,7 +58,9 @@ export interface PrepareProtocolServersInput {
|
|
|
52
58
|
activitypub: string;
|
|
53
59
|
nostr: string;
|
|
54
60
|
};
|
|
61
|
+
/** A staging PLC, or https://plc.directory for a production PDS whose operator set HOSTED_PDS_PRODUCTION_PLC=true. */
|
|
55
62
|
test_plc_url: string;
|
|
63
|
+
/** Relay publisher whitelist; may be empty for a public production relay. */
|
|
56
64
|
test_publisher_pubkeys: string[];
|
|
57
65
|
}
|
|
58
66
|
export interface PreparedProtocolServers {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DiscoveryLayout } from '../types.js';
|
|
2
|
-
/** Layout presentations plus the recommendation module kinds, so viewers who keep hiding a kind stop seeing it. */
|
|
3
|
-
export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags';
|
|
2
|
+
/** Layout presentations plus the recommendation and radar module kinds, so viewers who keep hiding a kind stop seeing it. */
|
|
3
|
+
export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags' | 'breaking' | 'story';
|
|
4
4
|
type Counts = {
|
|
5
5
|
views: number;
|
|
6
6
|
engagements: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags'];
|
|
1
|
+
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags', 'breaking', 'story'];
|
|
2
2
|
const fresh = (now) => ({ version: 1, startedAt: now, layouts: {
|
|
3
3
|
video: { views: 0, engagements: 0, hides: 0 }, posts: { views: 0, engagements: 0, hides: 0 }, visual: { views: 0, engagements: 0, hides: 0 }, conversation: { views: 0, engagements: 0, hides: 0 },
|
|
4
4
|
thread: { views: 0, engagements: 0, hides: 0 }, longread: { views: 0, engagements: 0, hides: 0 },
|
|
5
5
|
groups: { views: 0, engagements: 0, hides: 0 }, people: { views: 0, engagements: 0, hides: 0 }, tags: { views: 0, engagements: 0, hides: 0 },
|
|
6
|
+
breaking: { views: 0, engagements: 0, hides: 0 }, story: { views: 0, engagements: 0, hides: 0 },
|
|
6
7
|
} });
|
|
7
8
|
/** Local, per-viewer experimental presentation preferences. Stores counts only,
|
|
8
9
|
* never source text, author IDs or inferred traits. It cannot promote content,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DiscoveryLayout, Post } from '../types.js';
|
|
2
2
|
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
3
3
|
import type { PostsResource } from './posts.js';
|
|
4
|
-
import type { ProtocolsResource, SocialNetwork, SocialPost } from './protocols.js';
|
|
4
|
+
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
|
|
5
5
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
6
6
|
export interface DiscoveryGroupCandidate {
|
|
7
7
|
id: string;
|
|
@@ -38,6 +38,66 @@ export interface DiscoveryRecommendationSources {
|
|
|
38
38
|
people?: () => Promise<DiscoveryPersonCandidate[]>;
|
|
39
39
|
tags?: () => Promise<DiscoveryTagCandidate[]>;
|
|
40
40
|
}
|
|
41
|
+
/** An outlet or Hacker News member of a radar cluster: rendered as a link chip, never as a post card. */
|
|
42
|
+
export interface DiscoveryClusterSource {
|
|
43
|
+
kind: 'outlet' | 'hackernews';
|
|
44
|
+
title: string;
|
|
45
|
+
url: string;
|
|
46
|
+
outlet?: string;
|
|
47
|
+
lane?: 'mainstream' | 'independent';
|
|
48
|
+
publishedAt: string;
|
|
49
|
+
}
|
|
50
|
+
/** One story from the app's topic radar, validated for rendering. Every value is a reference to server data; the label is a verbatim headline or topic name. */
|
|
51
|
+
export interface DiscoveryCluster {
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
kind: 'breaking' | 'viral' | 'story';
|
|
55
|
+
theme?: string;
|
|
56
|
+
outletCount: number;
|
|
57
|
+
laneMix: {
|
|
58
|
+
mainstream: number;
|
|
59
|
+
independent: number;
|
|
60
|
+
};
|
|
61
|
+
sources: Record<string, number>;
|
|
62
|
+
velocity: {
|
|
63
|
+
normalized: number;
|
|
64
|
+
};
|
|
65
|
+
newestAt: string;
|
|
66
|
+
lead?: {
|
|
67
|
+
type: 'image' | 'video';
|
|
68
|
+
url: string;
|
|
69
|
+
poster?: string;
|
|
70
|
+
width?: number;
|
|
71
|
+
height?: number;
|
|
72
|
+
memberKey: string;
|
|
73
|
+
};
|
|
74
|
+
links: DiscoveryClusterSource[];
|
|
75
|
+
/** Keys of the minds/social DiscoveryPostItems that belong to the story, in this session's key format. */
|
|
76
|
+
memberKeys: string[];
|
|
77
|
+
}
|
|
78
|
+
/** Reader for the app's radar snapshot; bounded to three seconds and optional, so an older server simply yields no radar module. */
|
|
79
|
+
export type DiscoveryRadarSource = (params: {
|
|
80
|
+
limit?: number;
|
|
81
|
+
category?: string;
|
|
82
|
+
}) => Promise<{
|
|
83
|
+
data: SocialRadar;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* Readers for a story's own member posts. A member found by the radar's search lane is almost never
|
|
87
|
+
* in the page's hot or trend window, so without these a story counts posts it cannot show.
|
|
88
|
+
* Optional, like the radar: an older server answers 404 and the story shows the posts its page carried.
|
|
89
|
+
*/
|
|
90
|
+
export interface DiscoveryRadarMemberSources {
|
|
91
|
+
social?: (params: {
|
|
92
|
+
cluster: string;
|
|
93
|
+
}) => Promise<{
|
|
94
|
+
data: SocialRadarPosts;
|
|
95
|
+
}>;
|
|
96
|
+
minds?: (id: string) => Promise<{
|
|
97
|
+
data: Post;
|
|
98
|
+
}>;
|
|
99
|
+
}
|
|
100
|
+
export type DiscoveryRadarPresentation = 'breaking' | 'story';
|
|
41
101
|
export type DiscoveryPostItem = {
|
|
42
102
|
key: string;
|
|
43
103
|
kind: 'minds';
|
|
@@ -59,15 +119,21 @@ export type DiscoveryItem = DiscoveryPostItem | {
|
|
|
59
119
|
key: string;
|
|
60
120
|
kind: 'tag';
|
|
61
121
|
tag: DiscoveryTagCandidate;
|
|
122
|
+
} | {
|
|
123
|
+
key: string;
|
|
124
|
+
kind: 'cluster';
|
|
125
|
+
cluster: DiscoveryCluster;
|
|
62
126
|
};
|
|
63
127
|
export declare const isDiscoveryPost: (item: DiscoveryItem) => item is DiscoveryPostItem;
|
|
64
128
|
/** At most this many candidates per recommendation module. */
|
|
65
129
|
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
130
|
+
/** Clusters read from the radar per session; the server never holds more. */
|
|
131
|
+
export declare const RADAR_MAX_CLUSTERS = 40;
|
|
66
132
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
67
133
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
68
134
|
export interface DiscoveryModule {
|
|
69
135
|
id: string;
|
|
70
|
-
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind;
|
|
136
|
+
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind | DiscoveryRadarPresentation;
|
|
71
137
|
source: DiscoveryLayout['source'];
|
|
72
138
|
/** Subject of the featured window (a browse-category id or `mixed`), for specific module copy. */
|
|
73
139
|
theme?: string;
|
|
@@ -76,7 +142,7 @@ export interface DiscoveryModule {
|
|
|
76
142
|
root: 'module';
|
|
77
143
|
elements: {
|
|
78
144
|
module: {
|
|
79
|
-
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags';
|
|
145
|
+
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags' | 'DiscoveryBreaking' | 'DiscoveryStory';
|
|
80
146
|
props: {
|
|
81
147
|
itemKeys: string[];
|
|
82
148
|
};
|
|
@@ -102,16 +168,34 @@ export declare class DiscoveryFeedSession {
|
|
|
102
168
|
private category?;
|
|
103
169
|
private learning?;
|
|
104
170
|
private recommend?;
|
|
171
|
+
private members?;
|
|
105
172
|
private streams;
|
|
106
173
|
private pending;
|
|
107
174
|
private seen;
|
|
108
175
|
private page;
|
|
109
176
|
private nextSource;
|
|
110
|
-
|
|
177
|
+
/** The radar snapshot, read once on the first page; null until then. */
|
|
178
|
+
private clusters;
|
|
179
|
+
private radarUsed;
|
|
180
|
+
private radar?;
|
|
181
|
+
/** Page 0's radar read, started before its stream reads rather than after the slowest of them. */
|
|
182
|
+
private radarLoading;
|
|
183
|
+
/** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
|
|
184
|
+
private memberReads;
|
|
185
|
+
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource, members?: DiscoveryRadarMemberSources | undefined);
|
|
111
186
|
retryFailed(): void;
|
|
112
187
|
next(): Promise<DiscoveryFeedPage>;
|
|
113
188
|
private read;
|
|
114
189
|
private readPage;
|
|
190
|
+
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
191
|
+
private radarCluster;
|
|
192
|
+
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
|
|
193
|
+
private pickCluster;
|
|
194
|
+
/** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
|
|
195
|
+
private memberPosts;
|
|
196
|
+
private readMembers;
|
|
197
|
+
private loadRadar;
|
|
198
|
+
private radarModule;
|
|
115
199
|
private recommendation;
|
|
116
200
|
}
|
|
117
201
|
export declare class DiscoveryResource {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
2
2
|
/** At most this many candidates per recommendation module. */
|
|
3
3
|
export const RECOMMENDATION_MAX_ITEMS = 6;
|
|
4
|
+
/** Clusters read from the radar per session; the server never holds more. */
|
|
5
|
+
export const RADAR_MAX_CLUSTERS = 40;
|
|
6
|
+
const RADAR_MAX_MEMBERS = 12;
|
|
7
|
+
/** Minds members read one by one, so a story reads at most this many. */
|
|
8
|
+
const RADAR_MAX_MINDS_READS = 4;
|
|
9
|
+
/** A discovery page holds its own deadline: a retried 5xx from one provider only makes the whole page wait. */
|
|
10
|
+
const NO_RETRY = { retry: false };
|
|
4
11
|
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
5
12
|
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
6
13
|
function recommendationItem(kind, candidate) {
|
|
@@ -21,6 +28,75 @@ function recommendationItem(kind, candidate) {
|
|
|
21
28
|
return null;
|
|
22
29
|
return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
|
|
23
30
|
}
|
|
31
|
+
const RADAR_KINDS = new Set(['breaking', 'viral', 'story']);
|
|
32
|
+
const RADAR_SOCIAL_KINDS = new Set(['bluesky', 'activitypub', 'nostr']);
|
|
33
|
+
const httpUrl = (value) => typeof value === 'string' && value.length <= 4096 && /^https?:\/\//i.test(value);
|
|
34
|
+
const countOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
35
|
+
const dimension = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
36
|
+
/** Validate one radar cluster: bad or unexpected shapes are dropped, never rendered. Member keys are translated to this session's key format. */
|
|
37
|
+
function radarCluster(value) {
|
|
38
|
+
if (!value || typeof value !== 'object')
|
|
39
|
+
return null;
|
|
40
|
+
const c = value;
|
|
41
|
+
if (typeof c.id !== 'string' || !/^[0-9a-f]{64}$/.test(c.id) || !nonEmpty(c.label) || typeof c.kind !== 'string' || !RADAR_KINDS.has(c.kind))
|
|
42
|
+
return null;
|
|
43
|
+
const newestAt = (c.freshness ?? {}).newestAt;
|
|
44
|
+
if (typeof newestAt !== 'string' || !Number.isFinite(Date.parse(newestAt)))
|
|
45
|
+
return null;
|
|
46
|
+
const laneMix = (c.laneMix ?? {});
|
|
47
|
+
const mainstream = countOf(laneMix.mainstream);
|
|
48
|
+
const independent = countOf(laneMix.independent);
|
|
49
|
+
if (mainstream === null || independent === null)
|
|
50
|
+
return null;
|
|
51
|
+
const normalized = c.velocity?.normalized;
|
|
52
|
+
const sources = {};
|
|
53
|
+
if (c.sources && typeof c.sources === 'object')
|
|
54
|
+
for (const [name, count] of Object.entries(c.sources)) {
|
|
55
|
+
const n = countOf(count);
|
|
56
|
+
if (n !== null && /^[a-z]{1,32}$/.test(name))
|
|
57
|
+
sources[name] = n;
|
|
58
|
+
}
|
|
59
|
+
const links = [];
|
|
60
|
+
const memberKeys = [];
|
|
61
|
+
const translated = new Map();
|
|
62
|
+
for (const raw of Array.isArray(c.members) ? c.members.slice(0, RADAR_MAX_MEMBERS) : []) {
|
|
63
|
+
if (!raw || typeof raw !== 'object')
|
|
64
|
+
continue;
|
|
65
|
+
const member = raw;
|
|
66
|
+
if (typeof member.key !== 'string' || !member.key || member.key.length > 4096)
|
|
67
|
+
continue;
|
|
68
|
+
if (member.kind === 'outlet' || member.kind === 'hackernews') {
|
|
69
|
+
if (!nonEmpty(member.title) || !httpUrl(member.url) || typeof member.publishedAt !== 'string')
|
|
70
|
+
continue;
|
|
71
|
+
const outlet = (member.outlet ?? {});
|
|
72
|
+
links.push({ kind: member.kind, title: member.title, url: member.url, ...(nonEmpty(outlet.name) ? { outlet: outlet.name } : {}),
|
|
73
|
+
...(outlet.lane === 'mainstream' || outlet.lane === 'independent' ? { lane: outlet.lane } : {}), publishedAt: member.publishedAt });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
let itemKey = null;
|
|
77
|
+
if (member.kind === 'minds')
|
|
78
|
+
itemKey = key('minds', member.key);
|
|
79
|
+
else if (typeof member.kind === 'string' && RADAR_SOCIAL_KINDS.has(member.kind)) {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(member.key);
|
|
82
|
+
if (Array.isArray(parsed) && parsed.length === 2 && parsed[0] === member.kind && nonEmpty(parsed[1]))
|
|
83
|
+
itemKey = key(parsed[0], parsed[1]);
|
|
84
|
+
}
|
|
85
|
+
catch { /* not a post reference */ }
|
|
86
|
+
}
|
|
87
|
+
if (itemKey && !memberKeys.includes(itemKey)) {
|
|
88
|
+
memberKeys.push(itemKey);
|
|
89
|
+
translated.set(member.key, itemKey);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const media = (c.media?.lead ?? null);
|
|
93
|
+
const lead = media && (media.type === 'image' || media.type === 'video') && httpUrl(media.url) && typeof media.memberKey === 'string' && media.memberKey
|
|
94
|
+
? { type: media.type, url: media.url, ...(httpUrl(media.poster) ? { poster: media.poster } : {}), ...(dimension(media.width) && dimension(media.height) ? { width: dimension(media.width), height: dimension(media.height) } : {}),
|
|
95
|
+
memberKey: translated.get(media.memberKey) ?? media.memberKey } : undefined;
|
|
96
|
+
const theme = typeof c.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(c.theme) ? c.theme : undefined;
|
|
97
|
+
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
|
|
98
|
+
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
99
|
+
}
|
|
24
100
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
25
101
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
26
102
|
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
@@ -47,17 +123,30 @@ export class DiscoveryFeedSession {
|
|
|
47
123
|
category;
|
|
48
124
|
learning;
|
|
49
125
|
recommend;
|
|
126
|
+
members;
|
|
50
127
|
streams;
|
|
51
128
|
pending = null;
|
|
52
129
|
seen = new Set();
|
|
53
130
|
page = 0;
|
|
54
131
|
nextSource = 0;
|
|
55
|
-
|
|
132
|
+
/** The radar snapshot, read once on the first page; null until then. */
|
|
133
|
+
clusters = null;
|
|
134
|
+
radarUsed = new Set();
|
|
135
|
+
radar;
|
|
136
|
+
/** Page 0's radar read, started before its stream reads rather than after the slowest of them. */
|
|
137
|
+
radarLoading = null;
|
|
138
|
+
/** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
|
|
139
|
+
memberReads = new Map();
|
|
140
|
+
constructor(posts, protocols, networks, category, source, learning, recommend, radar, members) {
|
|
56
141
|
this.posts = posts;
|
|
57
142
|
this.protocols = protocols;
|
|
58
143
|
this.category = category;
|
|
59
144
|
this.learning = learning;
|
|
60
145
|
this.recommend = recommend;
|
|
146
|
+
this.members = members;
|
|
147
|
+
// The radar clusters across every source, so a feed narrowed to one network carries none.
|
|
148
|
+
if (!source || source === 'all')
|
|
149
|
+
this.radar = radar;
|
|
61
150
|
const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
|
|
62
151
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
63
152
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
@@ -95,7 +184,7 @@ export class DiscoveryFeedSession {
|
|
|
95
184
|
stream.done = true;
|
|
96
185
|
return { items: [], layout: ordinary, partial: false };
|
|
97
186
|
}
|
|
98
|
-
const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor });
|
|
187
|
+
const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY);
|
|
99
188
|
validatePosts(data);
|
|
100
189
|
if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
|
|
101
190
|
throw new Error('Invalid discovery topic');
|
|
@@ -106,7 +195,7 @@ export class DiscoveryFeedSession {
|
|
|
106
195
|
stream.done = !stream.topics.length;
|
|
107
196
|
return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
|
|
108
197
|
}
|
|
109
|
-
const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true });
|
|
198
|
+
const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
|
|
110
199
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
|
|
111
200
|
throw new Error('Invalid discovery source response');
|
|
112
201
|
if (data.kind === 'topics') {
|
|
@@ -125,6 +214,13 @@ export class DiscoveryFeedSession {
|
|
|
125
214
|
return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
|
|
126
215
|
}
|
|
127
216
|
async readPage() {
|
|
217
|
+
// The radar is one small snapshot read: start it with the page, not after the slowest provider,
|
|
218
|
+
// and start the lead story's member reads the moment the snapshot names it.
|
|
219
|
+
if (this.page === 0 && this.radar && !this.radarLoading) {
|
|
220
|
+
this.radarLoading = this.loadRadar();
|
|
221
|
+
void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
|
|
222
|
+
void this.memberPosts(lead); });
|
|
223
|
+
}
|
|
128
224
|
const active = this.streams.filter(stream => !stream.done && !stream.failed);
|
|
129
225
|
const native = active.find(stream => stream.protocol === 'minds');
|
|
130
226
|
const remote = active.filter(stream => stream.protocol !== 'minds');
|
|
@@ -169,6 +265,23 @@ export class DiscoveryFeedSession {
|
|
|
169
265
|
const modules = [];
|
|
170
266
|
const page = this.page++;
|
|
171
267
|
let remaining = mixed;
|
|
268
|
+
// The radar leads the first page with the top breaking story and returns
|
|
269
|
+
// every second page with the next one. Its member posts move under the
|
|
270
|
+
// story, on this page or on any later one, so the hot stream never repeats them.
|
|
271
|
+
const cluster = mixed.length ? await this.radarCluster(page) : null;
|
|
272
|
+
let clusterItems = [];
|
|
273
|
+
if (cluster) {
|
|
274
|
+
const members = new Set(cluster.memberKeys);
|
|
275
|
+
const onPage = remaining.filter(row => members.has(row.item.key)).map(row => row.item);
|
|
276
|
+
const carried = new Set(onPage.map(item => item.key));
|
|
277
|
+
// Members this page did not carry come from the story's own reads; one an earlier page showed stays shown there.
|
|
278
|
+
const read = (await this.memberPosts(cluster)).filter(item => !carried.has(item.key) && !this.seen.has(item.key));
|
|
279
|
+
// Under the story, posts keep the server's member order: the lead media member first, then by member score.
|
|
280
|
+
clusterItems = [...onPage, ...read].sort((a, b) => cluster.memberKeys.indexOf(a.key) - cluster.memberKeys.indexOf(b.key));
|
|
281
|
+
remaining = remaining.filter(row => !members.has(row.item.key));
|
|
282
|
+
for (const memberKey of cluster.memberKeys)
|
|
283
|
+
this.seen.add(memberKey);
|
|
284
|
+
}
|
|
172
285
|
// One gallery strip per page: it may gather images from the whole page, so a
|
|
173
286
|
// second one would only sweep up leftovers into a stub.
|
|
174
287
|
let galleryMinted = false;
|
|
@@ -208,9 +321,102 @@ export class DiscoveryFeedSession {
|
|
|
208
321
|
const recommendation = modules.length ? await this.recommendation(page) : null;
|
|
209
322
|
if (recommendation)
|
|
210
323
|
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
324
|
+
if (cluster)
|
|
325
|
+
modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
|
|
211
326
|
return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
|
|
212
327
|
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
|
|
213
328
|
}
|
|
329
|
+
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
330
|
+
async radarCluster(page) {
|
|
331
|
+
if (!this.radar)
|
|
332
|
+
return null;
|
|
333
|
+
if (page === 0)
|
|
334
|
+
this.clusters = await (this.radarLoading ?? this.loadRadar());
|
|
335
|
+
if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
|
|
336
|
+
return null;
|
|
337
|
+
const cluster = this.pickCluster(this.clusters, page);
|
|
338
|
+
if (!cluster)
|
|
339
|
+
return null;
|
|
340
|
+
this.radarUsed.add(cluster.id);
|
|
341
|
+
// A viewer who keeps hiding radar stories stops seeing them, through the same bandit as layouts.
|
|
342
|
+
if (this.learning && this.learning.choose(cluster.kind === 'breaking' ? 'breaking' : 'story', page) === 'posts')
|
|
343
|
+
return null;
|
|
344
|
+
return cluster;
|
|
345
|
+
}
|
|
346
|
+
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
|
|
347
|
+
pickCluster(clusters, page) {
|
|
348
|
+
const unused = clusters.filter(candidate => !this.radarUsed.has(candidate.id));
|
|
349
|
+
return page === 0 ? unused.find(candidate => candidate.kind === 'breaking') ?? unused[0] : unused.find(candidate => candidate.kind !== 'breaking');
|
|
350
|
+
}
|
|
351
|
+
/** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
|
|
352
|
+
memberPosts(cluster) {
|
|
353
|
+
let pending = this.memberReads.get(cluster.id);
|
|
354
|
+
if (!pending) {
|
|
355
|
+
pending = this.readMembers(cluster);
|
|
356
|
+
this.memberReads.set(cluster.id, pending);
|
|
357
|
+
}
|
|
358
|
+
return pending;
|
|
359
|
+
}
|
|
360
|
+
async readMembers(cluster) {
|
|
361
|
+
const wanted = new Set(cluster.memberKeys);
|
|
362
|
+
const mindsIds = cluster.memberKeys.flatMap(memberKey => { try {
|
|
363
|
+
const [kind, id] = JSON.parse(memberKey);
|
|
364
|
+
return kind === 'minds' ? [id] : [];
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return [];
|
|
368
|
+
} })
|
|
369
|
+
.slice(0, RADAR_MAX_MINDS_READS);
|
|
370
|
+
const social = this.members?.social && mindsIds.length < cluster.memberKeys.length
|
|
371
|
+
? withDeadline(this.members.social({ cluster: cluster.id }).then(({ data }) => {
|
|
372
|
+
if (!data || data.cluster !== cluster.id || !Array.isArray(data.items))
|
|
373
|
+
return [];
|
|
374
|
+
return data.items.filter(post => post && typeof post.nativeId === 'string' && post.author?.protocol === post.protocol && wanted.has(key(post.protocol, post.nativeId)))
|
|
375
|
+
.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post }));
|
|
376
|
+
}), [])
|
|
377
|
+
: Promise.resolve([]);
|
|
378
|
+
const minds = this.members?.minds
|
|
379
|
+
? Promise.all(mindsIds.map(id => withDeadline(this.members.minds(id).then(({ data }) => data && data.id === id ? [{ key: key('minds', id), kind: 'minds', post: data }] : []), []))).then(rows => rows.flat())
|
|
380
|
+
: Promise.resolve([]);
|
|
381
|
+
const items = [...await social, ...await minds];
|
|
382
|
+
const unique = new Map(items.map(item => [item.key, item]));
|
|
383
|
+
return [...unique.values()];
|
|
384
|
+
}
|
|
385
|
+
async loadRadar() {
|
|
386
|
+
let response;
|
|
387
|
+
let cancel = () => { };
|
|
388
|
+
try {
|
|
389
|
+
response = await Promise.race([this.radar({ limit: RADAR_MAX_CLUSTERS, ...(this.category ? { category: this.category } : {}) }), new Promise((_resolve, reject) => {
|
|
390
|
+
const timer = setTimeout(() => reject(new Error('Radar source timed out')), 3000);
|
|
391
|
+
cancel = () => clearTimeout(timer);
|
|
392
|
+
})]);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
cancel();
|
|
399
|
+
}
|
|
400
|
+
const clusters = response?.data?.clusters;
|
|
401
|
+
if (!Array.isArray(clusters))
|
|
402
|
+
return [];
|
|
403
|
+
const out = [];
|
|
404
|
+
const ids = new Set();
|
|
405
|
+
for (const value of clusters.slice(0, RADAR_MAX_CLUSTERS)) {
|
|
406
|
+
const cluster = radarCluster(value);
|
|
407
|
+
if (cluster && !ids.has(cluster.id)) {
|
|
408
|
+
ids.add(cluster.id);
|
|
409
|
+
out.push(cluster);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
radarModule(page, cluster, posts) {
|
|
415
|
+
const presentation = cluster.kind === 'breaking' ? 'breaking' : 'story';
|
|
416
|
+
const items = [{ key: `radar:${cluster.id}`, kind: 'cluster', cluster }, ...posts];
|
|
417
|
+
return { id: `discovery-${page}-radar-${cluster.id}`, presentation, source: 'fallback', items, ...(cluster.theme && cluster.theme !== 'mixed' ? { theme: cluster.theme } : {}),
|
|
418
|
+
spec: { root: 'module', elements: { module: { type: presentation === 'breaking' ? 'DiscoveryBreaking' : 'DiscoveryStory', props: { itemKeys: items.map(item => item.key) }, children: [] } } } };
|
|
419
|
+
}
|
|
214
420
|
async recommendation(page) {
|
|
215
421
|
const kinds = RECOMMENDATION_KINDS.filter(kind => typeof this.recommend?.[kind] === 'function');
|
|
216
422
|
const kind = kinds[page % Math.max(1, kinds.length)];
|
|
@@ -261,6 +467,13 @@ export class DiscoveryResource {
|
|
|
261
467
|
this.protocols = protocols;
|
|
262
468
|
}
|
|
263
469
|
createFeed(options) {
|
|
264
|
-
|
|
470
|
+
// The radar rides along by default; an older server answers 404 and the session simply shows no story module.
|
|
471
|
+
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) });
|
|
265
472
|
}
|
|
266
473
|
}
|
|
474
|
+
/** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
|
|
475
|
+
function withDeadline(read, fallback, ms = 3000) {
|
|
476
|
+
let timer;
|
|
477
|
+
return Promise.race([read.catch(() => fallback), new Promise(resolve => { timer = setTimeout(() => resolve(fallback), ms); })])
|
|
478
|
+
.finally(() => clearTimeout(timer));
|
|
479
|
+
}
|
package/dist/resources/e2ee.d.ts
CHANGED
|
@@ -30,14 +30,23 @@ export interface E2eeEvent {
|
|
|
30
30
|
welcome: string | null;
|
|
31
31
|
createdAt: string;
|
|
32
32
|
}
|
|
33
|
+
/** One-time key packages a device still has to hand out. */
|
|
34
|
+
export interface E2eeKeyPackageStatus {
|
|
35
|
+
/** Unclaimed packages that have not expired. Once this reaches 0 nobody can add the device to a new conversation. */
|
|
36
|
+
available: number;
|
|
37
|
+
/** Of `available`, how many expire within 7 days. */
|
|
38
|
+
expiringSoon: number;
|
|
39
|
+
}
|
|
33
40
|
/**
|
|
34
41
|
* E2EE v2 ciphertext transport. The server relays envelopes and public
|
|
35
42
|
* credentials only; encryption happens in the shared core on each device.
|
|
36
|
-
* Requires the `e2eeV2` network feature flag
|
|
43
|
+
* Requires the `e2eeV2` network feature flag and a key acting as the account
|
|
44
|
+
* owner (agent-scoped keys are refused). The realm is the network id.
|
|
37
45
|
*/
|
|
38
46
|
export declare class E2eeResource {
|
|
39
47
|
private client;
|
|
40
48
|
constructor(client: HttpClient);
|
|
49
|
+
/** Register this device's public identity and its signed approval chain. */
|
|
41
50
|
registerDevice(input: {
|
|
42
51
|
device_name: string;
|
|
43
52
|
realm: string;
|
|
@@ -50,12 +59,22 @@ export declare class E2eeResource {
|
|
|
50
59
|
data: E2eeDevice[];
|
|
51
60
|
}>;
|
|
52
61
|
revokeDevice(deviceId: string): Promise<SingleResponse<E2eeDevice>>;
|
|
62
|
+
/** Publish one-time key packages for one of your devices; `available` is how many it now has. */
|
|
53
63
|
publishKeyPackages(input: {
|
|
54
64
|
device_id: string;
|
|
55
65
|
packages: string[];
|
|
56
66
|
}): Promise<SingleResponse<{
|
|
57
67
|
published: number;
|
|
68
|
+
available: number;
|
|
58
69
|
}>>;
|
|
70
|
+
/**
|
|
71
|
+
* How many one-time key packages one of your active devices still has to
|
|
72
|
+
* hand out. Publish more when `available` runs low or most of it is
|
|
73
|
+
* `expiringSoon`: at 0, nobody can add the device to a new conversation.
|
|
74
|
+
*/
|
|
75
|
+
keyPackageStatus(params: {
|
|
76
|
+
device_id: string;
|
|
77
|
+
}): Promise<SingleResponse<E2eeKeyPackageStatus>>;
|
|
59
78
|
claimKeyPackage(input: {
|
|
60
79
|
device_id: string;
|
|
61
80
|
target_device_id: string;
|
package/dist/resources/e2ee.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* E2EE v2 ciphertext transport. The server relays envelopes and public
|
|
3
3
|
* credentials only; encryption happens in the shared core on each device.
|
|
4
|
-
* Requires the `e2eeV2` network feature flag
|
|
4
|
+
* Requires the `e2eeV2` network feature flag and a key acting as the account
|
|
5
|
+
* owner (agent-scoped keys are refused). The realm is the network id.
|
|
5
6
|
*/
|
|
6
7
|
export class E2eeResource {
|
|
7
8
|
client;
|
|
8
9
|
constructor(client) {
|
|
9
10
|
this.client = client;
|
|
10
11
|
}
|
|
12
|
+
/** Register this device's public identity and its signed approval chain. */
|
|
11
13
|
registerDevice(input) {
|
|
12
14
|
return this.client.post('/e2ee/devices', input);
|
|
13
15
|
}
|
|
@@ -17,9 +19,18 @@ export class E2eeResource {
|
|
|
17
19
|
revokeDevice(deviceId) {
|
|
18
20
|
return this.client.post(`/e2ee/devices/${encodeURIComponent(deviceId)}/revoke`);
|
|
19
21
|
}
|
|
22
|
+
/** Publish one-time key packages for one of your devices; `available` is how many it now has. */
|
|
20
23
|
publishKeyPackages(input) {
|
|
21
24
|
return this.client.post('/e2ee/key-packages', input);
|
|
22
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* How many one-time key packages one of your active devices still has to
|
|
28
|
+
* hand out. Publish more when `available` runs low or most of it is
|
|
29
|
+
* `expiringSoon`: at 0, nobody can add the device to a new conversation.
|
|
30
|
+
*/
|
|
31
|
+
keyPackageStatus(params) {
|
|
32
|
+
return this.client.get('/e2ee/key-packages/status', params);
|
|
33
|
+
}
|
|
23
34
|
claimKeyPackage(input) {
|
|
24
35
|
return this.client.post('/e2ee/key-packages/claim', input);
|
|
25
36
|
}
|
|
@@ -211,6 +211,8 @@ export interface HostedProtocolConnections {
|
|
|
211
211
|
servers: {
|
|
212
212
|
protocol: 'atproto' | 'activitypub' | 'nostr';
|
|
213
213
|
url: string;
|
|
214
|
+
/** Handle suffixes the PDS issues (`minds.com` -> `alice.minds.com`); absent means the PDS hostname. */
|
|
215
|
+
handleDomains?: string[];
|
|
214
216
|
}[];
|
|
215
217
|
}
|
|
216
218
|
export type HostedProtocol = 'atproto' | 'activitypub' | 'nostr';
|
|
@@ -300,6 +302,18 @@ export interface SocialSourceControl {
|
|
|
300
302
|
export type SocialSourceControlChange = Omit<SocialSourceControl, 'id' | 'createdAt'> & {
|
|
301
303
|
enabled: boolean;
|
|
302
304
|
};
|
|
305
|
+
/** One image already uploaded to Minds storage. Only Minds-hosted HTTPS URLs are accepted; JPEG, PNG, WebP or GIF; at most four per post. */
|
|
306
|
+
export interface NativeMediaInput {
|
|
307
|
+
url: string;
|
|
308
|
+
/** The member's alt text. When omitted an empty description is sent, never an invented one. */
|
|
309
|
+
alt?: string;
|
|
310
|
+
width?: number;
|
|
311
|
+
height?: number;
|
|
312
|
+
mimeType?: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif';
|
|
313
|
+
}
|
|
314
|
+
export interface NativeMediaItem extends NativeMediaInput {
|
|
315
|
+
alt: string;
|
|
316
|
+
}
|
|
303
317
|
export interface NativeReplyPrepareInput {
|
|
304
318
|
clientMutationId: string;
|
|
305
319
|
text: string;
|
|
@@ -309,6 +323,8 @@ export interface NativeReplyPrepareInput {
|
|
|
309
323
|
readRef?: string;
|
|
310
324
|
};
|
|
311
325
|
accountIds: string[];
|
|
326
|
+
/** Minds-hosted images attached to every selected destination. Omit for a text-only reply. */
|
|
327
|
+
media?: NativeMediaInput[];
|
|
312
328
|
/** Selected different-network accounts to review as standalone posts with a source link. */
|
|
313
329
|
linkedPostAccountIds?: string[];
|
|
314
330
|
/** Fresh review of selected unsent or definitively rejected deliveries. The original selected reviews are atomically fenced; preserve this mutation ID on retry. */
|
|
@@ -518,6 +534,11 @@ export interface NativeReplyReceipt {
|
|
|
518
534
|
providerId: string | null;
|
|
519
535
|
acknowledged: boolean;
|
|
520
536
|
readBack: 'matched' | 'absent' | 'unavailable';
|
|
537
|
+
/** Present when the delivery carried images: how many, and how many had no alt text. */
|
|
538
|
+
media?: {
|
|
539
|
+
count: number;
|
|
540
|
+
alt_missing: number;
|
|
541
|
+
};
|
|
521
542
|
}
|
|
522
543
|
export interface NativeReplyRemovalReceipt {
|
|
523
544
|
/** Absence is observed on the owning server; this is not global erasure. */
|
|
@@ -581,6 +602,8 @@ export interface NativeReplyBatch {
|
|
|
581
602
|
text: string;
|
|
582
603
|
wireText: string;
|
|
583
604
|
receipt: NativeReplyReceipt | null;
|
|
605
|
+
/** The reviewed images exactly as they will be sent. */
|
|
606
|
+
media?: NativeMediaItem[];
|
|
584
607
|
removal: {
|
|
585
608
|
id: string;
|
|
586
609
|
expiresAt: string;
|
|
@@ -589,11 +612,13 @@ export interface NativeReplyBatch {
|
|
|
589
612
|
} | null;
|
|
590
613
|
}>;
|
|
591
614
|
}
|
|
592
|
-
/** Public text
|
|
615
|
+
/** Public text and Minds-hosted images; accounts are selected explicitly in the shared editor. */
|
|
593
616
|
export interface NativePostPrepareInput {
|
|
594
617
|
clientMutationId: string;
|
|
595
618
|
text: string;
|
|
596
619
|
accountIds: string[];
|
|
620
|
+
/** Minds-hosted images attached to every selected destination. Omit for a text-only post. */
|
|
621
|
+
media?: NativeMediaInput[];
|
|
597
622
|
/** Native quote on Bluesky only. The server reads the source and permissions using each selected account. */
|
|
598
623
|
quote?: {
|
|
599
624
|
protocol: 'bluesky';
|
|
@@ -929,6 +954,113 @@ export interface SocialTrendFeed extends SocialTrendProvenance {
|
|
|
929
954
|
items: SocialPost[];
|
|
930
955
|
cursor: string | null;
|
|
931
956
|
}
|
|
957
|
+
/** A member of a radar cluster: a headline or excerpt and a link only, never a body. */
|
|
958
|
+
export type SocialRadarSourceKind = 'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds';
|
|
959
|
+
export interface SocialRadarMember {
|
|
960
|
+
kind: SocialRadarSourceKind;
|
|
961
|
+
/** outlet: the article url; hackernews: the `hn:<id>` candidate id; social: JSON `[protocol, nativeId]`; minds: the post id. */
|
|
962
|
+
key: string;
|
|
963
|
+
title: string;
|
|
964
|
+
url?: string;
|
|
965
|
+
publishedAt: string;
|
|
966
|
+
outlet?: {
|
|
967
|
+
name: string;
|
|
968
|
+
lane: 'mainstream' | 'independent';
|
|
969
|
+
};
|
|
970
|
+
engagement?: {
|
|
971
|
+
likes?: number;
|
|
972
|
+
reposts?: number;
|
|
973
|
+
replies?: number;
|
|
974
|
+
score?: number;
|
|
975
|
+
perHour?: number;
|
|
976
|
+
samples: number;
|
|
977
|
+
};
|
|
978
|
+
media?: {
|
|
979
|
+
type: 'image' | 'video';
|
|
980
|
+
url: string;
|
|
981
|
+
poster?: string;
|
|
982
|
+
width?: number;
|
|
983
|
+
height?: number;
|
|
984
|
+
};
|
|
985
|
+
providerRank?: number;
|
|
986
|
+
}
|
|
987
|
+
/** One story on the radar. `breaking` is outlet corroboration inside a short window; `viral` is social velocity above its network's norm. */
|
|
988
|
+
export interface SocialRadarCluster {
|
|
989
|
+
id: string;
|
|
990
|
+
/** Verbatim: a World Today headline, a provider topic name or the medoid outlet title. Never generated. */
|
|
991
|
+
label: string;
|
|
992
|
+
kind: 'breaking' | 'viral' | 'story';
|
|
993
|
+
seed: {
|
|
994
|
+
kind: 'world_story' | 'trend_topic' | 'emergent';
|
|
995
|
+
ref: string;
|
|
996
|
+
};
|
|
997
|
+
/** A browse-category id or `mixed`, from cached judgments only. */
|
|
998
|
+
theme?: string;
|
|
999
|
+
sources: Partial<Record<SocialRadarSourceKind, number>>;
|
|
1000
|
+
corroboration: number;
|
|
1001
|
+
laneMix: {
|
|
1002
|
+
mainstream: number;
|
|
1003
|
+
independent: number;
|
|
1004
|
+
};
|
|
1005
|
+
velocity: {
|
|
1006
|
+
perHour: number;
|
|
1007
|
+
normalized: number;
|
|
1008
|
+
samples: number;
|
|
1009
|
+
};
|
|
1010
|
+
media: {
|
|
1011
|
+
images: number;
|
|
1012
|
+
videos: number;
|
|
1013
|
+
lead?: {
|
|
1014
|
+
type: 'image' | 'video';
|
|
1015
|
+
url: string;
|
|
1016
|
+
poster?: string;
|
|
1017
|
+
width?: number;
|
|
1018
|
+
height?: number;
|
|
1019
|
+
memberKey: string;
|
|
1020
|
+
};
|
|
1021
|
+
};
|
|
1022
|
+
freshness: {
|
|
1023
|
+
newestAt: string;
|
|
1024
|
+
oldestAt: string;
|
|
1025
|
+
outletsInBreakingWindow: number;
|
|
1026
|
+
};
|
|
1027
|
+
score: number;
|
|
1028
|
+
members: SocialRadarMember[];
|
|
1029
|
+
}
|
|
1030
|
+
/** The app's radar snapshot: rebuilt by a server job on its own interval, never on a request. `builtAt` is null before the first tick. */
|
|
1031
|
+
export interface SocialRadar {
|
|
1032
|
+
version: 'topic-radar-v1';
|
|
1033
|
+
builtAt: string | null;
|
|
1034
|
+
windowHours: number;
|
|
1035
|
+
breakingWindowHours: number;
|
|
1036
|
+
clusters: SocialRadarCluster[];
|
|
1037
|
+
provenance: {
|
|
1038
|
+
providers: Array<{
|
|
1039
|
+
name: string;
|
|
1040
|
+
observedAt: string;
|
|
1041
|
+
coverage: 'provider_ranked_window' | 'allowlisted_outlets' | 'minds_hot';
|
|
1042
|
+
}>;
|
|
1043
|
+
};
|
|
1044
|
+
/** Present when a category was selected; `partial` while some clusters have no cached theme yet. */
|
|
1045
|
+
radar_filter?: {
|
|
1046
|
+
selected: string;
|
|
1047
|
+
status: 'complete' | 'partial';
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
export interface SocialRadarParams {
|
|
1051
|
+
category?: string;
|
|
1052
|
+
limit?: number;
|
|
1053
|
+
}
|
|
1054
|
+
/** One radar story's social member posts, in the cluster's member order. Empty once the story leaves the snapshot. */
|
|
1055
|
+
export interface SocialRadarPosts {
|
|
1056
|
+
cluster: string;
|
|
1057
|
+
builtAt: string | null;
|
|
1058
|
+
items: SocialPost[];
|
|
1059
|
+
}
|
|
1060
|
+
/** Read options for callers that hold their own deadline, such as a discovery page. */
|
|
1061
|
+
export interface SocialReadOptions {
|
|
1062
|
+
retry?: boolean;
|
|
1063
|
+
}
|
|
932
1064
|
/** Provider-ranked accounts. `source` says whether this page is the app's hourly index snapshot or a live read. */
|
|
933
1065
|
export interface SocialPopularProfiles {
|
|
934
1066
|
items: SocialProfile[];
|
|
@@ -1009,6 +1141,8 @@ export declare class ProtocolsResource {
|
|
|
1009
1141
|
category?: string;
|
|
1010
1142
|
discovery?: boolean;
|
|
1011
1143
|
}): Promise<SingleResponse<SocialTrendFeed>>;
|
|
1144
|
+
/** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
|
|
1145
|
+
getSharedRadar(projectId: string, params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
|
|
1012
1146
|
publicNetworks(): Promise<SingleResponse<SocialNetwork[]>>;
|
|
1013
1147
|
/** Query the live public network. It does not import users or posts. */
|
|
1014
1148
|
searchPublicPosts(params: SearchPublicPostsParams): Promise<SingleResponse<SocialPostSearch>>;
|
|
@@ -1048,7 +1182,7 @@ export declare class ProtocolsResource {
|
|
|
1048
1182
|
includePreviews?: boolean;
|
|
1049
1183
|
category?: string;
|
|
1050
1184
|
discovery?: boolean;
|
|
1051
|
-
}): Promise<SingleResponse<SocialTrends>>;
|
|
1185
|
+
}, options?: SocialReadOptions): Promise<SingleResponse<SocialTrends>>;
|
|
1052
1186
|
/** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
|
|
1053
1187
|
getPublicTrendFeed(params: {
|
|
1054
1188
|
protocol: string;
|
|
@@ -1057,7 +1191,13 @@ export declare class ProtocolsResource {
|
|
|
1057
1191
|
cursor?: string;
|
|
1058
1192
|
category?: string;
|
|
1059
1193
|
discovery?: boolean;
|
|
1060
|
-
}): Promise<SingleResponse<SocialTrendFeed>>;
|
|
1194
|
+
}, options?: SocialReadOptions): Promise<SingleResponse<SocialTrendFeed>>;
|
|
1195
|
+
/** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
|
|
1196
|
+
getPublicRadar(params?: SocialRadarParams, options?: SocialReadOptions): Promise<SingleResponse<SocialRadar>>;
|
|
1197
|
+
/** One radar story's social member posts, so its card can show the posts it counts. Minds members are read with `posts.get`. */
|
|
1198
|
+
getPublicRadarPosts(params: {
|
|
1199
|
+
cluster: string;
|
|
1200
|
+
}, options?: SocialReadOptions): Promise<SingleResponse<SocialRadarPosts>>;
|
|
1061
1201
|
/** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
|
|
1062
1202
|
getPublicPopularProfiles(params: {
|
|
1063
1203
|
protocol: string;
|
|
@@ -1097,7 +1237,7 @@ export declare class ProtocolsResource {
|
|
|
1097
1237
|
}): Promise<SingleResponse<NativeRepostReview>>;
|
|
1098
1238
|
/** Reconcile the saved action without sending another native write. */
|
|
1099
1239
|
nativeRepostStatus(id: string): Promise<SingleResponse<NativeRepostReview>>;
|
|
1100
|
-
/** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
|
|
1240
|
+
/** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
|
|
1101
1241
|
prepareNativePost(input: NativePostPrepareInput): Promise<SingleResponse<NativePostBatch>>;
|
|
1102
1242
|
/** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
|
|
1103
1243
|
submitNativePost(id: string, input: {
|
|
@@ -34,6 +34,16 @@ function hostedIdentityPath(id) {
|
|
|
34
34
|
}
|
|
35
35
|
return `/protocols/identities/${encodeURIComponent(id)}`;
|
|
36
36
|
}
|
|
37
|
+
/** Only the two radar fields travel; caller scope or destination extras never reach the query string. */
|
|
38
|
+
/** Forwarded only when given, so a default read makes exactly the call it always made. */
|
|
39
|
+
const readOptions = (options) => options ? [options] : [];
|
|
40
|
+
function radarQuery(params) {
|
|
41
|
+
return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
|
|
42
|
+
}
|
|
43
|
+
/** Forward only the media contract fields; nothing else from the app's own media rows. */
|
|
44
|
+
const nativeMediaInput = (media) => media.map(item => ({ url: item.url,
|
|
45
|
+
...(item.alt !== undefined ? { alt: item.alt } : {}), ...(item.width !== undefined ? { width: item.width } : {}),
|
|
46
|
+
...(item.height !== undefined ? { height: item.height } : {}), ...(item.mimeType !== undefined ? { mimeType: item.mimeType } : {}) }));
|
|
37
47
|
export class ProtocolsResource {
|
|
38
48
|
client;
|
|
39
49
|
subscriptionListeners = new Set();
|
|
@@ -80,6 +90,10 @@ export class ProtocolsResource {
|
|
|
80
90
|
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trend-feed`, { protocol: params.protocol, topic: params.topic,
|
|
81
91
|
limit: params.limit, cursor: params.cursor, category: params.category, discovery: params.discovery });
|
|
82
92
|
}
|
|
93
|
+
/** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
|
|
94
|
+
getSharedRadar(projectId, params = {}) {
|
|
95
|
+
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/radar`, radarQuery(params));
|
|
96
|
+
}
|
|
83
97
|
publicNetworks() {
|
|
84
98
|
return this.client.get('/protocols/social/networks');
|
|
85
99
|
}
|
|
@@ -118,17 +132,25 @@ export class ProtocolsResource {
|
|
|
118
132
|
return this.client.get('/protocols/social/conversation', { protocol: params.protocol, post: params.post, readRef: params.readRef });
|
|
119
133
|
}
|
|
120
134
|
/** Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content. */
|
|
121
|
-
getPublicTrends(params) {
|
|
135
|
+
getPublicTrends(params, options) {
|
|
122
136
|
return this.client.get('/protocols/social/trends', { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
|
|
123
137
|
...(params.includePreviews !== undefined ? { includePreviews: params.includePreviews } : {}),
|
|
124
138
|
...(params.category !== undefined ? { category: params.category } : {}),
|
|
125
|
-
...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
|
|
139
|
+
...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
|
|
126
140
|
}
|
|
127
141
|
/** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
|
|
128
|
-
getPublicTrendFeed(params) {
|
|
142
|
+
getPublicTrendFeed(params, options) {
|
|
129
143
|
return this.client.get('/protocols/social/trend-feed', { protocol: params.protocol, topic: params.topic, limit: params.limit, cursor: params.cursor,
|
|
130
144
|
...(params.category !== undefined ? { category: params.category } : {}),
|
|
131
|
-
...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
|
|
145
|
+
...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
|
|
146
|
+
}
|
|
147
|
+
/** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
|
|
148
|
+
getPublicRadar(params = {}, options) {
|
|
149
|
+
return this.client.get('/protocols/social/radar', radarQuery(params), ...readOptions(options));
|
|
150
|
+
}
|
|
151
|
+
/** One radar story's social member posts, so its card can show the posts it counts. Minds members are read with `posts.get`. */
|
|
152
|
+
getPublicRadarPosts(params, options) {
|
|
153
|
+
return this.client.get('/protocols/social/radar/posts', { cluster: params.cluster }, ...readOptions(options));
|
|
132
154
|
}
|
|
133
155
|
/** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
|
|
134
156
|
getPublicPopularProfiles(params) {
|
|
@@ -200,11 +222,12 @@ export class ProtocolsResource {
|
|
|
200
222
|
hostedIdentityPath(id);
|
|
201
223
|
return this.client.get(`/protocols/social/reposts/${encodeURIComponent(id)}`);
|
|
202
224
|
}
|
|
203
|
-
/** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
|
|
225
|
+
/** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
|
|
204
226
|
prepareNativePost(input) {
|
|
205
227
|
return this.client.post('/protocols/social/posts/prepare', { clientMutationId: input.clientMutationId,
|
|
206
228
|
text: input.text, accountIds: input.accountIds,
|
|
207
|
-
...(input.quote ? { quote: { protocol: input.quote.protocol, nativeId: input.quote.nativeId } } : {})
|
|
229
|
+
...(input.quote ? { quote: { protocol: input.quote.protocol, nativeId: input.quote.nativeId } } : {}),
|
|
230
|
+
...(input.media !== undefined ? { media: nativeMediaInput(input.media) } : {}) }, { retry: false });
|
|
208
231
|
}
|
|
209
232
|
/** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
|
|
210
233
|
submitNativePost(id, input) {
|
|
@@ -236,7 +259,8 @@ export class ProtocolsResource {
|
|
|
236
259
|
parent: { protocol: input.parent.protocol, nativeId: input.parent.nativeId,
|
|
237
260
|
...(input.parent.readRef !== undefined ? { readRef: input.parent.readRef } : {}) }, accountIds: input.accountIds,
|
|
238
261
|
...(input.linkedPostAccountIds !== undefined ? { linkedPostAccountIds: input.linkedPostAccountIds } : {}),
|
|
239
|
-
...(input.recoverFrom !== undefined ? { recoverFrom: { batchId: input.recoverFrom.batchId, deliveryIds: input.recoverFrom.deliveryIds } } : {})
|
|
262
|
+
...(input.recoverFrom !== undefined ? { recoverFrom: { batchId: input.recoverFrom.batchId, deliveryIds: input.recoverFrom.deliveryIds } } : {}),
|
|
263
|
+
...(input.media !== undefined ? { media: nativeMediaInput(input.media) } : {}) }, { retry: false });
|
|
240
264
|
}
|
|
241
265
|
/** Send only explicitly approved delivery IDs from this preview. Repeated submissions reconcile; they do not replay a write. */
|
|
242
266
|
submitNativeReply(id, input) {
|
package/llm.md
CHANGED
|
@@ -478,11 +478,12 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
|
|
|
478
478
|
|
|
479
479
|
### r.e2ee — End-to-end encryption transport (flag-gated)
|
|
480
480
|
|
|
481
|
-
Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag
|
|
481
|
+
Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise); blocks apply (`blocked`); commits must send `expected_commit_seq`.
|
|
482
482
|
|
|
483
483
|
```ts
|
|
484
|
-
const { data: device } = await r.e2ee.registerDevice({ device_name: 'phone-1', realm:
|
|
485
|
-
await r.e2ee.publishKeyPackages({ device_id: device.id, packages: [keyPackageBase64] });
|
|
484
|
+
const { data: device } = await r.e2ee.registerDevice({ device_name: 'phone-1', realm: networkId, signing_key, credential });
|
|
485
|
+
await r.e2ee.publishKeyPackages({ device_id: device.id, packages: [keyPackageBase64] }); // → { published, available }
|
|
486
|
+
const { data: stock } = await r.e2ee.keyPackageStatus({ device_id: device.id }); // { available, expiringSoon }; refill when low
|
|
486
487
|
const { data: claimed } = await r.e2ee.claimKeyPackage({ device_id: device.id, target_device_id: peerDeviceId });
|
|
487
488
|
await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope }); // idempotent per event_id
|
|
488
489
|
const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
|
|
@@ -634,7 +635,7 @@ if (subscription.active) {
|
|
|
634
635
|
- `r.organizationSettings` / `r.organizationSecurity` — Org configuration
|
|
635
636
|
- Native follows: `nativeFollowState({ accountId, protocol, nativeId })`, `prepareNativeFollow({ clientMutationId, accountId, protocol, nativeId, action: "follow" | "unfollow" })`, `submitNativeFollow(id, { approve: true, event? })`, `nativeFollowStatus(id)`. Show the acting identity and native following/requested/unfollowed state separately from the Minds feed subscription. Nostr must sign the complete review event without dropping other contacts; stale lists require a fresh review. Keep mutation IDs and reconcile ambiguous outcomes without replay. Requires current follow scopes and API `NATIVE_SOCIAL_FOLLOW_PROJECTS` plus source controls.
|
|
636
637
|
- Native reposts: `nativeRepostState({ accountId, protocol, nativeId })`, `prepareNativeRepost({ clientMutationId, accountId, protocol, nativeId, action: "repost" | "unrepost" })`, `submitNativeRepost(id, { approve: true, event? })`, `nativeRepostStatus(id)`. Requires `NATIVE_SOCIAL_REPOST_PROJECTS`, account capability and explicit approval; Nostr signs the exact review (kind 6 or own kind 5). Unknown attempts only reconcile. Bluesky connections need the new repost scope; ActivityPub uses write:statuses. Native likes: `nativeLikeState({ accountId, protocol, nativeId })`, `prepareNativeLike({ clientMutationId, accountId, protocol, nativeId, action: "like" | "unlike" })`, `submitNativeLike(id, { approve: true, event? })`, and `nativeLikeStatus(id)`. Display the selected account, source and action before approval. Only Nostr supplies `event`: sign the exact public `signing.event` in the review; never transmit a private key. Preserve mutation IDs on ambiguous responses and refresh unknown results instead of resending. Read capabilities before offering actions; old reply-only connections need reconnecting for likes. Nostr results describe the connected relay, not every public relay. The app-specific `NATIVE_SOCIAL_LIKE_PROJECTS` switch and source controls must be enabled on the API.
|
|
637
|
-
- Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source
|
|
638
|
+
- Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, media?, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source, exact `wireText` and every reviewed `media` item with its alt text; connection is not publication approval. `media` takes the same `NativeMediaInput[]` as `prepareNativePost` and needs the account's `publish_media` capability. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
|
|
638
639
|
- `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
639
640
|
- Native Nostr: `const { data: preview } = await r.protocols.prepareNativeNostr(identityId, { action: "publish", content: draft })`; review and sign the exact public event, then `submitNativeNostr(identityId, { intent: preview.intent, event, approve: true })`. Preparation/submission never auto-retry. After uncertainty, use `getNativeNostrPost(identityId, preview.postId ?? preview.eventId)` before a user-selected retry of the identical intent/event: deletion checks the original note's `postId`, publication checks `eventId`. Check `nativeNostrAccount()` enablement first; keys stay with the signer. For replies, after `nativeNostrAccount()` advertises `reply_text_note`, use `prepareNativeNostr(identityId, { action: "reply", content: draft, parentId: post.nativeId })`. Review returned parent/root/participants/relay and exact event, sign with the connected client signer, then `submitNativeNostr(identityId, { intent, event, approve: true })`. Requires users:read, posts:read and posts:write. Kind1 public notes only; reply enablement and shared UI/live proof remain pending. Do not inherit arbitrary notification tags or relay hints. Signing mutations never auto-retry; check `getNativeNostrPost(identityId, eventId)` after uncertainty before a user-selected identical-event retry.
|
|
640
641
|
- Anonymous discovery: `const visitor = new Recursiv({ anonymous: true }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows.
|
|
@@ -822,7 +823,7 @@ publication; history stores no signature, signing key or intent. Keep the acting
|
|
|
822
823
|
user/app-bound result IDs and discard UI state after an account switch. The last
|
|
823
824
|
read-back observation is separate from retained publication/deletion evidence.
|
|
824
825
|
|
|
825
|
-
Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text
|
|
826
|
+
Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text`; no reply parent or audience override. Attach already-uploaded Minds images with `media: [{ url, alt?, width?, height?, mimeType? }]` (at most four JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment) on accounts that also advertise `publish_media` — grants made before image scopes existed keep working for text and must be reconnected first. Always pass the member's own `alt`; an omitted one is sent empty, never invented. The receipt reports `media: { count, alt_missing }`. Preserve mutation IDs, never create a new send to recover uncertainty.
|
|
826
827
|
Bluesky quote: accounts must advertise `quote`; call `prepareNativePost({clientMutationId,text,accountIds,quote:{protocol:'bluesky',nativeId:sourceAtUri}})`, review each returned quote/account/comment, then approve via the same submit method. The server binds the source CID and rechecks permissions before sending. No cross-network quote conversion. Public reads expose one filtered `post.quote` (`available` with `post`, or `unavailable` without source details). Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
|
|
827
828
|
|
|
828
829
|
Public post search (API/SDK 0.7.35): `const { data: page } = await r.protocols.searchPublicPosts({ protocol: 'nostr', query: 'open social', limit: 10 })`. Bluesky/ActivityPub require a selected `accountId` from `nativeSocialAccounts()` with `post_search`; older Bluesky grants must reconnect with consent. Open their results with `getConnectedSocialConversation({ protocol, accountId: page.readerAccountId, post: item.nativeId, readRef: item.readRef })` and pass the same accountId to `reportSource` for post reports. Nostr uses `getPublicConversation`. Pages report provider/observedAt and bounded coverage; no invented exhaustive or global search. Keep cursors paired with the exact owner, query, account, limit and connection revision. No writes, credentials or new ingestion are part of search.
|