@recursiv/sdk 0.7.52 → 0.7.54
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 +49 -3
- package/dist/client.d.ts +6 -2
- package/dist/client.js +7 -8
- package/dist/index.d.ts +12 -2
- package/dist/index.js +11 -1
- package/dist/resources/auth.d.ts +17 -3
- package/dist/resources/auth.js +7 -2
- package/dist/resources/bookmarks.d.ts +172 -0
- package/dist/resources/bookmarks.js +44 -0
- package/dist/resources/communities.d.ts +11 -1
- package/dist/resources/communities.js +14 -0
- package/dist/resources/curator.d.ts +1 -1
- package/dist/resources/discovery.d.ts +168 -3
- package/dist/resources/discovery.js +133 -21
- package/dist/resources/human-input.js +2 -1
- package/dist/resources/link-preview.d.ts +11 -4
- package/dist/resources/link-preview.js +4 -3
- package/dist/resources/notifications.d.ts +26 -0
- package/dist/resources/posts.d.ts +21 -3
- package/dist/resources/posts.js +21 -1
- package/dist/resources/profiles.d.ts +29 -16
- package/dist/resources/profiles.js +23 -6
- package/dist/resources/protocols.d.ts +60 -3
- package/dist/resources/search.d.ts +159 -0
- package/dist/resources/search.js +21 -0
- package/dist/types.d.ts +178 -2
- package/llm.md +36 -5
- package/package.json +1 -1
|
@@ -1,9 +1,126 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { HttpClient } from '../client.js';
|
|
2
|
+
import type { DiscoveryLayout, FalloverStep, Post } from '../types.js';
|
|
2
3
|
import type { CuratorResource } from './curator.js';
|
|
3
4
|
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
4
5
|
import type { PostsResource } from './posts.js';
|
|
5
6
|
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
|
|
6
7
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
8
|
+
/** What GET /discover/recommendations suggests: members, groups, tags, open-network accounts or World Today stories. */
|
|
9
|
+
export type EntityKind = 'member' | 'group' | 'tag' | 'network_account' | 'story';
|
|
10
|
+
/**
|
|
11
|
+
* One suggestion. `key` is the entity's id (a uuid), or `<protocol>:<native id>` for an open-network account.
|
|
12
|
+
* The public display fields depend on the kind: members carry id, username, name, image, bio and
|
|
13
|
+
* follower_count; groups id, name, slug, image, description and member_count; tags id, name and slug;
|
|
14
|
+
* open-network accounts protocol, actor, handle, display_name and avatar_url (never a link out); stories id,
|
|
15
|
+
* headline, kicker, story_kind and edition_date.
|
|
16
|
+
*/
|
|
17
|
+
export interface EntityRecommendation {
|
|
18
|
+
kind: EntityKind;
|
|
19
|
+
key: string;
|
|
20
|
+
/** active: posted in the last 7 days or 4+ times in 30 (for groups, 3+ distinct posters in 30 days); warm: activity in 90 days. */
|
|
21
|
+
tier: 'active' | 'warm';
|
|
22
|
+
score: number;
|
|
23
|
+
/** The entity's share of the requested category, or null for category=all. */
|
|
24
|
+
share: number | null;
|
|
25
|
+
last_active_at: string | null;
|
|
26
|
+
posts_30d: number;
|
|
27
|
+
/** Distinct posters in 30 days (groups and tags), otherwise null. */
|
|
28
|
+
authors_30d: number | null;
|
|
29
|
+
/** Up to three browse categories, largest share first. */
|
|
30
|
+
categories: Array<{
|
|
31
|
+
category: string;
|
|
32
|
+
share: number;
|
|
33
|
+
}>;
|
|
34
|
+
[display: string]: unknown;
|
|
35
|
+
}
|
|
36
|
+
export interface EntityRecommendationsParams {
|
|
37
|
+
kind: EntityKind;
|
|
38
|
+
/** 'all' (default) or a browse category id. */
|
|
39
|
+
category?: string;
|
|
40
|
+
/** 1..50, default 10. */
|
|
41
|
+
limit?: number;
|
|
42
|
+
/** A per-session token ([A-Za-z0-9_-], up to 64): the same token keeps the order stable; a new one rotates near-ties. */
|
|
43
|
+
session?: string;
|
|
44
|
+
/** Up to 50 keys of the kind to leave out, e.g. open-network accounts the member follows through a connected account. */
|
|
45
|
+
exclude?: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface EntityRecommendationsResponse {
|
|
48
|
+
data: EntityRecommendation[];
|
|
49
|
+
meta: {
|
|
50
|
+
kind: EntityKind;
|
|
51
|
+
category: string;
|
|
52
|
+
as_of: string | null;
|
|
53
|
+
/** Why the list is empty: serving is off, the key's app is not the one signals are built for, no snapshot yet, the viewer's follows could not be checked, or the key has no app. */
|
|
54
|
+
reason?: 'disabled' | 'other_app' | 'no_snapshot' | 'viewer_facts_unavailable' | 'no_app';
|
|
55
|
+
/** Suggestions left out because they are resting for this viewer, and because the viewer follows, joined, blocked or muted them. */
|
|
56
|
+
resting: number;
|
|
57
|
+
excluded: number;
|
|
58
|
+
/** True when the member's own category order lifted suggestions in their first categories. */
|
|
59
|
+
personalized: boolean;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface EntityImpressionsInput {
|
|
63
|
+
kind: EntityKind;
|
|
64
|
+
/** 1..50 keys of the kind. */
|
|
65
|
+
keys: string[];
|
|
66
|
+
/** 'shown' when a suggestion was on screen; 'engaged' on a tap, follow or join. Three showings without a tap rest a suggestion for fourteen days; engaging clears it. */
|
|
67
|
+
event: 'shown' | 'engaged';
|
|
68
|
+
}
|
|
69
|
+
/** One section of a category surface: bounded, read under its own deadline; empty with a `reason` when it timed out, failed, is off, or the recommender said why. */
|
|
70
|
+
export interface CategorySurfaceSection<T> {
|
|
71
|
+
items: T[];
|
|
72
|
+
ms: number;
|
|
73
|
+
reason?: string;
|
|
74
|
+
}
|
|
75
|
+
/** A radar cluster themed into the category, with up to six members (headline or excerpt and link only). */
|
|
76
|
+
export type CategoryStory = Pick<SocialRadar['clusters'][number], 'id' | 'label' | 'kind' | 'theme' | 'sources' | 'corroboration' | 'laneMix' | 'velocity' | 'media' | 'freshness' | 'score' | 'members'>;
|
|
77
|
+
export interface CategoryClip {
|
|
78
|
+
id: string;
|
|
79
|
+
title: string | null;
|
|
80
|
+
excerpt: string;
|
|
81
|
+
created_at: string;
|
|
82
|
+
author: {
|
|
83
|
+
id: string;
|
|
84
|
+
username: string | null;
|
|
85
|
+
name: string;
|
|
86
|
+
image: string | null;
|
|
87
|
+
};
|
|
88
|
+
media: Array<{
|
|
89
|
+
id: string;
|
|
90
|
+
url: string;
|
|
91
|
+
type: string;
|
|
92
|
+
thumbnail_url: string | null;
|
|
93
|
+
unplayable?: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
engagement: {
|
|
96
|
+
score: number;
|
|
97
|
+
like_count: number;
|
|
98
|
+
reply_count: number;
|
|
99
|
+
repost_count: number;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export interface CategorySurface {
|
|
103
|
+
category: string;
|
|
104
|
+
/** When the oldest cached section served (stories, network, clips) was built. */
|
|
105
|
+
as_of: string;
|
|
106
|
+
stories: CategorySurfaceSection<CategoryStory>;
|
|
107
|
+
network: CategorySurfaceSection<SocialPost> & {
|
|
108
|
+
by_protocol: Record<string, number>;
|
|
109
|
+
};
|
|
110
|
+
clips: CategorySurfaceSection<CategoryClip>;
|
|
111
|
+
people: CategorySurfaceSection<EntityRecommendation>;
|
|
112
|
+
groups: CategorySurfaceSection<EntityRecommendation>;
|
|
113
|
+
}
|
|
114
|
+
export interface CategorySurfaceResponse {
|
|
115
|
+
/** Null when the key has no app (meta.reason `no_app`). */
|
|
116
|
+
data: CategorySurface | null;
|
|
117
|
+
meta: {
|
|
118
|
+
category: string;
|
|
119
|
+
as_of?: string;
|
|
120
|
+
reason?: 'no_app';
|
|
121
|
+
personalized: boolean;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
7
124
|
export interface DiscoveryGroupCandidate {
|
|
8
125
|
id: string;
|
|
9
126
|
name: string;
|
|
@@ -157,12 +274,18 @@ export interface DiscoveryFeedPage {
|
|
|
157
274
|
hasMore: boolean;
|
|
158
275
|
failedSources: string[];
|
|
159
276
|
partialCategories: boolean;
|
|
277
|
+
/**
|
|
278
|
+
* The Minds feed's fall-through, once its category feed has ended a stage (the server's
|
|
279
|
+
* `meta.fallover`): where to read next when this session has no more. Absent until then, and
|
|
280
|
+
* on servers that predate it.
|
|
281
|
+
*/
|
|
282
|
+
fallover?: FalloverStep[];
|
|
160
283
|
}
|
|
161
284
|
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
162
285
|
export declare const GALLERY_MAX_ITEMS = 12;
|
|
163
286
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
164
287
|
* source changes. All reads retain existing server authorization/moderation.
|
|
165
|
-
* At most four
|
|
288
|
+
* At most four sources per page (a topics network's read walks up to TOPIC_WALK topic pages); providers fail independently. */
|
|
166
289
|
export declare class DiscoveryFeedSession {
|
|
167
290
|
private posts;
|
|
168
291
|
private protocols;
|
|
@@ -187,6 +310,15 @@ export declare class DiscoveryFeedSession {
|
|
|
187
310
|
retryFailed(): void;
|
|
188
311
|
next(): Promise<DiscoveryFeedPage>;
|
|
189
312
|
private read;
|
|
313
|
+
/**
|
|
314
|
+
* One read of a topics network: the next topic page that carries posts. A topic the provider no
|
|
315
|
+
* longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
|
|
316
|
+
* that topic's end, not the network's failure; a topic page with nothing in the category is
|
|
317
|
+
* walked past too, and its continuation kept, so the page reaches a topic that carries posts
|
|
318
|
+
* instead of spending a round on each empty one. Bounded by TOPIC_WALK reads and TOPIC_WALK_MS;
|
|
319
|
+
* a walk that stops short has moved the stream on, and the next read continues from there.
|
|
320
|
+
*/
|
|
321
|
+
private walkTopics;
|
|
190
322
|
private readPage;
|
|
191
323
|
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
192
324
|
private radarCluster;
|
|
@@ -203,7 +335,40 @@ export declare class DiscoveryResource {
|
|
|
203
335
|
private posts;
|
|
204
336
|
private protocols;
|
|
205
337
|
private curator?;
|
|
206
|
-
|
|
338
|
+
private http?;
|
|
339
|
+
constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined);
|
|
340
|
+
/**
|
|
341
|
+
* Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
|
|
342
|
+
* with several posters, tags used by several people, open-network accounts or stories, overall or in one
|
|
343
|
+
* browse category. What the viewer follows, joined, blocked or muted is left out, and so is anything
|
|
344
|
+
* resting for them. Call `recordImpressions` when suggestions are actually on screen and when one is tapped.
|
|
345
|
+
*/
|
|
346
|
+
recommendations(params: EntityRecommendationsParams, options?: {
|
|
347
|
+
retry?: boolean;
|
|
348
|
+
}): Promise<EntityRecommendationsResponse>;
|
|
349
|
+
/** Records that suggestions were shown (at most once per half hour counts) or engaged with. */
|
|
350
|
+
recordImpressions(input: EntityImpressionsInput): Promise<{
|
|
351
|
+
data: {
|
|
352
|
+
recorded: number;
|
|
353
|
+
reason?: 'disabled' | 'other_app' | 'unavailable' | 'no_app';
|
|
354
|
+
};
|
|
355
|
+
}>;
|
|
356
|
+
/**
|
|
357
|
+
* A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
|
|
358
|
+
* clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
|
|
359
|
+
* (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
|
|
360
|
+
* may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
|
|
361
|
+
* viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
|
|
362
|
+
* viewer's suggestions in it. A section that timed out, failed or is off is empty
|
|
363
|
+
* with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
|
|
364
|
+
* whose pages carry `meta.next` and `meta.fallover`.
|
|
365
|
+
*/
|
|
366
|
+
category(params: {
|
|
367
|
+
category: string;
|
|
368
|
+
session?: string;
|
|
369
|
+
}, options?: {
|
|
370
|
+
retry?: boolean;
|
|
371
|
+
}): Promise<CategorySurfaceResponse>;
|
|
207
372
|
/**
|
|
208
373
|
* A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
|
|
209
374
|
* snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
|
|
@@ -8,6 +8,30 @@ const RADAR_MAX_MEMBERS = 12;
|
|
|
8
8
|
const RADAR_MAX_MINDS_READS = 4;
|
|
9
9
|
/** A discovery page holds its own deadline: a retried 5xx from one provider only makes the whole page wait. */
|
|
10
10
|
const NO_RETRY = { retry: false };
|
|
11
|
+
/**
|
|
12
|
+
* The provider no longer lists the topic (`trend_unavailable`): that topic's end, never a network
|
|
13
|
+
* failure. Matched on the server's code, which every SDK error carries: any other 404 on the route
|
|
14
|
+
* (`unsupported_protocol`, a missing route) is the network failing, and still fails the source.
|
|
15
|
+
*/
|
|
16
|
+
const isGone = (error) => typeof error === 'object' && error !== null && error.code === 'trend_unavailable';
|
|
17
|
+
/**
|
|
18
|
+
* Topic pages one read of a topics network walks past when each is gone or carries nothing in the
|
|
19
|
+
* category, so a page reaches a topic that carries posts. Live (2026-09-26, Bluesky, news) 21 of 25
|
|
20
|
+
* topics answered no posts in the category; one topic a read left the network out of most pages.
|
|
21
|
+
*/
|
|
22
|
+
const TOPIC_WALK = 5;
|
|
23
|
+
/** The walk starts no topic read after this long: well inside the page deadline, so it commits its progress rather than timing the network out. */
|
|
24
|
+
const TOPIC_WALK_MS = 8000;
|
|
25
|
+
/**
|
|
26
|
+
* A topic page the server answered from its open-network source index (`sourceIndex`): in a category, a
|
|
27
|
+
* topic that is gone or holds nothing in it is served the network's indexed posts in the category, which
|
|
28
|
+
* are the same for every such topic. While the provider has more of the topic the page carries the
|
|
29
|
+
* provider's own cursor, which the walk keeps like any other; a topic that is gone or ended carries an
|
|
30
|
+
* `idx:tf:` cursor, the one index stream of the network. So a walk starts that stream at most once per
|
|
31
|
+
* session (a second start would re-read what it already showed), and an index page that shows nothing
|
|
32
|
+
* new counts as an empty topic.
|
|
33
|
+
*/
|
|
34
|
+
const INDEX_CURSOR = 'idx:tf:';
|
|
11
35
|
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
12
36
|
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
13
37
|
function recommendationItem(kind, candidate) {
|
|
@@ -116,7 +140,7 @@ function windowLayout(layout, index) {
|
|
|
116
140
|
}
|
|
117
141
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
118
142
|
* source changes. All reads retain existing server authorization/moderation.
|
|
119
|
-
* At most four
|
|
143
|
+
* At most four sources per page (a topics network's read walks up to TOPIC_WALK topic pages); providers fail independently. */
|
|
120
144
|
export class DiscoveryFeedSession {
|
|
121
145
|
posts;
|
|
122
146
|
protocols;
|
|
@@ -174,6 +198,9 @@ export class DiscoveryFeedSession {
|
|
|
174
198
|
if (stream.cursor)
|
|
175
199
|
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
176
200
|
stream.cursor = next;
|
|
201
|
+
// A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
|
|
202
|
+
if (Array.isArray(response.meta.fallover))
|
|
203
|
+
stream.fallover = response.meta.fallover;
|
|
177
204
|
return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
|
|
178
205
|
layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial' };
|
|
179
206
|
}
|
|
@@ -181,23 +208,9 @@ export class DiscoveryFeedSession {
|
|
|
181
208
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
|
|
182
209
|
throw new Error('Invalid discovery source response');
|
|
183
210
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
stream.done = true;
|
|
188
|
-
return { items: [], layout: ordinary, partial: false };
|
|
189
|
-
}
|
|
190
|
-
const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY);
|
|
191
|
-
validatePosts(data);
|
|
192
|
-
if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
|
|
193
|
-
throw new Error('Invalid discovery topic');
|
|
194
|
-
const next = data.cursor;
|
|
195
|
-
stream.topics.shift();
|
|
196
|
-
if (next && next !== topic.cursor && !topic.used.includes(next))
|
|
197
|
-
stream.topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
|
|
198
|
-
stream.done = !stream.topics.length;
|
|
199
|
-
return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
|
|
200
|
-
}
|
|
211
|
+
const started = Date.now();
|
|
212
|
+
if (stream.topics)
|
|
213
|
+
return this.walkTopics(stream, params, validatePosts, false, started);
|
|
201
214
|
const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
|
|
202
215
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
|
|
203
216
|
throw new Error('Invalid discovery source response');
|
|
@@ -206,7 +219,14 @@ export class DiscoveryFeedSession {
|
|
|
206
219
|
throw new Error('Invalid discovery preview');
|
|
207
220
|
stream.topics = [...new Set(data.items.map(topic => topic.nativeId))].map(topic => ({ topic, used: [] }));
|
|
208
221
|
stream.done = !stream.topics.length;
|
|
209
|
-
|
|
222
|
+
const partial = data.categoryFilter?.status === 'partial';
|
|
223
|
+
// Outside a category the catalog's previews are this page. In a category a preview is not
|
|
224
|
+
// judged to be in it, so the catalog carries no card: the read walks into its topics now,
|
|
225
|
+
// not a round later, so the network's first page in a category is not empty by design.
|
|
226
|
+
const previews = data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
|
|
227
|
+
if (previews.length || stream.done)
|
|
228
|
+
return { items: previews, layout: ordinary, partial };
|
|
229
|
+
return this.walkTopics(stream, params, validatePosts, partial, started);
|
|
210
230
|
}
|
|
211
231
|
validatePosts(data);
|
|
212
232
|
const next = data.cursor ?? undefined;
|
|
@@ -216,6 +236,61 @@ export class DiscoveryFeedSession {
|
|
|
216
236
|
stream.cursor = next;
|
|
217
237
|
return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
|
|
218
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* One read of a topics network: the next topic page that carries posts. A topic the provider no
|
|
241
|
+
* longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
|
|
242
|
+
* that topic's end, not the network's failure; a topic page with nothing in the category is
|
|
243
|
+
* walked past too, and its continuation kept, so the page reaches a topic that carries posts
|
|
244
|
+
* instead of spending a round on each empty one. Bounded by TOPIC_WALK reads and TOPIC_WALK_MS;
|
|
245
|
+
* a walk that stops short has moved the stream on, and the next read continues from there.
|
|
246
|
+
*/
|
|
247
|
+
async walkTopics(stream, params, validatePosts, partial, started) {
|
|
248
|
+
const topics = stream.topics;
|
|
249
|
+
for (let reads = 0; reads < TOPIC_WALK && Date.now() - started < TOPIC_WALK_MS; reads++) {
|
|
250
|
+
const topic = topics[0];
|
|
251
|
+
if (!topic)
|
|
252
|
+
break;
|
|
253
|
+
// A failed read of the network's index stream (the server's index busy, or its safety cache down) ends that
|
|
254
|
+
// continuation, not the network: the stream may start again from a later topic's page. A provider topic
|
|
255
|
+
// read that fails any other way than gone is still the network's failure.
|
|
256
|
+
const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
|
|
257
|
+
const response = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY)
|
|
258
|
+
.catch((error) => {
|
|
259
|
+
if (isGone(error))
|
|
260
|
+
return null;
|
|
261
|
+
if (indexContinuation) {
|
|
262
|
+
stream.indexStarted = false;
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
});
|
|
267
|
+
topics.shift();
|
|
268
|
+
if (!response)
|
|
269
|
+
continue;
|
|
270
|
+
const { data } = response;
|
|
271
|
+
validatePosts(data);
|
|
272
|
+
if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
|
|
273
|
+
throw new Error('Invalid discovery topic');
|
|
274
|
+
const next = data.cursor;
|
|
275
|
+
// A provider cursor is always kept. An index cursor from a provider page starts the network's one index
|
|
276
|
+
// stream: queued the first time only, since every later start would re-read the same stream from its top.
|
|
277
|
+
const indexed = !!data.sourceIndex;
|
|
278
|
+
const startsIndex = !!next?.startsWith(INDEX_CURSOR) && !topic.cursor?.startsWith(INDEX_CURSOR);
|
|
279
|
+
if (next && next !== topic.cursor && !topic.used.includes(next) && !(startsIndex && stream.indexStarted)) {
|
|
280
|
+
topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
|
|
281
|
+
if (startsIndex)
|
|
282
|
+
stream.indexStarted = true;
|
|
283
|
+
}
|
|
284
|
+
partial ||= data.categoryFilter?.status === 'partial';
|
|
285
|
+
const items = indexed ? data.items.filter(post => !this.seen.has(key(post.protocol, post.nativeId))) : data.items;
|
|
286
|
+
if (items.length) {
|
|
287
|
+
stream.done = !topics.length;
|
|
288
|
+
return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
stream.done = !topics.length;
|
|
292
|
+
return { items: [], layout: ordinary, partial };
|
|
293
|
+
}
|
|
219
294
|
async readPage() {
|
|
220
295
|
// The radar is one small snapshot read: start it with the page, not after the slowest provider,
|
|
221
296
|
// and start the lead story's member reads the moment the snapshot names it.
|
|
@@ -326,8 +401,10 @@ export class DiscoveryFeedSession {
|
|
|
326
401
|
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
327
402
|
if (cluster)
|
|
328
403
|
modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
|
|
404
|
+
const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
|
|
329
405
|
return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
|
|
330
|
-
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial)
|
|
406
|
+
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial),
|
|
407
|
+
...(fallover ? { fallover } : {}) };
|
|
331
408
|
}
|
|
332
409
|
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
333
410
|
async radarCluster(page) {
|
|
@@ -466,10 +543,45 @@ export class DiscoveryResource {
|
|
|
466
543
|
posts;
|
|
467
544
|
protocols;
|
|
468
545
|
curator;
|
|
469
|
-
|
|
546
|
+
http;
|
|
547
|
+
constructor(posts, protocols, curator, http) {
|
|
470
548
|
this.posts = posts;
|
|
471
549
|
this.protocols = protocols;
|
|
472
550
|
this.curator = curator;
|
|
551
|
+
this.http = http;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
|
|
555
|
+
* with several posters, tags used by several people, open-network accounts or stories, overall or in one
|
|
556
|
+
* browse category. What the viewer follows, joined, blocked or muted is left out, and so is anything
|
|
557
|
+
* resting for them. Call `recordImpressions` when suggestions are actually on screen and when one is tapped.
|
|
558
|
+
*/
|
|
559
|
+
recommendations(params, options) {
|
|
560
|
+
if (!this.http)
|
|
561
|
+
throw new Error('recommendations needs the HTTP client');
|
|
562
|
+
const { exclude, ...rest } = params;
|
|
563
|
+
return this.http.get('/discover/recommendations', { ...rest, exclude: exclude?.length ? exclude.join(',') : undefined }, options);
|
|
564
|
+
}
|
|
565
|
+
/** Records that suggestions were shown (at most once per half hour counts) or engaged with. */
|
|
566
|
+
recordImpressions(input) {
|
|
567
|
+
if (!this.http)
|
|
568
|
+
throw new Error('recordImpressions needs the HTTP client');
|
|
569
|
+
return this.http.post('/discover/impressions', input);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
|
|
573
|
+
* clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
|
|
574
|
+
* (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
|
|
575
|
+
* may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
|
|
576
|
+
* viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
|
|
577
|
+
* viewer's suggestions in it. A section that timed out, failed or is off is empty
|
|
578
|
+
* with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
|
|
579
|
+
* whose pages carry `meta.next` and `meta.fallover`.
|
|
580
|
+
*/
|
|
581
|
+
category(params, options) {
|
|
582
|
+
if (!this.http)
|
|
583
|
+
throw new Error('category needs the HTTP client');
|
|
584
|
+
return this.http.get('/discover/category', params, options);
|
|
473
585
|
}
|
|
474
586
|
/**
|
|
475
587
|
* A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
|
|
@@ -18,7 +18,8 @@ export class HumanInputResource {
|
|
|
18
18
|
}
|
|
19
19
|
/** Raise a blocking question and notify the human wherever they are. */
|
|
20
20
|
request(input) {
|
|
21
|
-
|
|
21
|
+
// This write is not idempotent. A lost response must not replay the ask.
|
|
22
|
+
return this.client.post('/human-input', input, { retry: false });
|
|
22
23
|
}
|
|
23
24
|
/** Has the human answered yet? */
|
|
24
25
|
answers(conversationId) {
|
|
@@ -9,7 +9,13 @@ export interface LinkPreview {
|
|
|
9
9
|
siteName: string | null;
|
|
10
10
|
}
|
|
11
11
|
export interface LinkPreviewResponse {
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The preview, or null when the link cannot be previewed: the page refused a
|
|
14
|
+
* server (a private repository, a paywall or bot wall), is not HTML, or could
|
|
15
|
+
* not be reached. Show the plain link. Servers before this change answered
|
|
16
|
+
* 422 for these links instead, so treat that rejection the same way.
|
|
17
|
+
*/
|
|
18
|
+
data: LinkPreview | null;
|
|
13
19
|
cached?: boolean;
|
|
14
20
|
}
|
|
15
21
|
export declare class LinkPreviewResource {
|
|
@@ -17,9 +23,10 @@ export declare class LinkPreviewResource {
|
|
|
17
23
|
constructor(client: HttpClient);
|
|
18
24
|
/**
|
|
19
25
|
* Fetch Open Graph / Twitter card metadata for a public URL. Cached
|
|
20
|
-
* server-side so repeated lookups are cheap
|
|
21
|
-
*
|
|
22
|
-
* link previews.
|
|
26
|
+
* server-side so repeated lookups are cheap, including links that have no
|
|
27
|
+
* preview (`data: null`). No auth required — the endpoint is intentionally
|
|
28
|
+
* public so unauthenticated UIs can render link previews. A URL that is not
|
|
29
|
+
* http(s), or points at a private or internal address, still rejects (422).
|
|
23
30
|
*/
|
|
24
31
|
get(url: string): Promise<LinkPreviewResponse>;
|
|
25
32
|
}
|
|
@@ -5,9 +5,10 @@ export class LinkPreviewResource {
|
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
7
|
* Fetch Open Graph / Twitter card metadata for a public URL. Cached
|
|
8
|
-
* server-side so repeated lookups are cheap
|
|
9
|
-
*
|
|
10
|
-
* link previews.
|
|
8
|
+
* server-side so repeated lookups are cheap, including links that have no
|
|
9
|
+
* preview (`data: null`). No auth required — the endpoint is intentionally
|
|
10
|
+
* public so unauthenticated UIs can render link previews. A URL that is not
|
|
11
|
+
* http(s), or points at a private or internal address, still rejects (422).
|
|
11
12
|
*/
|
|
12
13
|
get(url) {
|
|
13
14
|
return this.client.get('/link-preview', { url });
|
|
@@ -7,6 +7,12 @@ export interface NotificationActor {
|
|
|
7
7
|
username: string | null;
|
|
8
8
|
image: string | null;
|
|
9
9
|
is_ai: boolean | null;
|
|
10
|
+
/**
|
|
11
|
+
* Follow rows only: whether the viewer already follows this person, so
|
|
12
|
+
* Follow back can render without reading the follow set. Absent on other
|
|
13
|
+
* rows and from servers older than this field.
|
|
14
|
+
*/
|
|
15
|
+
viewer_follows?: boolean;
|
|
10
16
|
}
|
|
11
17
|
/**
|
|
12
18
|
* The post a notification references, as it reads NOW, with the viewer's own
|
|
@@ -15,7 +21,18 @@ export interface NotificationActor {
|
|
|
15
21
|
*/
|
|
16
22
|
export interface NotificationPostPreview {
|
|
17
23
|
id: string;
|
|
24
|
+
/**
|
|
25
|
+
* The post's words as plain text (no Markdown or HTML marks; a link reads as
|
|
26
|
+
* its label and a bare URL as its site), up to 140 characters. A longer post
|
|
27
|
+
* is cut at the last whole word and ends in "…". Show it as it is.
|
|
28
|
+
*/
|
|
18
29
|
excerpt: string | null;
|
|
30
|
+
/**
|
|
31
|
+
* True when `excerpt` is a cut of a longer post. Use this rather than the
|
|
32
|
+
* excerpt's length to decide whether there is more to read. Absent from
|
|
33
|
+
* servers older than this field.
|
|
34
|
+
*/
|
|
35
|
+
excerpt_truncated?: boolean;
|
|
19
36
|
media_url: string | null;
|
|
20
37
|
media_type: string | null;
|
|
21
38
|
reactions_count?: number;
|
|
@@ -24,7 +41,10 @@ export interface NotificationPostPreview {
|
|
|
24
41
|
downvote_count?: number;
|
|
25
42
|
/** The viewer's own reaction on this post ('upvote', 'downvote', ...), if any. */
|
|
26
43
|
user_reaction?: string | null;
|
|
44
|
+
/** Stored replies a reader can open. */
|
|
27
45
|
reply_count?: number;
|
|
46
|
+
/** A legacy import's comment count; none of those comments are stored as replies. */
|
|
47
|
+
reply_count_external?: number;
|
|
28
48
|
unique_commenters?: number;
|
|
29
49
|
reposts_count?: number;
|
|
30
50
|
/** Id of the viewer's own repost of this post; delete it to undo the repost. */
|
|
@@ -43,6 +63,12 @@ export interface Notification {
|
|
|
43
63
|
read_at: string | null;
|
|
44
64
|
/** For `post_reaction` rows: which reaction ('upvote', 'downvote', ...). Null otherwise or on older rows. */
|
|
45
65
|
reaction_type?: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* For `agent_needs_input` rows: whether you have written in the agent's
|
|
68
|
+
* thread since it asked, which is what answers an ask. The title then reads
|
|
69
|
+
* "You answered <agent>". Null on other rows; absent from older servers.
|
|
70
|
+
*/
|
|
71
|
+
answered?: boolean | null;
|
|
46
72
|
actor?: NotificationActor | null;
|
|
47
73
|
post_preview?: NotificationPostPreview | null;
|
|
48
74
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
-
import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, ReactionType } from '../types.js';
|
|
2
|
+
import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, SearchPostsResponse, ReactionType } from '../types.js';
|
|
3
|
+
import type { BookmarkedPost, BookmarksPage, SavedBookmark, UnsavedBookmark } from './bookmarks.js';
|
|
3
4
|
/** How GET /posts/categories ordered its answer: the member's own order, the app's popularity order, or the fixed order. */
|
|
4
5
|
export type CategoryOrder = 'personal' | 'popular' | 'static';
|
|
5
6
|
/** Why an answer is not `personal`. */
|
|
@@ -33,6 +34,19 @@ export declare class PostsResource {
|
|
|
33
34
|
}>;
|
|
34
35
|
/** List posts the authenticated caller has upvoted. */
|
|
35
36
|
liked(params?: Pick<ListPostsParams, 'limit' | 'offset' | 'cursor'>): Promise<PaginatedResponse<Post>>;
|
|
37
|
+
/**
|
|
38
|
+
* The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
|
|
39
|
+
* `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
|
|
40
|
+
* Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
|
|
41
|
+
*/
|
|
42
|
+
bookmarked(params?: {
|
|
43
|
+
limit?: number;
|
|
44
|
+
cursor?: string;
|
|
45
|
+
}): Promise<BookmarksPage<BookmarkedPost>>;
|
|
46
|
+
/** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
|
|
47
|
+
bookmark(postId: string): Promise<SingleResponse<SavedBookmark>>;
|
|
48
|
+
/** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
|
|
49
|
+
unbookmark(postId: string): Promise<SingleResponse<UnsavedBookmark>>;
|
|
36
50
|
/** Posts similar to this one (embedding neighbours), in the app's scope. */
|
|
37
51
|
similar(id: string, params?: {
|
|
38
52
|
limit?: number;
|
|
@@ -48,8 +62,12 @@ export declare class PostsResource {
|
|
|
48
62
|
update(id: string, input: UpdatePostInput): Promise<SingleResponse<CreatedPost>>;
|
|
49
63
|
/** Delete a post */
|
|
50
64
|
delete(id: string): Promise<DeleteResponse>;
|
|
51
|
-
/**
|
|
52
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
|
|
67
|
+
* the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
|
|
68
|
+
* `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
|
|
69
|
+
*/
|
|
70
|
+
search(params: SearchPostsParams): Promise<SearchPostsResponse>;
|
|
53
71
|
/** Add or update a reaction on a post */
|
|
54
72
|
react(postId: string, type: ReactionType): Promise<SingleResponse<{
|
|
55
73
|
success: true;
|
package/dist/resources/posts.js
CHANGED
|
@@ -20,6 +20,22 @@ export class PostsResource {
|
|
|
20
20
|
liked(params) {
|
|
21
21
|
return this.list({ ...params, liked: true });
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
|
|
25
|
+
* `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
|
|
26
|
+
* Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
|
|
27
|
+
*/
|
|
28
|
+
bookmarked(params) {
|
|
29
|
+
return this.client.get('/posts', { bookmarked: true, ...params });
|
|
30
|
+
}
|
|
31
|
+
/** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
|
|
32
|
+
bookmark(postId) {
|
|
33
|
+
return this.client.put('/bookmarks', { kind: 'post', post_id: postId });
|
|
34
|
+
}
|
|
35
|
+
/** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
|
|
36
|
+
unbookmark(postId) {
|
|
37
|
+
return this.client.delete(`/bookmarks/${encodeURIComponent(`post:${postId}`)}`);
|
|
38
|
+
}
|
|
23
39
|
/** Posts similar to this one (embedding neighbours), in the app's scope. */
|
|
24
40
|
similar(id, params) {
|
|
25
41
|
return this.client.get(`/posts/${encodeURIComponent(id)}/similar`, params);
|
|
@@ -44,7 +60,11 @@ export class PostsResource {
|
|
|
44
60
|
delete(id) {
|
|
45
61
|
return this.client.delete(`/posts/${id}`);
|
|
46
62
|
}
|
|
47
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
|
|
65
|
+
* the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
|
|
66
|
+
* `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
|
|
67
|
+
*/
|
|
48
68
|
search(params) {
|
|
49
69
|
return this.client.get('/posts/search', params);
|
|
50
70
|
}
|