@recursiv/sdk 0.7.81 → 0.7.83
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 +45 -2
- package/dist/assistant/fixedCardData.d.ts +91 -0
- package/dist/assistant/fixedCardData.js +372 -0
- package/dist/assistant/fixedCards.d.ts +35 -0
- package/dist/assistant/fixedCards.js +904 -0
- package/dist/assistant/hiddenText.d.ts +24 -0
- package/dist/assistant/hiddenText.js +263 -0
- package/dist/assistant/index.d.ts +22 -0
- package/dist/assistant/index.js +22 -0
- package/dist/assistant/render.d.ts +8 -0
- package/dist/assistant/render.js +253 -0
- package/dist/assistant/subjectBundle.d.ts +30 -0
- package/dist/assistant/subjectBundle.js +518 -0
- package/dist/assistant/types.d.ts +353 -0
- package/dist/assistant/types.js +11 -0
- package/dist/assistant-surfaces.d.ts +42 -0
- package/dist/assistant-surfaces.js +113 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +2 -1
- package/dist/realtime.d.ts +10 -3
- package/dist/realtime.js +11 -2
- package/dist/resources/agents.d.ts +6 -1
- package/dist/resources/agents.js +12 -2
- package/dist/resources/chat.d.ts +73 -1
- package/dist/resources/chat.js +83 -1
- package/dist/resources/e2ee.d.ts +56 -0
- package/dist/resources/e2ee.js +21 -0
- package/dist/resources/link-preview.d.ts +42 -1
- package/dist/resources/link-preview.js +36 -2
- package/dist/resources/network.d.ts +12 -0
- package/dist/resources/project-brain.d.ts +8 -0
- package/dist/resources/reports.d.ts +24 -0
- package/dist/resources/reports.js +12 -0
- package/dist/resources/users.d.ts +11 -2
- package/dist/resources/users.js +1 -1
- package/dist/types.d.ts +34 -0
- package/llm.md +35 -3
- package/package.json +6 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildSubjectBundle`: what an assistant may read about a post, built from data the caller already holds. The
|
|
3
|
+
* server, a Sealed phone (no request at all) and an app's own ask all build it the same way, so the rules below hold
|
|
4
|
+
* wherever the ask starts:
|
|
5
|
+
*
|
|
6
|
+
* - posts and replies whose authors turned asks off are left out, and 'sealed_only' authors are read only in Sealed;
|
|
7
|
+
* - authors the asker blocked or muted, and authors who blocked the asker, are left out;
|
|
8
|
+
* - known-minor authors are left out unless the asker is that author;
|
|
9
|
+
* - posts under a child-safety hold, and posts a moderator turned the assistant off for, are left out, whoever asks;
|
|
10
|
+
* - a post the server's per-viewer verdict (`assist_readable`) says is not readable is left out, whoever asks;
|
|
11
|
+
* - pictures go as alt text and the stored caption only: never bytes, never an address (held media not even that);
|
|
12
|
+
* - every string loses what a reader cannot see (stripHiddenText) and is cut to its limit, and the bundle has a total
|
|
13
|
+
* that the asked-about post is cut to fit as well.
|
|
14
|
+
*
|
|
15
|
+
* Every safety flag fails closed: any value but `false`, `null` or absent counts as set, and an id list that cannot be
|
|
16
|
+
* read throws. When the asked-about post itself is left out the result has no bundle: make no model call. The
|
|
17
|
+
* exclusions are counts by reason, never ids or handles, and the reasons the asker may see fold blocks, minors, holds
|
|
18
|
+
* and suppression into one ('unavailable'); the detail is in `audit`, for the server's log only. Pure: no clock, no
|
|
19
|
+
* randomness, no network.
|
|
20
|
+
*/
|
|
21
|
+
import { findHandles, findUrls, mapUrls, stripHiddenText } from './hiddenText.js';
|
|
22
|
+
/** The default (and highest) limits. A caller may lower them, never raise them. */
|
|
23
|
+
export const SUBJECT_BUNDLE_LIMITS = Object.freeze({
|
|
24
|
+
max_replies: 20,
|
|
25
|
+
max_media: 8,
|
|
26
|
+
post_chars: 2000,
|
|
27
|
+
context_chars: 1000,
|
|
28
|
+
reply_chars: 500,
|
|
29
|
+
title_chars: 200,
|
|
30
|
+
name_chars: 64,
|
|
31
|
+
alt_text_chars: 300,
|
|
32
|
+
caption_chars: 600,
|
|
33
|
+
link_title_chars: 200,
|
|
34
|
+
link_description_chars: 300,
|
|
35
|
+
total_chars: 12_000,
|
|
36
|
+
});
|
|
37
|
+
/** Ids are opaque, but plain: nothing that could close a label or start a line. */
|
|
38
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
39
|
+
/** A plain handle, or a federated one with its server. Anything else is rendered as no handle, never mangled. */
|
|
40
|
+
const HANDLE_PATTERN = /^[A-Za-z0-9_](?:[A-Za-z0-9_.-]{0,62}[A-Za-z0-9_])?(?:@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)?$/;
|
|
41
|
+
const ISO_TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/i;
|
|
42
|
+
const WEB_ADDRESS = /^https?:\/\/[^\s<>"'`]+$/i;
|
|
43
|
+
const MAX_URL_LENGTH = 2048;
|
|
44
|
+
/** The host and path of a web address, without the URL global (not every phone runtime has a whole one). */
|
|
45
|
+
const URL_PARTS = /^https?:\/\/(?:[^/?#@]*@)?(\[[^\]]*\]|[^/?#:]*)(?::\d*)?([^?#]*)/i;
|
|
46
|
+
const PICTURE_FILE = /\.(?:png|jpe?g|jfif|gif|webp|avif|heic|heif|bmp|svg|ico|tiff?)$/i;
|
|
47
|
+
/** Hosts that serve members' pictures and clips: Minds' CDN, R2 and Bunny. */
|
|
48
|
+
const MEDIA_HOST = /(?:^|\.)(?:cdn\.minds\.com|r2\.dev|r2\.cloudflarestorage\.com|b-cdn\.net|bunnycdn\.com|cdn\.recursiv\.io)$/i;
|
|
49
|
+
/** Paths that serve them on any host: Minds' /fs/v1/thumbnail and kin, and /api/v2/media/proxy and kin. */
|
|
50
|
+
const MEDIA_PATH = /^\/(?:fs\/v\d+\/|api\/v\d+\/media\/)/i;
|
|
51
|
+
/** What a picture or clip address in a member's text becomes. */
|
|
52
|
+
export const PICTURE_LINK_LABEL = '[link to a picture or clip]';
|
|
53
|
+
const ELLIPSIS = '\u2026';
|
|
54
|
+
/** Characters as code points: what every limit counts, so an emoji is one character and a cut never splits one. */
|
|
55
|
+
export function textLength(text) {
|
|
56
|
+
let n = 0;
|
|
57
|
+
for (const _point of text)
|
|
58
|
+
n += 1;
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
function cut(text, limit) {
|
|
62
|
+
if (!text)
|
|
63
|
+
return { value: null, truncated: false };
|
|
64
|
+
if (text.length <= limit)
|
|
65
|
+
return { value: text, truncated: false };
|
|
66
|
+
// Count code points, so a cut never splits a surrogate pair.
|
|
67
|
+
const points = Array.from(text);
|
|
68
|
+
if (points.length <= limit)
|
|
69
|
+
return { value: text, truncated: false };
|
|
70
|
+
if (limit <= 0)
|
|
71
|
+
return { value: null, truncated: true };
|
|
72
|
+
return { value: `${points.slice(0, Math.max(0, limit - 1)).join('').trimEnd()}${ELLIPSIS}`, truncated: true };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A picture's or clip's address, which a model must never be handed: one ending in a picture file type, or on a host
|
|
76
|
+
* or path that serves members' media (Minds' own picture addresses have no file type).
|
|
77
|
+
*/
|
|
78
|
+
export function isPictureAddress(url) {
|
|
79
|
+
const parts = URL_PARTS.exec(url);
|
|
80
|
+
if (!parts)
|
|
81
|
+
return false;
|
|
82
|
+
const host = parts[1].toLowerCase().replace(/\.$/, '');
|
|
83
|
+
let path = parts[2];
|
|
84
|
+
try {
|
|
85
|
+
path = decodeURIComponent(path);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A path that does not decode is read as written.
|
|
89
|
+
}
|
|
90
|
+
return MEDIA_HOST.test(host) || MEDIA_PATH.test(path) || PICTURE_FILE.test(path.replace(/\/+$/, ''));
|
|
91
|
+
}
|
|
92
|
+
/** Text a member wrote as a reader sees it, with each picture address replaced by a label. */
|
|
93
|
+
function visible(value) {
|
|
94
|
+
return mapUrls(stripHiddenText(value), (url) => (isPictureAddress(url) ? PICTURE_LINK_LABEL : url));
|
|
95
|
+
}
|
|
96
|
+
/** One line of visible text: hidden text gone, spacing collapsed, cut to `limit`. */
|
|
97
|
+
function line(value, limit) {
|
|
98
|
+
if (typeof value !== 'string')
|
|
99
|
+
return { value: null, truncated: false };
|
|
100
|
+
return cut(visible(value).replace(/\s+/g, ' ').trim(), limit);
|
|
101
|
+
}
|
|
102
|
+
/** Visible text that keeps its paragraphs. */
|
|
103
|
+
function paragraphs(value, limit) {
|
|
104
|
+
if (typeof value !== 'string')
|
|
105
|
+
return { value: null, truncated: false };
|
|
106
|
+
const text = visible(value)
|
|
107
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
108
|
+
.replace(/ ?\n ?/g, '\n')
|
|
109
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
110
|
+
.trim();
|
|
111
|
+
return cut(text, limit);
|
|
112
|
+
}
|
|
113
|
+
export function plainId(value) {
|
|
114
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
|
115
|
+
return String(value);
|
|
116
|
+
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
|
|
117
|
+
}
|
|
118
|
+
export function plainHandle(value) {
|
|
119
|
+
if (typeof value !== 'string')
|
|
120
|
+
return null;
|
|
121
|
+
const handle = value.trim().replace(/^@/, '');
|
|
122
|
+
return HANDLE_PATTERN.test(handle) ? handle : null;
|
|
123
|
+
}
|
|
124
|
+
export function isoTime(value) {
|
|
125
|
+
if (typeof value !== 'string')
|
|
126
|
+
return null;
|
|
127
|
+
const match = ISO_TIME.exec(value.trim());
|
|
128
|
+
if (!match)
|
|
129
|
+
return null;
|
|
130
|
+
const fraction = match[3] ? match[3].slice(0, 4) : '';
|
|
131
|
+
const zone = match[4].length === 5 ? `${match[4].slice(0, 3)}:${match[4].slice(3)}` : match[4];
|
|
132
|
+
const time = Date.parse(`${match[1]}T${match[2]}${fraction}${zone.toUpperCase()}`);
|
|
133
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
134
|
+
}
|
|
135
|
+
export function webAddress(value) {
|
|
136
|
+
if (typeof value !== 'string')
|
|
137
|
+
return null;
|
|
138
|
+
const url = stripHiddenText(value).trim();
|
|
139
|
+
if (!url || url.length > MAX_URL_LENGTH || !WEB_ADDRESS.test(url))
|
|
140
|
+
return null;
|
|
141
|
+
// A link card that points at a picture would hand the model a picture's address.
|
|
142
|
+
return isPictureAddress(url) ? null : url;
|
|
143
|
+
}
|
|
144
|
+
export function count(value) {
|
|
145
|
+
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
|
|
146
|
+
}
|
|
147
|
+
function counts(value) {
|
|
148
|
+
if (!value || typeof value !== 'object')
|
|
149
|
+
return null;
|
|
150
|
+
const out = {};
|
|
151
|
+
for (const key of ['votes', 'upvotes', 'downvotes', 'replies', 'reposts']) {
|
|
152
|
+
const n = count(value[key]);
|
|
153
|
+
if (n === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
if (key !== 'votes' && n < 0)
|
|
156
|
+
continue;
|
|
157
|
+
out[key] = n;
|
|
158
|
+
}
|
|
159
|
+
return Object.keys(out).length ? out : null;
|
|
160
|
+
}
|
|
161
|
+
function mediaType(value) {
|
|
162
|
+
const type = typeof value === 'string' ? value.toLowerCase() : '';
|
|
163
|
+
if (type === 'image' || type === 'photo' || type === 'gif' || type.startsWith('image/'))
|
|
164
|
+
return 'image';
|
|
165
|
+
if (type === 'video' || type === 'clip' || type.startsWith('video/'))
|
|
166
|
+
return 'video';
|
|
167
|
+
if (type === 'audio' || type === 'sound' || type.startsWith('audio/'))
|
|
168
|
+
return 'audio';
|
|
169
|
+
return 'other';
|
|
170
|
+
}
|
|
171
|
+
/** The author's choice as one of the three, failing closed on a value this SDK does not know. */
|
|
172
|
+
export function resolveAskChoice(value) {
|
|
173
|
+
if (value === undefined || value === null || value === true || value === 'all')
|
|
174
|
+
return 'all';
|
|
175
|
+
if (value === 'sealed_only')
|
|
176
|
+
return 'sealed_only';
|
|
177
|
+
return 'off';
|
|
178
|
+
}
|
|
179
|
+
/** The server's verdict, or null when it gave none; a value this SDK does not know means 'none'. */
|
|
180
|
+
function resolveReadable(value) {
|
|
181
|
+
if (value === undefined || value === null)
|
|
182
|
+
return null;
|
|
183
|
+
return value === 'all' || value === 'sealed_only' ? value : 'none';
|
|
184
|
+
}
|
|
185
|
+
/** A safety flag, failing closed: anything but false, null or absent (a record, a time, 1, 't') counts as set. */
|
|
186
|
+
function isSet(value) {
|
|
187
|
+
return value !== undefined && value !== null && value !== false;
|
|
188
|
+
}
|
|
189
|
+
/** An id list. One that is present but cannot be read throws: a block list must never count as empty by mistake. */
|
|
190
|
+
function toSet(values, name) {
|
|
191
|
+
const out = new Set();
|
|
192
|
+
if (values === undefined || values === null)
|
|
193
|
+
return out;
|
|
194
|
+
if (typeof values !== 'object' || typeof values[Symbol.iterator] !== 'function') {
|
|
195
|
+
throw new TypeError(`buildSubjectBundle: asker.${name} must be an array or a set of ids`);
|
|
196
|
+
}
|
|
197
|
+
for (const value of values) {
|
|
198
|
+
if (typeof value === 'string') {
|
|
199
|
+
if (value)
|
|
200
|
+
out.add(value);
|
|
201
|
+
}
|
|
202
|
+
else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
203
|
+
out.add(String(value));
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
throw new TypeError(`buildSubjectBundle: asker.${name} holds an entry that is not an id`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
function resolveLimits(overrides) {
|
|
212
|
+
const limits = { ...SUBJECT_BUNDLE_LIMITS };
|
|
213
|
+
if (!overrides)
|
|
214
|
+
return limits;
|
|
215
|
+
for (const key of Object.keys(limits)) {
|
|
216
|
+
const value = overrides[key];
|
|
217
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
218
|
+
limits[key] = Math.max(0, Math.min(SUBJECT_BUNDLE_LIMITS[key], Math.floor(value)));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return limits;
|
|
222
|
+
}
|
|
223
|
+
/** Why `post` may not be read by this asker, or null when it may. Order matters only for which reason is counted. */
|
|
224
|
+
function ruleOut(post, rules) {
|
|
225
|
+
if (!post || typeof post !== 'object' || plainId(post.id) === null)
|
|
226
|
+
return 'invalid';
|
|
227
|
+
if (isSet(post.child_safety_hold))
|
|
228
|
+
return 'child_safety_hold';
|
|
229
|
+
if (isSet(post.assistant_suppressed))
|
|
230
|
+
return 'moderator_suppressed';
|
|
231
|
+
// The server's verdict is for this viewer already, the author included.
|
|
232
|
+
const verdict = resolveReadable(post.assist_readable);
|
|
233
|
+
if (verdict === 'none')
|
|
234
|
+
return 'not_readable';
|
|
235
|
+
if (verdict === 'sealed_only' && rules.mode !== 'sealed')
|
|
236
|
+
return 'author_sealed_only';
|
|
237
|
+
const authorId = post.author && typeof post.author.id === 'string' ? post.author.id : '';
|
|
238
|
+
if (!authorId)
|
|
239
|
+
return 'unknown_author';
|
|
240
|
+
// The asker's own posts: their blocks, age and ask choice are theirs to waive.
|
|
241
|
+
if (authorId === rules.askerId)
|
|
242
|
+
return null;
|
|
243
|
+
if (rules.walls.has(authorId))
|
|
244
|
+
return 'blocked_or_muted';
|
|
245
|
+
if (isSet(post.author?.known_minor))
|
|
246
|
+
return 'known_minor_author';
|
|
247
|
+
const choice = resolveAskChoice(post.ai_ask_allowed);
|
|
248
|
+
if (choice === 'off')
|
|
249
|
+
return 'author_opted_out';
|
|
250
|
+
if (choice === 'sealed_only' && rules.mode !== 'sealed')
|
|
251
|
+
return 'author_sealed_only';
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const UNAVAILABLE = new Set([
|
|
255
|
+
'child_safety_hold', 'moderator_suppressed', 'blocked_or_muted', 'known_minor_author', 'media_held', 'not_readable',
|
|
256
|
+
]);
|
|
257
|
+
/** A reason as the asker may be told it: blocks, minors, holds, suppression and a 'none' verdict all read the same. */
|
|
258
|
+
function askerReason(reason) {
|
|
259
|
+
return UNAVAILABLE.has(reason) ? 'unavailable' : reason;
|
|
260
|
+
}
|
|
261
|
+
class Exclusions {
|
|
262
|
+
counts = new Map();
|
|
263
|
+
add(role, reason, n = 1) {
|
|
264
|
+
if (n <= 0)
|
|
265
|
+
return;
|
|
266
|
+
const key = `${role}:${reason}`;
|
|
267
|
+
const entry = this.counts.get(key);
|
|
268
|
+
if (entry)
|
|
269
|
+
entry.count += n;
|
|
270
|
+
else
|
|
271
|
+
this.counts.set(key, { role, reason, count: n });
|
|
272
|
+
}
|
|
273
|
+
/** The detail, for the server's log. */
|
|
274
|
+
detailed() {
|
|
275
|
+
return [...this.counts.values()].map((entry) => ({ ...entry }));
|
|
276
|
+
}
|
|
277
|
+
/** What the asker may be told: the same counts, with the reasons behind 'unavailable' merged. */
|
|
278
|
+
forAsker() {
|
|
279
|
+
const merged = new Map();
|
|
280
|
+
for (const entry of this.counts.values()) {
|
|
281
|
+
const reason = askerReason(entry.reason);
|
|
282
|
+
const key = `${entry.role}:${reason}`;
|
|
283
|
+
const found = merged.get(key);
|
|
284
|
+
if (found)
|
|
285
|
+
found.count += entry.count;
|
|
286
|
+
else
|
|
287
|
+
merged.set(key, { role: entry.role, reason, count: entry.count });
|
|
288
|
+
}
|
|
289
|
+
return [...merged.values()];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function result(bundle, refused, excluded) {
|
|
293
|
+
return {
|
|
294
|
+
bundle,
|
|
295
|
+
refused: refused ? askerReason(refused) : null,
|
|
296
|
+
excluded: excluded.forAsker(),
|
|
297
|
+
audit: { refused, excluded: excluded.detailed() },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const TEXT_LIMIT = {
|
|
301
|
+
subject: 'post_chars',
|
|
302
|
+
parent: 'context_chars',
|
|
303
|
+
quoted: 'context_chars',
|
|
304
|
+
reply: 'reply_chars',
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* The item for `post`. Each string is cut to its field limit and, for the asked-about post, to what is left of
|
|
308
|
+
* `budget` (the total limit), in this order: words, title, display name, alt text, stored caption, link card. Other
|
|
309
|
+
* items pass no budget: one that does not fit the total is left out whole.
|
|
310
|
+
*/
|
|
311
|
+
function buildItem(post, role, limits, excluded, budget = Number.POSITIVE_INFINITY) {
|
|
312
|
+
let truncated = false;
|
|
313
|
+
let left = budget;
|
|
314
|
+
/** A field limit, lowered to what is left of the budget. */
|
|
315
|
+
const room = (limit) => Math.min(limit, Math.max(0, left));
|
|
316
|
+
const take = (clean) => {
|
|
317
|
+
truncated ||= clean.truncated;
|
|
318
|
+
if (clean.value)
|
|
319
|
+
left -= textLength(clean.value);
|
|
320
|
+
return clean.value;
|
|
321
|
+
};
|
|
322
|
+
const text = take(paragraphs(post.content, room(limits[TEXT_LIMIT[role]]))) ?? '';
|
|
323
|
+
const title = take(line(post.title, room(limits.title_chars)));
|
|
324
|
+
const name = take(line(post.author?.name, room(limits.name_chars)));
|
|
325
|
+
const media = [];
|
|
326
|
+
let held = 0;
|
|
327
|
+
let overLimit = 0;
|
|
328
|
+
for (const item of Array.isArray(post.media) ? post.media : []) {
|
|
329
|
+
if (!item || typeof item !== 'object')
|
|
330
|
+
continue;
|
|
331
|
+
if (isSet(item.held)) {
|
|
332
|
+
held += 1;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (media.length >= limits.max_media) {
|
|
336
|
+
overLimit += 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
media.push({ type: mediaType(item.type), alt_text: take(line(item.alt_text, room(limits.alt_text_chars))) });
|
|
340
|
+
}
|
|
341
|
+
excluded.add('media', 'media_held', held);
|
|
342
|
+
excluded.add('media', 'media_limit', overLimit);
|
|
343
|
+
// The stored caption describes the post's picture: when any of its media is held, it may describe that one.
|
|
344
|
+
let aiCaption = null;
|
|
345
|
+
if (held > 0) {
|
|
346
|
+
if (typeof post.ai_caption === 'string' && post.ai_caption.trim())
|
|
347
|
+
excluded.add('caption', 'media_held');
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
aiCaption = take(line(post.ai_caption, room(limits.caption_chars)));
|
|
351
|
+
}
|
|
352
|
+
let linkCard = null;
|
|
353
|
+
if (post.link_card && typeof post.link_card === 'object') {
|
|
354
|
+
const linkTitle = take(line(post.link_card.title, room(limits.link_title_chars)));
|
|
355
|
+
const description = take(line(post.link_card.description, room(limits.link_description_chars)));
|
|
356
|
+
// An address is never cut: one that does not fit what is left is left out.
|
|
357
|
+
let url = webAddress(post.link_card.url);
|
|
358
|
+
if (url && textLength(url) > left) {
|
|
359
|
+
url = null;
|
|
360
|
+
truncated = true;
|
|
361
|
+
}
|
|
362
|
+
if (url)
|
|
363
|
+
left -= textLength(url);
|
|
364
|
+
if (url || linkTitle || description)
|
|
365
|
+
linkCard = { url, title: linkTitle, description };
|
|
366
|
+
}
|
|
367
|
+
return {
|
|
368
|
+
role,
|
|
369
|
+
id: plainId(post.id),
|
|
370
|
+
author: {
|
|
371
|
+
id: post.author.id,
|
|
372
|
+
username: plainHandle(post.author?.username),
|
|
373
|
+
name,
|
|
374
|
+
},
|
|
375
|
+
created_at: isoTime(post.created_at),
|
|
376
|
+
title,
|
|
377
|
+
text,
|
|
378
|
+
media,
|
|
379
|
+
ai_caption: aiCaption,
|
|
380
|
+
link_card: linkCard,
|
|
381
|
+
counts: counts(post.counts),
|
|
382
|
+
truncated,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/** Characters of member-written text in an item: what the total limit counts. */
|
|
386
|
+
export function itemChars(item) {
|
|
387
|
+
const strings = [item.text, item.title, item.author.name, item.ai_caption, ...item.media.map((media) => media.alt_text)];
|
|
388
|
+
if (item.link_card)
|
|
389
|
+
strings.push(item.link_card.url, item.link_card.title, item.link_card.description);
|
|
390
|
+
return strings.reduce((n, text) => n + (text ? textLength(text) : 0), 0);
|
|
391
|
+
}
|
|
392
|
+
function hasWords(item) {
|
|
393
|
+
return Boolean(item.text || item.title || item.ai_caption || item.link_card || item.media.some((media) => media.alt_text));
|
|
394
|
+
}
|
|
395
|
+
/** The member-written strings of an item, where an address or a handle may appear. */
|
|
396
|
+
export function itemTexts(item) {
|
|
397
|
+
const texts = [item.text, item.title, item.author.name, item.ai_caption];
|
|
398
|
+
for (const media of item.media)
|
|
399
|
+
texts.push(media.alt_text);
|
|
400
|
+
if (item.link_card)
|
|
401
|
+
texts.push(item.link_card.title, item.link_card.description);
|
|
402
|
+
return texts.filter((text) => typeof text === 'string' && text.length > 0);
|
|
403
|
+
}
|
|
404
|
+
/** Every id, handle and web address the items hold, in order, each once. Never a picture or media address. */
|
|
405
|
+
export function referencesOf(items) {
|
|
406
|
+
const ids = new Set();
|
|
407
|
+
const handles = new Set();
|
|
408
|
+
const urls = new Set();
|
|
409
|
+
const addUrl = (url) => {
|
|
410
|
+
if (!isPictureAddress(url))
|
|
411
|
+
urls.add(url);
|
|
412
|
+
};
|
|
413
|
+
for (const item of items) {
|
|
414
|
+
ids.add(item.id);
|
|
415
|
+
if (item.author.username)
|
|
416
|
+
handles.add(item.author.username);
|
|
417
|
+
if (item.link_card?.url)
|
|
418
|
+
addUrl(item.link_card.url);
|
|
419
|
+
for (const text of itemTexts(item)) {
|
|
420
|
+
for (const handle of findHandles(text))
|
|
421
|
+
handles.add(handle);
|
|
422
|
+
for (const url of findUrls(text))
|
|
423
|
+
addUrl(url);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { ids: [...ids], handles: [...handles], urls: [...urls] };
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* What an assistant may read about `input.post`, with its parent, quoted post and replies, for this asker in this
|
|
430
|
+
* mode. See the module comment for the rules.
|
|
431
|
+
*/
|
|
432
|
+
export function buildSubjectBundle(input) {
|
|
433
|
+
if (!input || typeof input !== 'object')
|
|
434
|
+
throw new TypeError('buildSubjectBundle needs an input object');
|
|
435
|
+
const asker = input.asker;
|
|
436
|
+
if (!asker || typeof asker.id !== 'string' || !asker.id)
|
|
437
|
+
throw new TypeError('buildSubjectBundle needs asker.id');
|
|
438
|
+
if (!input.post || typeof input.post !== 'object')
|
|
439
|
+
throw new TypeError('buildSubjectBundle needs the post');
|
|
440
|
+
const mode = input.mode === 'sealed' ? 'sealed' : 'standard';
|
|
441
|
+
const rules = {
|
|
442
|
+
mode,
|
|
443
|
+
askerId: asker.id,
|
|
444
|
+
walls: new Set([
|
|
445
|
+
...toSet(asker.blocked_ids, 'blocked_ids'),
|
|
446
|
+
...toSet(asker.muted_ids, 'muted_ids'),
|
|
447
|
+
...toSet(asker.blocked_by_ids, 'blocked_by_ids'),
|
|
448
|
+
]),
|
|
449
|
+
};
|
|
450
|
+
const limits = resolveLimits(input.limits);
|
|
451
|
+
const excluded = new Exclusions();
|
|
452
|
+
const refused = ruleOut(input.post, rules);
|
|
453
|
+
if (refused) {
|
|
454
|
+
excluded.add('subject', refused);
|
|
455
|
+
return result(null, refused, excluded);
|
|
456
|
+
}
|
|
457
|
+
const seen = new Set();
|
|
458
|
+
// The asked-about post is always kept, cut to fit the total; what follows it is added only while it fits.
|
|
459
|
+
const subject = buildItem(input.post, 'subject', limits, excluded, limits.total_chars);
|
|
460
|
+
seen.add(subject.id);
|
|
461
|
+
let chars = itemChars(subject);
|
|
462
|
+
/** The item for `post` in `role`, or null with the reason counted. */
|
|
463
|
+
const admit = (post, role) => {
|
|
464
|
+
const reason = ruleOut(post, rules);
|
|
465
|
+
if (reason) {
|
|
466
|
+
excluded.add(role, reason);
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const id = plainId(post.id);
|
|
470
|
+
if (seen.has(id)) {
|
|
471
|
+
excluded.add(role, 'duplicate');
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
const item = buildItem(post, role, limits, excluded);
|
|
475
|
+
if (!hasWords(item)) {
|
|
476
|
+
excluded.add(role, 'empty');
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const size = itemChars(item);
|
|
480
|
+
if (chars + size > limits.total_chars) {
|
|
481
|
+
excluded.add(role, 'size_limit');
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
seen.add(id);
|
|
485
|
+
chars += size;
|
|
486
|
+
return item;
|
|
487
|
+
};
|
|
488
|
+
const parent = input.parent ? admit(input.parent, 'parent') : null;
|
|
489
|
+
const quoted = input.quoted ? admit(input.quoted, 'quoted') : null;
|
|
490
|
+
const replies = [];
|
|
491
|
+
for (const reply of Array.isArray(input.replies) ? input.replies : []) {
|
|
492
|
+
// Rules first, so a left-out reply never takes one of the kept places.
|
|
493
|
+
const reason = ruleOut(reply, rules);
|
|
494
|
+
if (reason) {
|
|
495
|
+
excluded.add('reply', reason);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (replies.length >= limits.max_replies) {
|
|
499
|
+
excluded.add('reply', 'reply_limit');
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
const item = admit(reply, 'reply');
|
|
503
|
+
if (item)
|
|
504
|
+
replies.push(item);
|
|
505
|
+
}
|
|
506
|
+
const items = [subject, ...(parent ? [parent] : []), ...(quoted ? [quoted] : []), ...replies];
|
|
507
|
+
const bundle = {
|
|
508
|
+
version: 1,
|
|
509
|
+
mode,
|
|
510
|
+
subject,
|
|
511
|
+
parent,
|
|
512
|
+
quoted,
|
|
513
|
+
replies,
|
|
514
|
+
references: referencesOf(items),
|
|
515
|
+
chars,
|
|
516
|
+
};
|
|
517
|
+
return result(bundle, null, excluded);
|
|
518
|
+
}
|