reddit-mcp-server 1.4.8 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +14 -13
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/index.js +584 -191
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
package/dist/index.js
CHANGED
|
@@ -3,6 +3,93 @@ import dotenv from "dotenv";
|
|
|
3
3
|
import { FastMCP } from "fastmcp";
|
|
4
4
|
import { Left, Option, Right, Try } from "functype";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
//#region src/client/errors.ts
|
|
7
|
+
/**
|
|
8
|
+
* Typed error channel for the Reddit client.
|
|
9
|
+
*
|
|
10
|
+
* The client's methods are the imperative-to-functional boundary: each captures throws
|
|
11
|
+
* (network, HTTP, JSON parsing, validation, deliberate domain errors) inside a `Try` and
|
|
12
|
+
* converts to a typed `Either<RedditError, T>`. Modelling the error as a discriminated ADT
|
|
13
|
+
* — rather than a bare `Error` — makes the failure contract explicit at the type level, so
|
|
14
|
+
* callers can reason about (and branch on) what actually went wrong without string-matching.
|
|
15
|
+
*
|
|
16
|
+
* `classifyRedditError` is TOTAL and never re-throws — every captured `Error` maps to a
|
|
17
|
+
* variant — which is what makes the migration behavior-preserving: an error that was a
|
|
18
|
+
* graceful `Left` before is still a graceful `Left` after, just with a richer type.
|
|
19
|
+
*
|
|
20
|
+
* Two-tier behavior, to preserve the exact messages of the original try/catch code:
|
|
21
|
+
* - A *deliberate* typed throw (HttpError, NotFoundError, …) already carries its final
|
|
22
|
+
* message, so it passes through unchanged.
|
|
23
|
+
* - An *unexpected* generic error (fetch/JSON/orThrow) is wrapped as UnknownError, with the
|
|
24
|
+
* optional `context` prefix — present for read methods (which prefixed in their catch),
|
|
25
|
+
* absent for write methods (which returned the raw message).
|
|
26
|
+
*/
|
|
27
|
+
var RedditErrorBase = class extends Error {};
|
|
28
|
+
/** A non-ok HTTP response from the Reddit API. Carries the status for caller branching. */
|
|
29
|
+
var HttpError = class extends RedditErrorBase {
|
|
30
|
+
status;
|
|
31
|
+
_tag = "HttpError";
|
|
32
|
+
constructor(status, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.status = status;
|
|
35
|
+
this.name = "HttpError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/** A write operation was attempted without the required user credentials / in a wrong mode. */
|
|
39
|
+
var NotAuthenticatedError = class extends RedditErrorBase {
|
|
40
|
+
_tag = "NotAuthenticatedError";
|
|
41
|
+
constructor(message) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "NotAuthenticatedError";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** Reddit accepted the request but returned errors in its JSON envelope (or an unusable body). */
|
|
47
|
+
var ApiError = class extends RedditErrorBase {
|
|
48
|
+
_tag = "ApiError";
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "ApiError";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** The requested post/entity does not exist or is not accessible. */
|
|
55
|
+
var NotFoundError = class extends RedditErrorBase {
|
|
56
|
+
_tag = "NotFoundError";
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "NotFoundError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** Client-side input or safety-policy rejection (invalid sort, duplicate-content guard). */
|
|
63
|
+
var ValidationError = class extends RedditErrorBase {
|
|
64
|
+
_tag = "ValidationError";
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "ValidationError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/** Any failure that is not a recognized category: network, JSON parsing, unexpected throws. */
|
|
71
|
+
var UnknownError = class extends RedditErrorBase {
|
|
72
|
+
cause;
|
|
73
|
+
_tag = "UnknownError";
|
|
74
|
+
constructor(message, cause) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.cause = cause;
|
|
77
|
+
this.name = "UnknownError";
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
function isRedditError(error) {
|
|
81
|
+
return error instanceof RedditErrorBase;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Total classifier from a captured `Error` to a `RedditError`. Deliberate typed throws pass
|
|
85
|
+
* through unchanged; everything else becomes an `UnknownError`, prefixed with `context` when
|
|
86
|
+
* provided so the observable message text matches the previous try/catch-based wrapping.
|
|
87
|
+
*/
|
|
88
|
+
function classifyRedditError(error, context) {
|
|
89
|
+
if (isRedditError(error)) return error;
|
|
90
|
+
return new UnknownError(Option(context).fold(() => error.message, (ctx) => `${ctx}: ${error.message}`), error);
|
|
91
|
+
}
|
|
92
|
+
//#endregion
|
|
6
93
|
//#region src/client/response-cache.ts
|
|
7
94
|
const SECOND = 1e3;
|
|
8
95
|
var ResponseCache = class {
|
|
@@ -65,8 +152,13 @@ var ResponseCache = class {
|
|
|
65
152
|
};
|
|
66
153
|
//#endregion
|
|
67
154
|
//#region src/client/reddit-client.ts
|
|
68
|
-
function
|
|
69
|
-
|
|
155
|
+
function listingCursor(data) {
|
|
156
|
+
const after = typeof data.after === "string" ? { after: data.after } : {};
|
|
157
|
+
const before = typeof data.before === "string" ? { before: data.before } : {};
|
|
158
|
+
return {
|
|
159
|
+
...after,
|
|
160
|
+
...before
|
|
161
|
+
};
|
|
70
162
|
}
|
|
71
163
|
function parsePostData(post) {
|
|
72
164
|
return {
|
|
@@ -100,6 +192,7 @@ var RedditClient = class {
|
|
|
100
192
|
safeMode;
|
|
101
193
|
botDisclosure;
|
|
102
194
|
cache;
|
|
195
|
+
retry;
|
|
103
196
|
accessToken;
|
|
104
197
|
tokenExpiry = 0;
|
|
105
198
|
authenticated = false;
|
|
@@ -127,6 +220,11 @@ var RedditClient = class {
|
|
|
127
220
|
footer: ""
|
|
128
221
|
};
|
|
129
222
|
this.cache = ((_config$cache = config.cache) === null || _config$cache === void 0 ? void 0 : _config$cache.enabled) === true ? new ResponseCache({ maxBytes: config.cache.maxBytes }) : void 0;
|
|
223
|
+
this.retry = config.retry ?? {
|
|
224
|
+
maxRetries: 3,
|
|
225
|
+
baseDelayMs: 1e3,
|
|
226
|
+
maxDelayMs: 6e4
|
|
227
|
+
};
|
|
130
228
|
}
|
|
131
229
|
determineBaseUrl() {
|
|
132
230
|
switch (this.authMode) {
|
|
@@ -136,13 +234,13 @@ var RedditClient = class {
|
|
|
136
234
|
}
|
|
137
235
|
}
|
|
138
236
|
async makeRequest(path, options = {}) {
|
|
139
|
-
|
|
237
|
+
return (await Try.async(async () => {
|
|
140
238
|
const url = `${this.baseUrl}${path}`;
|
|
141
239
|
const method = (options.method ?? "GET").toUpperCase();
|
|
142
240
|
const cacheable = this.cache !== void 0 && method === "GET";
|
|
143
241
|
if (cacheable) {
|
|
144
242
|
const cached = this.cache.get(url);
|
|
145
|
-
if (cached !== void 0) return
|
|
243
|
+
if (cached !== void 0) return new Response(cached.body, { status: cached.status });
|
|
146
244
|
}
|
|
147
245
|
const requiresAuth = this.authMode === "authenticated" || this.authMode === "auto" && this.hasCredentials;
|
|
148
246
|
if (requiresAuth && (Date.now() >= this.tokenExpiry || !this.authenticated)) (await this.authenticate()).orThrow();
|
|
@@ -151,30 +249,18 @@ var RedditClient = class {
|
|
|
151
249
|
...options.headers
|
|
152
250
|
};
|
|
153
251
|
if (requiresAuth && this.accessToken !== void 0) headers["Authorization"] = `Bearer ${this.accessToken}`;
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
headers
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
(await this.authenticate()).orThrow();
|
|
160
|
-
const retryHeaders = {
|
|
161
|
-
...headers,
|
|
162
|
-
Authorization: `Bearer ${this.accessToken}`
|
|
163
|
-
};
|
|
164
|
-
return Right(await fetch(url, {
|
|
165
|
-
...options,
|
|
166
|
-
headers: retryHeaders
|
|
167
|
-
}));
|
|
168
|
-
}
|
|
252
|
+
const first = await this.fetchWithRetry(url, options, headers, path, 0);
|
|
253
|
+
const response = first.status === 401 && this.authenticated ? await this.fetchWithRetry(url, options, {
|
|
254
|
+
...headers,
|
|
255
|
+
Authorization: await this.reauthorize()
|
|
256
|
+
}, path, 0) : first;
|
|
169
257
|
if (cacheable && response.ok) {
|
|
170
258
|
const text = await response.text();
|
|
171
259
|
this.cache.set(url, text, response.status);
|
|
172
|
-
return
|
|
260
|
+
return new Response(text, { status: response.status });
|
|
173
261
|
}
|
|
174
|
-
return
|
|
175
|
-
}
|
|
176
|
-
return Left(toError(error));
|
|
177
|
-
}
|
|
262
|
+
return response;
|
|
263
|
+
})).toEither((error) => error);
|
|
178
264
|
}
|
|
179
265
|
async authenticate() {
|
|
180
266
|
if (this.authMode === "anonymous") {
|
|
@@ -186,9 +272,9 @@ var RedditClient = class {
|
|
|
186
272
|
this.authenticated = false;
|
|
187
273
|
return Right(void 0);
|
|
188
274
|
}
|
|
189
|
-
|
|
275
|
+
return (await Try.async(async () => {
|
|
190
276
|
const now = Date.now();
|
|
191
|
-
if (this.accessToken !== void 0 && now < this.tokenExpiry) return
|
|
277
|
+
if (this.accessToken !== void 0 && now < this.tokenExpiry) return;
|
|
192
278
|
const authUrl = "https://www.reddit.com/api/v1/access_token";
|
|
193
279
|
const authData = new URLSearchParams();
|
|
194
280
|
const { username } = this;
|
|
@@ -210,16 +296,13 @@ var RedditClient = class {
|
|
|
210
296
|
});
|
|
211
297
|
if (!response.ok) {
|
|
212
298
|
const statusText = response.statusText !== "" ? response.statusText : "Unknown Error";
|
|
213
|
-
|
|
299
|
+
throw new Error(`Authentication failed: ${response.status} ${statusText}`);
|
|
214
300
|
}
|
|
215
301
|
const data = await response.json();
|
|
216
302
|
this.accessToken = data.access_token;
|
|
217
303
|
this.tokenExpiry = now + data.expires_in * 1e3;
|
|
218
304
|
this.authenticated = true;
|
|
219
|
-
|
|
220
|
-
} catch (error) {
|
|
221
|
-
return Left(toError(error));
|
|
222
|
-
}
|
|
305
|
+
})).toEither((error) => error);
|
|
223
306
|
}
|
|
224
307
|
async checkAuthentication() {
|
|
225
308
|
if (!this.authenticated) return (await this.authenticate()).isRight();
|
|
@@ -227,8 +310,8 @@ var RedditClient = class {
|
|
|
227
310
|
}
|
|
228
311
|
validateWriteAccess() {
|
|
229
312
|
if (this.username === void 0 || this.password === void 0) {
|
|
230
|
-
if (this.authMode === "anonymous") throw new
|
|
231
|
-
throw new
|
|
313
|
+
if (this.authMode === "anonymous") throw new NotAuthenticatedError("Write operations not available in anonymous mode. Set REDDIT_USERNAME, REDDIT_PASSWORD and use 'auto' or 'authenticated' mode.");
|
|
314
|
+
throw new NotAuthenticatedError("Write operations require REDDIT_USERNAME and REDDIT_PASSWORD");
|
|
232
315
|
}
|
|
233
316
|
}
|
|
234
317
|
async enforceWriteRateLimit() {
|
|
@@ -249,8 +332,8 @@ var RedditClient = class {
|
|
|
249
332
|
const hash = this.hashContent(content);
|
|
250
333
|
const duplicate = this.recentContentRecords.find((record) => record.hash === hash);
|
|
251
334
|
if (duplicate !== void 0) {
|
|
252
|
-
if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new
|
|
253
|
-
throw new
|
|
335
|
+
if (subreddit !== void 0 && duplicate.subreddit !== "" && subreddit !== duplicate.subreddit) throw new ValidationError("Cross-subreddit duplicate detected. Reddit's Responsible Builder Policy prohibits posting identical or substantially similar content across multiple subreddits. Please create unique content for each subreddit.");
|
|
336
|
+
throw new ValidationError("Duplicate content detected. Reddit's spam filter may ban your account for posting identical content. Please modify your content and try again.");
|
|
254
337
|
}
|
|
255
338
|
this.recentContentRecords.push({
|
|
256
339
|
hash,
|
|
@@ -259,16 +342,49 @@ var RedditClient = class {
|
|
|
259
342
|
});
|
|
260
343
|
this.recentContentRecords = this.recentContentRecords.slice(-this.safeMode.maxRecentHashes);
|
|
261
344
|
}
|
|
345
|
+
async reauthorize() {
|
|
346
|
+
(await this.authenticate()).orThrow();
|
|
347
|
+
return `Bearer ${this.accessToken}`;
|
|
348
|
+
}
|
|
349
|
+
async fetchWithRetry(url, options, headers, path, attempt) {
|
|
350
|
+
const response = await fetch(url, {
|
|
351
|
+
...options,
|
|
352
|
+
headers
|
|
353
|
+
});
|
|
354
|
+
if (response.status !== 429 || attempt >= this.retry.maxRetries) return response;
|
|
355
|
+
const wait = this.retryAfterMs(response).fold(() => Math.min(this.retry.baseDelayMs * 2 ** attempt, this.retry.maxDelayMs), (ms) => ms);
|
|
356
|
+
if (wait > this.retry.maxDelayMs) return response;
|
|
357
|
+
console.error(`[RateLimit] 429 from ${path} — retry ${attempt + 1}/${this.retry.maxRetries} in ${wait}ms`);
|
|
358
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
359
|
+
return this.fetchWithRetry(url, options, headers, path, attempt + 1);
|
|
360
|
+
}
|
|
361
|
+
retryAfterMs(response) {
|
|
362
|
+
const { headers } = response;
|
|
363
|
+
const retryAfter = headers.get("retry-after");
|
|
364
|
+
if (retryAfter !== null && retryAfter !== "") {
|
|
365
|
+
const seconds = Number(retryAfter);
|
|
366
|
+
if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
|
|
367
|
+
const when = Date.parse(retryAfter);
|
|
368
|
+
if (!Number.isNaN(when)) return Option(Math.max(0, when - Date.now()));
|
|
369
|
+
}
|
|
370
|
+
const reset = headers.get("x-ratelimit-reset");
|
|
371
|
+
if (reset !== null && reset !== "") {
|
|
372
|
+
const seconds = Number(reset);
|
|
373
|
+
if (!Number.isNaN(seconds)) return Option(seconds * 1e3);
|
|
374
|
+
}
|
|
375
|
+
return Option.none();
|
|
376
|
+
}
|
|
262
377
|
appendBotDisclosure(content) {
|
|
263
378
|
if (!this.botDisclosure.enabled || this.botDisclosure.footer === "") return content;
|
|
264
379
|
return `${content}${this.botDisclosure.footer}`;
|
|
265
380
|
}
|
|
266
381
|
async getUser(username) {
|
|
267
|
-
|
|
382
|
+
const context = `Failed to get user info for ${username}`;
|
|
383
|
+
return (await Try.async(async () => {
|
|
268
384
|
const response = (await this.makeRequest(`/user/${username}/about.json`)).orThrow();
|
|
269
|
-
if (!response.ok)
|
|
385
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
270
386
|
const { data } = await response.json();
|
|
271
|
-
return
|
|
387
|
+
return {
|
|
272
388
|
name: data.name,
|
|
273
389
|
id: data.id,
|
|
274
390
|
commentKarma: data.comment_karma,
|
|
@@ -279,17 +395,79 @@ var RedditClient = class {
|
|
|
279
395
|
isEmployee: data.is_employee,
|
|
280
396
|
createdUtc: data.created_utc,
|
|
281
397
|
profileUrl: `https://reddit.com/user/${data.name}`
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
398
|
+
};
|
|
399
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
400
|
+
}
|
|
401
|
+
async getUserContent(path, context) {
|
|
402
|
+
return (await Try.async(async () => {
|
|
403
|
+
const response = (await this.makeRequest(path)).orThrow();
|
|
404
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
405
|
+
const json = await response.json();
|
|
406
|
+
return {
|
|
407
|
+
posts: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
408
|
+
comments: json.data.children.filter((child) => child.kind === "t1").map((child) => {
|
|
409
|
+
const comment = child.data;
|
|
410
|
+
return {
|
|
411
|
+
id: comment.id,
|
|
412
|
+
author: comment.author,
|
|
413
|
+
body: comment.body ?? "",
|
|
414
|
+
score: comment.score,
|
|
415
|
+
controversiality: comment.controversiality,
|
|
416
|
+
subreddit: comment.subreddit,
|
|
417
|
+
submissionTitle: comment.link_title ?? "",
|
|
418
|
+
createdUtc: comment.created_utc,
|
|
419
|
+
edited: Boolean(comment.edited),
|
|
420
|
+
isSubmitter: comment.is_submitter,
|
|
421
|
+
permalink: comment.permalink,
|
|
422
|
+
parentId: comment.parent_id
|
|
423
|
+
};
|
|
424
|
+
}),
|
|
425
|
+
...listingCursor(json.data)
|
|
426
|
+
};
|
|
427
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
428
|
+
}
|
|
429
|
+
async getMyOverview(options = {}) {
|
|
430
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your overview requires REDDIT_USERNAME"));
|
|
431
|
+
const { limit = 25, after } = options;
|
|
432
|
+
const params = new URLSearchParams({ limit: limit.toString() });
|
|
433
|
+
if (after !== void 0) params.set("after", after);
|
|
434
|
+
return this.getUserContent(`/user/${this.username}/overview.json?${params}`, "Failed to get your overview");
|
|
435
|
+
}
|
|
436
|
+
async getMySaved(options = {}) {
|
|
437
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching saved content requires REDDIT_USERNAME"));
|
|
438
|
+
const { limit = 25, after } = options;
|
|
439
|
+
const params = new URLSearchParams({ limit: limit.toString() });
|
|
440
|
+
if (after !== void 0) params.set("after", after);
|
|
441
|
+
return this.getUserContent(`/user/${this.username}/saved.json?${params}`, "Failed to get saved content");
|
|
442
|
+
}
|
|
443
|
+
async getMe() {
|
|
444
|
+
if (this.username === void 0) return Left(new NotAuthenticatedError("Fetching your account requires REDDIT_USERNAME"));
|
|
445
|
+
const context = "Failed to get authenticated user info";
|
|
446
|
+
return (await Try.async(async () => {
|
|
447
|
+
const response = (await this.makeRequest("/api/v1/me")).orThrow();
|
|
448
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
449
|
+
const data = await response.json();
|
|
450
|
+
return {
|
|
451
|
+
name: data.name,
|
|
452
|
+
id: data.id,
|
|
453
|
+
commentKarma: data.comment_karma,
|
|
454
|
+
linkKarma: data.link_karma,
|
|
455
|
+
totalKarma: data.total_karma ?? data.comment_karma + data.link_karma,
|
|
456
|
+
isMod: data.is_mod,
|
|
457
|
+
isGold: data.is_gold,
|
|
458
|
+
isEmployee: data.is_employee,
|
|
459
|
+
createdUtc: data.created_utc,
|
|
460
|
+
profileUrl: `https://reddit.com/user/${data.name}`
|
|
461
|
+
};
|
|
462
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
286
463
|
}
|
|
287
464
|
async getSubredditInfo(subredditName) {
|
|
288
|
-
|
|
465
|
+
const context = `Failed to get subreddit info for ${subredditName}`;
|
|
466
|
+
return (await Try.async(async () => {
|
|
289
467
|
const response = (await this.makeRequest(`/r/${subredditName}/about.json`)).orThrow();
|
|
290
|
-
if (!response.ok)
|
|
468
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
291
469
|
const { data } = await response.json();
|
|
292
|
-
return
|
|
470
|
+
return {
|
|
293
471
|
displayName: data.display_name,
|
|
294
472
|
title: data.title,
|
|
295
473
|
description: data.description,
|
|
@@ -300,26 +478,56 @@ var RedditClient = class {
|
|
|
300
478
|
over18: data.over18,
|
|
301
479
|
subredditType: data.subreddit_type,
|
|
302
480
|
url: data.url
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
|
|
481
|
+
};
|
|
482
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
483
|
+
}
|
|
484
|
+
async getSubredditRules(subreddit) {
|
|
485
|
+
const context = `Failed to get rules for r/${subreddit}`;
|
|
486
|
+
return (await Try.async(async () => {
|
|
487
|
+
const response = (await this.makeRequest(`/r/${subreddit}/about/rules.json`)).orThrow();
|
|
488
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
489
|
+
return (await response.json()).rules.map((rule) => ({
|
|
490
|
+
shortName: rule.short_name,
|
|
491
|
+
description: rule.description,
|
|
492
|
+
kind: rule.kind,
|
|
493
|
+
violationReason: rule.violation_reason,
|
|
494
|
+
priority: rule.priority,
|
|
495
|
+
createdUtc: rule.created_utc
|
|
496
|
+
}));
|
|
497
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
498
|
+
}
|
|
499
|
+
async getPostFlairs(subreddit) {
|
|
500
|
+
const context = `Failed to get post flairs for r/${subreddit}`;
|
|
501
|
+
return (await Try.async(async () => {
|
|
502
|
+
const response = (await this.makeRequest(`/r/${subreddit}/api/link_flair_v2.json`)).orThrow();
|
|
503
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
504
|
+
return (await response.json()).map((flair) => ({
|
|
505
|
+
id: flair.id,
|
|
506
|
+
text: flair.text,
|
|
507
|
+
type: flair.type,
|
|
508
|
+
textEditable: flair.text_editable
|
|
509
|
+
}));
|
|
510
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
307
511
|
}
|
|
308
|
-
async getTopPosts(subreddit, timeFilter = "week", limit = 10) {
|
|
512
|
+
async getTopPosts(subreddit, timeFilter = "week", limit = 10, after) {
|
|
309
513
|
const endpoint = subreddit !== "" ? `/r/${subreddit}/top.json` : "/top.json";
|
|
310
514
|
const params = new URLSearchParams({
|
|
311
515
|
t: timeFilter,
|
|
312
516
|
limit: limit.toString()
|
|
313
517
|
});
|
|
314
|
-
|
|
518
|
+
if (after !== void 0) params.set("after", after);
|
|
519
|
+
const context = `Failed to get top posts for ${subreddit !== "" ? subreddit : "home"}`;
|
|
520
|
+
return (await Try.async(async () => {
|
|
315
521
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
316
|
-
if (!response.ok)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
522
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to get top posts: HTTP ${response.status}`);
|
|
523
|
+
const json = await response.json();
|
|
524
|
+
return {
|
|
525
|
+
items: json.data.children.map((child) => parsePostData(child.data)),
|
|
526
|
+
...listingCursor(json.data)
|
|
527
|
+
};
|
|
528
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
321
529
|
}
|
|
322
|
-
async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10) {
|
|
530
|
+
async browseSubreddit(subreddit, sort = "hot", timeFilter = "week", limit = 10, after) {
|
|
323
531
|
const validSorts = [
|
|
324
532
|
"hot",
|
|
325
533
|
"new",
|
|
@@ -327,43 +535,46 @@ var RedditClient = class {
|
|
|
327
535
|
"rising",
|
|
328
536
|
"controversial"
|
|
329
537
|
];
|
|
330
|
-
if (!validSorts.includes(sort)) return Left(
|
|
538
|
+
if (!validSorts.includes(sort)) return Left(new ValidationError(`Invalid sort "${sort}". Valid options are: ${validSorts.join(", ")}`));
|
|
331
539
|
const endpoint = subreddit !== "" ? `/r/${subreddit}/${sort}.json` : `/${sort}.json`;
|
|
332
540
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
333
541
|
if (sort === "top" || sort === "controversial") params.set("t", timeFilter);
|
|
334
|
-
|
|
542
|
+
if (after !== void 0) params.set("after", after);
|
|
543
|
+
const home = subreddit !== "" ? subreddit : "home";
|
|
544
|
+
const context = `Failed to browse r/${home} (${sort})`;
|
|
545
|
+
return (await Try.async(async () => {
|
|
335
546
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
336
|
-
if (!response.ok)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
547
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to browse r/${home}: HTTP ${response.status}`);
|
|
548
|
+
const json = await response.json();
|
|
549
|
+
return {
|
|
550
|
+
items: json.data.children.map((child) => parsePostData(child.data)),
|
|
551
|
+
...listingCursor(json.data)
|
|
552
|
+
};
|
|
553
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
341
554
|
}
|
|
342
555
|
async getPost(postId, subreddit) {
|
|
343
556
|
const endpoint = Option(subreddit).fold(() => `/api/info.json?id=t3_${postId}`, (sr) => `/r/${sr}/comments/${postId}.json`);
|
|
344
|
-
|
|
557
|
+
const context = `Failed to get post with ID ${postId}`;
|
|
558
|
+
return (await Try.async(async () => {
|
|
345
559
|
const response = (await this.makeRequest(endpoint)).orThrow();
|
|
346
|
-
if (!response.ok)
|
|
347
|
-
if (subreddit !== void 0) return
|
|
560
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
561
|
+
if (subreddit !== void 0) return parsePostData((await response.json())[0].data.children[0].data);
|
|
348
562
|
const json = await response.json();
|
|
349
|
-
if (json.data.children.length === 0)
|
|
350
|
-
return
|
|
351
|
-
}
|
|
352
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get post with ID ${postId}: ${toError(error).message}`));
|
|
353
|
-
}
|
|
563
|
+
if (json.data.children.length === 0) throw new NotFoundError(`Post with ID ${postId} not found`);
|
|
564
|
+
return parsePostData(json.data.children[0].data);
|
|
565
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
354
566
|
}
|
|
355
567
|
async getTrendingSubreddits(limit = 5) {
|
|
356
568
|
const params = new URLSearchParams({ limit: limit.toString() });
|
|
357
|
-
|
|
569
|
+
const context = `Failed to get trending subreddits`;
|
|
570
|
+
return (await Try.async(async () => {
|
|
358
571
|
const response = (await this.makeRequest(`/subreddits/popular.json?${params}`)).orThrow();
|
|
359
|
-
if (!response.ok)
|
|
360
|
-
return
|
|
361
|
-
}
|
|
362
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get trending subreddits: ${toError(error).message}`));
|
|
363
|
-
}
|
|
572
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
573
|
+
return (await response.json()).data.children.map((child) => child.data.display_name);
|
|
574
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
364
575
|
}
|
|
365
|
-
async createPost(subreddit, title, content, isSelf = true) {
|
|
366
|
-
|
|
576
|
+
async createPost(subreddit, title, content, isSelf = true, flairId, flairText) {
|
|
577
|
+
return (await Try.async(async () => {
|
|
367
578
|
var _json$json$data, _json$json$data2;
|
|
368
579
|
this.validateWriteAccess();
|
|
369
580
|
await this.enforceWriteRateLimit();
|
|
@@ -376,35 +587,30 @@ var RedditClient = class {
|
|
|
376
587
|
params.append("title", title);
|
|
377
588
|
params.append(isSelf ? "text" : "url", finalContent);
|
|
378
589
|
params.append("api_type", "json");
|
|
590
|
+
if (flairId !== void 0) params.append("flair_id", flairId);
|
|
591
|
+
if (flairText !== void 0) params.append("flair_text", flairText);
|
|
379
592
|
const response = (await this.makeRequest("/api/submit", {
|
|
380
593
|
method: "POST",
|
|
381
594
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
382
595
|
body: params.toString()
|
|
383
596
|
})).orThrow();
|
|
384
|
-
if (!response.ok)
|
|
597
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to create post: HTTP ${response.status}`);
|
|
385
598
|
const json = await response.json();
|
|
386
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
387
|
-
const errors = json.json.errors.map((e) => e[1]).join(", ");
|
|
388
|
-
return Left(/* @__PURE__ */ new Error(`Reddit API errors: ${errors}`));
|
|
389
|
-
}
|
|
599
|
+
if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
390
600
|
const postId = ((_json$json$data = json.json.data) === null || _json$json$data === void 0 ? void 0 : _json$json$data.id) ?? ((_json$json$data2 = json.json.data) === null || _json$json$data2 === void 0 || (_json$json$data2 = _json$json$data2.name) === null || _json$json$data2 === void 0 ? void 0 : _json$json$data2.replace("t3_", ""));
|
|
391
|
-
if (postId === void 0)
|
|
392
|
-
return this.getPost(postId, subreddit);
|
|
393
|
-
}
|
|
394
|
-
return Left(toError(error));
|
|
395
|
-
}
|
|
601
|
+
if (postId === void 0) throw new ApiError("No post ID returned from Reddit");
|
|
602
|
+
return (await this.getPost(postId, subreddit)).orThrow();
|
|
603
|
+
})).toEither((error) => classifyRedditError(error));
|
|
396
604
|
}
|
|
397
605
|
async checkPostExists(postId) {
|
|
398
|
-
|
|
606
|
+
return (await Try.async(async () => {
|
|
399
607
|
const response = (await this.makeRequest(`/api/info.json?id=t3_${postId}`)).orThrow();
|
|
400
608
|
if (!response.ok) return false;
|
|
401
609
|
return (await response.json()).data.children.length > 0;
|
|
402
|
-
}
|
|
403
|
-
return false;
|
|
404
|
-
}
|
|
610
|
+
})).orElse(false);
|
|
405
611
|
}
|
|
406
612
|
async replyToPost(postId, content) {
|
|
407
|
-
|
|
613
|
+
return (await Try.async(async () => {
|
|
408
614
|
var _json$json$data3;
|
|
409
615
|
this.validateWriteAccess();
|
|
410
616
|
await this.enforceWriteRateLimit();
|
|
@@ -412,7 +618,7 @@ var RedditClient = class {
|
|
|
412
618
|
const finalContent = this.appendBotDisclosure(content);
|
|
413
619
|
const fullThingId = postId.startsWith("t3_") || postId.startsWith("t1_") ? postId : `t3_${postId}`;
|
|
414
620
|
if (!postId.startsWith("t1_")) {
|
|
415
|
-
if (!await this.checkPostExists(postId.replace(/^t3_/, "")))
|
|
621
|
+
if (!await this.checkPostExists(postId.replace(/^t3_/, ""))) throw new NotFoundError(`Post with ID ${postId} does not exist or is not accessible`);
|
|
416
622
|
}
|
|
417
623
|
const params = new URLSearchParams();
|
|
418
624
|
params.append("thing_id", fullThingId);
|
|
@@ -423,12 +629,12 @@ var RedditClient = class {
|
|
|
423
629
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
424
630
|
body: params.toString()
|
|
425
631
|
})).orThrow();
|
|
426
|
-
if (!response.ok)
|
|
632
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to reply: HTTP ${response.status}`);
|
|
427
633
|
const json = await response.json();
|
|
428
634
|
if (((_json$json$data3 = json.json.data) === null || _json$json$data3 === void 0 ? void 0 : _json$json$data3.things) !== void 0 && json.json.data.things.length > 0) {
|
|
429
635
|
const commentData = json.json.data.things[0].data;
|
|
430
636
|
const author = this.username ?? "[unknown]";
|
|
431
|
-
return
|
|
637
|
+
return {
|
|
432
638
|
id: commentData.id,
|
|
433
639
|
author,
|
|
434
640
|
body: content,
|
|
@@ -440,17 +646,13 @@ var RedditClient = class {
|
|
|
440
646
|
edited: false,
|
|
441
647
|
isSubmitter: false,
|
|
442
648
|
permalink: commentData.permalink
|
|
443
|
-
}
|
|
444
|
-
} else if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
} else return Left(/* @__PURE__ */ new Error("Failed to parse reply response"));
|
|
448
|
-
} catch (error) {
|
|
449
|
-
return Left(toError(error));
|
|
450
|
-
}
|
|
649
|
+
};
|
|
650
|
+
} else if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
651
|
+
else throw new ApiError("Failed to parse reply response");
|
|
652
|
+
})).toEither((error) => classifyRedditError(error));
|
|
451
653
|
}
|
|
452
654
|
async deletePost(thingId) {
|
|
453
|
-
|
|
655
|
+
return (await Try.async(async () => {
|
|
454
656
|
this.validateWriteAccess();
|
|
455
657
|
const fullThingId = thingId.startsWith("t3_") || thingId.startsWith("t1_") ? thingId : `t3_${thingId}`;
|
|
456
658
|
const params = new URLSearchParams();
|
|
@@ -464,21 +666,21 @@ var RedditClient = class {
|
|
|
464
666
|
const errorText = await response.text();
|
|
465
667
|
console.error(`[Reddit API] Delete failed: ${response.status} ${response.statusText}`);
|
|
466
668
|
console.error(`[Reddit API] Error response: ${errorText}`);
|
|
467
|
-
|
|
669
|
+
throw new HttpError(response.status, `HTTP ${response.status}: ${errorText}`);
|
|
468
670
|
}
|
|
469
671
|
console.error(`[Reddit API] Successfully deleted ${fullThingId}`);
|
|
470
|
-
return
|
|
471
|
-
}
|
|
472
|
-
console.error(`[Reddit API] Delete exception:`, error);
|
|
473
|
-
return
|
|
474
|
-
}
|
|
672
|
+
return true;
|
|
673
|
+
})).toEither((error) => {
|
|
674
|
+
if (!isRedditError(error)) console.error(`[Reddit API] Delete exception:`, error);
|
|
675
|
+
return classifyRedditError(error);
|
|
676
|
+
});
|
|
475
677
|
}
|
|
476
678
|
async deleteComment(thingId) {
|
|
477
679
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
478
680
|
return this.deletePost(fullThingId);
|
|
479
681
|
}
|
|
480
682
|
async editPost(thingId, newText) {
|
|
481
|
-
|
|
683
|
+
return (await Try.async(async () => {
|
|
482
684
|
this.validateWriteAccess();
|
|
483
685
|
await this.enforceWriteRateLimit();
|
|
484
686
|
this.checkDuplicateContent(newText);
|
|
@@ -493,23 +695,18 @@ var RedditClient = class {
|
|
|
493
695
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
494
696
|
body: params.toString()
|
|
495
697
|
})).orThrow();
|
|
496
|
-
if (!response.ok)
|
|
698
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to edit: HTTP ${response.status}`);
|
|
497
699
|
const json = await response.json();
|
|
498
|
-
if (json.json.errors !== void 0 && json.json.errors.length > 0) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
return Right(true);
|
|
503
|
-
} catch (error) {
|
|
504
|
-
return Left(toError(error));
|
|
505
|
-
}
|
|
700
|
+
if (json.json.errors !== void 0 && json.json.errors.length > 0) throw new ApiError(`Reddit API errors: ${json.json.errors.map((e) => e[1]).join(", ")}`);
|
|
701
|
+
return true;
|
|
702
|
+
})).toEither((error) => classifyRedditError(error));
|
|
506
703
|
}
|
|
507
704
|
async editComment(thingId, newText) {
|
|
508
705
|
const fullThingId = thingId.startsWith("t1_") ? thingId : `t1_${thingId}`;
|
|
509
706
|
return this.editPost(fullThingId, newText);
|
|
510
707
|
}
|
|
511
708
|
async searchReddit(query, options = {}) {
|
|
512
|
-
const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link" } = options;
|
|
709
|
+
const { subreddit, sort = "relevance", timeFilter = "all", limit = 25, type = "link", after, before } = options;
|
|
513
710
|
const endpoint = Option(subreddit).fold(() => "/search.json", (sr) => `/r/${sr}/search.json`);
|
|
514
711
|
const params = new URLSearchParams({
|
|
515
712
|
q: query,
|
|
@@ -517,15 +714,20 @@ var RedditClient = class {
|
|
|
517
714
|
t: timeFilter,
|
|
518
715
|
limit: limit.toString(),
|
|
519
716
|
type,
|
|
520
|
-
...subreddit !== void 0 ? { restrict_sr: "true" } : {}
|
|
717
|
+
...subreddit !== void 0 ? { restrict_sr: "true" } : {},
|
|
718
|
+
...after !== void 0 ? { after } : {},
|
|
719
|
+
...before !== void 0 ? { before } : {}
|
|
521
720
|
});
|
|
522
|
-
|
|
721
|
+
const context = `Failed to search Reddit for: ${query}`;
|
|
722
|
+
return (await Try.async(async () => {
|
|
523
723
|
const response = (await this.makeRequest(`${endpoint}?${params}`)).orThrow();
|
|
524
|
-
if (!response.ok)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
724
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to search Reddit: HTTP ${response.status}`);
|
|
725
|
+
const json = await response.json();
|
|
726
|
+
return {
|
|
727
|
+
items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
728
|
+
...listingCursor(json.data)
|
|
729
|
+
};
|
|
730
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
529
731
|
}
|
|
530
732
|
async getPostComments(postId, subreddit, options = {}) {
|
|
531
733
|
const { sort = "best", limit = 100 } = options;
|
|
@@ -533,9 +735,10 @@ var RedditClient = class {
|
|
|
533
735
|
sort,
|
|
534
736
|
limit: limit.toString()
|
|
535
737
|
});
|
|
536
|
-
|
|
738
|
+
const context = `Failed to get comments for post ${postId}`;
|
|
739
|
+
return (await Try.async(async () => {
|
|
537
740
|
const response = (await this.makeRequest(`/r/${subreddit}/comments/${postId}.json?${params}`)).orThrow();
|
|
538
|
-
if (!response.ok)
|
|
741
|
+
if (!response.ok) throw new HttpError(response.status, `Failed to get comments: HTTP ${response.status}`);
|
|
539
742
|
const json = await response.json();
|
|
540
743
|
const postData = json[0].data.children[0].data;
|
|
541
744
|
const post = parsePostData(postData);
|
|
@@ -559,58 +762,95 @@ var RedditClient = class {
|
|
|
559
762
|
const { replies } = item.data;
|
|
560
763
|
return [comment, ...replies !== void 0 && typeof replies !== "string" ? parseComments(replies.data.children, depth + 1) : []];
|
|
561
764
|
});
|
|
562
|
-
return
|
|
765
|
+
return {
|
|
563
766
|
post,
|
|
564
767
|
comments: parseComments(json[1].data.children)
|
|
768
|
+
};
|
|
769
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
770
|
+
}
|
|
771
|
+
async getMoreComments(linkId, commentIds) {
|
|
772
|
+
const fullLinkId = linkId.startsWith("t3_") ? linkId : `t3_${linkId}`;
|
|
773
|
+
const context = `Failed to expand comments for ${fullLinkId}`;
|
|
774
|
+
const params = new URLSearchParams({
|
|
775
|
+
api_type: "json",
|
|
776
|
+
link_id: fullLinkId,
|
|
777
|
+
children: commentIds.join(",")
|
|
778
|
+
});
|
|
779
|
+
return (await Try.async(async () => {
|
|
780
|
+
var _json$json$data4;
|
|
781
|
+
const response = (await this.makeRequest(`/api/morechildren?${params}`)).orThrow();
|
|
782
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
783
|
+
return (((_json$json$data4 = (await response.json()).json.data) === null || _json$json$data4 === void 0 ? void 0 : _json$json$data4.things) ?? []).filter((thing) => thing.kind === "t1" && thing.data.body !== void 0).map((thing) => {
|
|
784
|
+
const comment = thing.data;
|
|
785
|
+
return {
|
|
786
|
+
id: comment.id,
|
|
787
|
+
author: comment.author,
|
|
788
|
+
body: comment.body ?? "",
|
|
789
|
+
score: comment.score,
|
|
790
|
+
controversiality: comment.controversiality,
|
|
791
|
+
subreddit: comment.subreddit,
|
|
792
|
+
submissionTitle: comment.link_title ?? "",
|
|
793
|
+
createdUtc: comment.created_utc,
|
|
794
|
+
edited: Boolean(comment.edited),
|
|
795
|
+
isSubmitter: comment.is_submitter,
|
|
796
|
+
permalink: comment.permalink,
|
|
797
|
+
parentId: comment.parent_id
|
|
798
|
+
};
|
|
565
799
|
});
|
|
566
|
-
}
|
|
567
|
-
return Left(/* @__PURE__ */ new Error(`Failed to get comments for post ${postId}: ${toError(error).message}`));
|
|
568
|
-
}
|
|
800
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
569
801
|
}
|
|
570
802
|
async getUserPosts(username, options = {}) {
|
|
571
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
803
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
572
804
|
const params = new URLSearchParams({
|
|
573
805
|
sort,
|
|
574
806
|
t: timeFilter,
|
|
575
807
|
limit: limit.toString()
|
|
576
808
|
});
|
|
577
|
-
|
|
809
|
+
if (after !== void 0) params.set("after", after);
|
|
810
|
+
const context = `Failed to get posts for user ${username}`;
|
|
811
|
+
return (await Try.async(async () => {
|
|
578
812
|
const response = (await this.makeRequest(`/user/${username}/submitted.json?${params}`)).orThrow();
|
|
579
|
-
if (!response.ok)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
813
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
814
|
+
const json = await response.json();
|
|
815
|
+
return {
|
|
816
|
+
items: json.data.children.filter((child) => child.kind === "t3").map((child) => parsePostData(child.data)),
|
|
817
|
+
...listingCursor(json.data)
|
|
818
|
+
};
|
|
819
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
584
820
|
}
|
|
585
821
|
async getUserComments(username, options = {}) {
|
|
586
|
-
const { sort = "new", timeFilter = "all", limit = 25 } = options;
|
|
822
|
+
const { sort = "new", timeFilter = "all", limit = 25, after } = options;
|
|
587
823
|
const params = new URLSearchParams({
|
|
588
824
|
sort,
|
|
589
825
|
t: timeFilter,
|
|
590
826
|
limit: limit.toString()
|
|
591
827
|
});
|
|
592
|
-
|
|
828
|
+
if (after !== void 0) params.set("after", after);
|
|
829
|
+
const context = `Failed to get comments for user ${username}`;
|
|
830
|
+
return (await Try.async(async () => {
|
|
593
831
|
const response = (await this.makeRequest(`/user/${username}/comments.json?${params}`)).orThrow();
|
|
594
|
-
if (!response.ok)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
832
|
+
if (!response.ok) throw new HttpError(response.status, `${context}: HTTP ${response.status}`);
|
|
833
|
+
const json = await response.json();
|
|
834
|
+
return {
|
|
835
|
+
items: json.data.children.filter((child) => child.kind === "t1").map((child) => {
|
|
836
|
+
const comment = child.data;
|
|
837
|
+
return {
|
|
838
|
+
id: comment.id,
|
|
839
|
+
author: comment.author,
|
|
840
|
+
body: comment.body ?? "",
|
|
841
|
+
score: comment.score,
|
|
842
|
+
controversiality: comment.controversiality,
|
|
843
|
+
subreddit: comment.subreddit,
|
|
844
|
+
submissionTitle: comment.link_title ?? "",
|
|
845
|
+
createdUtc: comment.created_utc,
|
|
846
|
+
edited: Boolean(comment.edited),
|
|
847
|
+
isSubmitter: comment.is_submitter,
|
|
848
|
+
permalink: comment.permalink
|
|
849
|
+
};
|
|
850
|
+
}),
|
|
851
|
+
...listingCursor(json.data)
|
|
852
|
+
};
|
|
853
|
+
})).toEither((error) => classifyRedditError(error, context));
|
|
614
854
|
}
|
|
615
855
|
};
|
|
616
856
|
const clientHolder = { instance: Option.none() };
|
|
@@ -752,7 +992,7 @@ function formatSubredditInfo(subreddit) {
|
|
|
752
992
|
//#endregion
|
|
753
993
|
//#region src/index.ts
|
|
754
994
|
dotenv.config({ quiet: true });
|
|
755
|
-
const VERSION = "1.
|
|
995
|
+
const VERSION = "1.5.0";
|
|
756
996
|
function validateUserAgent(userAgent, username) {
|
|
757
997
|
if (!/^[\w-]+:[\w-]+:[\d.]+ \(by \/u\/\w+\)$/.test(userAgent)) {
|
|
758
998
|
console.error("[Warning] User-Agent does not follow Reddit's recommended format");
|
|
@@ -803,6 +1043,15 @@ function buildSafeModeConfig(safeMode) {
|
|
|
803
1043
|
function unwrapClient() {
|
|
804
1044
|
return getRedditClient().orThrow(/* @__PURE__ */ new Error("Reddit client not initialized"));
|
|
805
1045
|
}
|
|
1046
|
+
function nextPageHint(after) {
|
|
1047
|
+
return Option(after).fold(() => "", (cursor) => `\n\n---\nMore results available — call again with after="${cursor}" for the next page.`);
|
|
1048
|
+
}
|
|
1049
|
+
function formatUserContent(heading, content) {
|
|
1050
|
+
return `# ${heading}\n\n${content.posts.length === 0 ? "" : `## Posts (${content.posts.length})\n${content.posts.map((post, index) => `${index + 1}. ${post.title} — r/${post.subreddit}, score ${post.score.toLocaleString()} — https://reddit.com${post.permalink}`).join("\n")}\n\n`}${content.comments.length === 0 ? "" : `## Comments (${content.comments.length})\n${content.comments.map((comment, index) => {
|
|
1051
|
+
const body = comment.body.length > 200 ? `${comment.body.substring(0, 200)}...` : comment.body;
|
|
1052
|
+
return `${index + 1}. in r/${comment.subreddit}: ${body} — https://reddit.com${comment.permalink}`;
|
|
1053
|
+
}).join("\n")}\n\n`}${content.posts.length === 0 && content.comments.length === 0 ? "No items found.\n\n" : ""}`.trimEnd() + nextPageHint(content.after);
|
|
1054
|
+
}
|
|
806
1055
|
async function setupRedditClient() {
|
|
807
1056
|
const clientId = process.env.REDDIT_CLIENT_ID;
|
|
808
1057
|
const clientSecret = process.env.REDDIT_CLIENT_SECRET;
|
|
@@ -847,6 +1096,7 @@ async function setupRedditClient() {
|
|
|
847
1096
|
enabled: cacheEnabled,
|
|
848
1097
|
maxBytes: (Number.isFinite(cacheMaxMb) && cacheMaxMb > 0 ? cacheMaxMb : 50) * 1024 * 1024
|
|
849
1098
|
};
|
|
1099
|
+
const maxRetriesRaw = Number(process.env.REDDIT_MAX_RETRIES ?? "3");
|
|
850
1100
|
const client = initializeRedditClient({
|
|
851
1101
|
clientId: clientId ?? "",
|
|
852
1102
|
clientSecret: clientSecret ?? "",
|
|
@@ -856,7 +1106,12 @@ async function setupRedditClient() {
|
|
|
856
1106
|
authMode,
|
|
857
1107
|
safeMode: safeModeConfig,
|
|
858
1108
|
botDisclosure: botDisclosureConfig,
|
|
859
|
-
cache: cacheConfig
|
|
1109
|
+
cache: cacheConfig,
|
|
1110
|
+
retry: {
|
|
1111
|
+
maxRetries: Number.isFinite(maxRetriesRaw) && maxRetriesRaw >= 0 ? Math.floor(maxRetriesRaw) : 3,
|
|
1112
|
+
baseDelayMs: 1e3,
|
|
1113
|
+
maxDelayMs: 6e4
|
|
1114
|
+
}
|
|
860
1115
|
});
|
|
861
1116
|
console.error("[Setup] Reddit client initialized");
|
|
862
1117
|
console.error(`[Setup] Authentication mode: ${authMode}`);
|
|
@@ -982,6 +1237,61 @@ server.addTool({
|
|
|
982
1237
|
});
|
|
983
1238
|
}
|
|
984
1239
|
});
|
|
1240
|
+
server.addTool({
|
|
1241
|
+
name: "get_me",
|
|
1242
|
+
description: "Get the authenticated user's own account info (karma, account status). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); fails in anonymous mode.",
|
|
1243
|
+
parameters: z.object({}),
|
|
1244
|
+
execute: async () => {
|
|
1245
|
+
return (await unwrapClient().getMe()).fold((err) => {
|
|
1246
|
+
throw new Error(`Failed to get authenticated user: ${err.message}`);
|
|
1247
|
+
}, (user) => {
|
|
1248
|
+
const formattedUser = formatUserInfo(user);
|
|
1249
|
+
return `# Your Account: u/${formattedUser.username}
|
|
1250
|
+
|
|
1251
|
+
## Profile Overview
|
|
1252
|
+
- Username: u/${formattedUser.username}
|
|
1253
|
+
- Karma:
|
|
1254
|
+
- Comment Karma: ${formattedUser.karma.commentKarma.toLocaleString()}
|
|
1255
|
+
- Post Karma: ${formattedUser.karma.postKarma.toLocaleString()}
|
|
1256
|
+
- Total Karma: ${formattedUser.karma.totalKarma.toLocaleString()}
|
|
1257
|
+
- Account Status: ${formattedUser.accountStatus.join(", ")}
|
|
1258
|
+
- Account Created: ${formattedUser.accountCreated}
|
|
1259
|
+
- Profile URL: ${formattedUser.profileUrl}`;
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
server.addTool({
|
|
1264
|
+
name: "get_my_overview",
|
|
1265
|
+
description: "Get your own recent activity (posts and comments combined). Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD).",
|
|
1266
|
+
parameters: z.object({
|
|
1267
|
+
limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
|
|
1268
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
|
|
1269
|
+
}),
|
|
1270
|
+
execute: async (args) => {
|
|
1271
|
+
return (await unwrapClient().getMyOverview({
|
|
1272
|
+
limit: args.limit,
|
|
1273
|
+
after: args.after
|
|
1274
|
+
})).fold((err) => {
|
|
1275
|
+
throw new Error(`Failed to get your overview: ${err.message}`);
|
|
1276
|
+
}, (content) => formatUserContent("Your Overview", content));
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
server.addTool({
|
|
1280
|
+
name: "get_my_saved",
|
|
1281
|
+
description: "Get your saved posts and comments. Requires user credentials (REDDIT_USERNAME/REDDIT_PASSWORD); saved content is private.",
|
|
1282
|
+
parameters: z.object({
|
|
1283
|
+
limit: z.number().min(1).max(100).default(25).describe("Number of items to retrieve"),
|
|
1284
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page")
|
|
1285
|
+
}),
|
|
1286
|
+
execute: async (args) => {
|
|
1287
|
+
return (await unwrapClient().getMySaved({
|
|
1288
|
+
limit: args.limit,
|
|
1289
|
+
after: args.after
|
|
1290
|
+
})).fold((err) => {
|
|
1291
|
+
throw new Error(`Failed to get saved content: ${err.message}`);
|
|
1292
|
+
}, (content) => formatUserContent("Your Saved Content", content));
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
985
1295
|
server.addTool({
|
|
986
1296
|
name: "get_user_posts",
|
|
987
1297
|
description: "Get recent posts by a Reddit user with sorting and filtering options",
|
|
@@ -1000,16 +1310,19 @@ server.addTool({
|
|
|
1000
1310
|
"year",
|
|
1001
1311
|
"all"
|
|
1002
1312
|
]).default("all").describe("Time filter for top posts"),
|
|
1003
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
|
|
1313
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1314
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1004
1315
|
}),
|
|
1005
1316
|
execute: async (args) => {
|
|
1006
1317
|
return (await unwrapClient().getUserPosts(args.username, {
|
|
1007
1318
|
sort: args.sort,
|
|
1008
1319
|
timeFilter: args.time_filter,
|
|
1009
|
-
limit: args.limit
|
|
1320
|
+
limit: args.limit,
|
|
1321
|
+
after: args.after
|
|
1010
1322
|
})).fold((err) => {
|
|
1011
1323
|
throw new Error(`Failed to get user posts: ${err.message}`);
|
|
1012
|
-
}, (
|
|
1324
|
+
}, (page) => {
|
|
1325
|
+
const posts = page.items;
|
|
1013
1326
|
if (posts.length === 0) return `No posts found for u/${args.username} with the specified filters.`;
|
|
1014
1327
|
const postSummaries = posts.map((post, index) => {
|
|
1015
1328
|
const flags = [...post.over18 ? ["**NSFW**"] : [], ...post.spoiler === true ? ["**Spoiler**"] : []];
|
|
@@ -1022,7 +1335,7 @@ server.addTool({
|
|
|
1022
1335
|
}).join("\n\n");
|
|
1023
1336
|
return `# Posts by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
1024
1337
|
|
|
1025
|
-
${postSummaries}`;
|
|
1338
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1026
1339
|
});
|
|
1027
1340
|
}
|
|
1028
1341
|
});
|
|
@@ -1044,16 +1357,19 @@ server.addTool({
|
|
|
1044
1357
|
"year",
|
|
1045
1358
|
"all"
|
|
1046
1359
|
]).default("all").describe("Time filter for top comments"),
|
|
1047
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve")
|
|
1360
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of comments to retrieve"),
|
|
1361
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1048
1362
|
}),
|
|
1049
1363
|
execute: async (args) => {
|
|
1050
1364
|
return (await unwrapClient().getUserComments(args.username, {
|
|
1051
1365
|
sort: args.sort,
|
|
1052
1366
|
timeFilter: args.time_filter,
|
|
1053
|
-
limit: args.limit
|
|
1367
|
+
limit: args.limit,
|
|
1368
|
+
after: args.after
|
|
1054
1369
|
})).fold((err) => {
|
|
1055
1370
|
throw new Error(`Failed to get user comments: ${err.message}`);
|
|
1056
|
-
}, (
|
|
1371
|
+
}, (page) => {
|
|
1372
|
+
const comments = page.items;
|
|
1057
1373
|
if (comments.length === 0) return `No comments found for u/${args.username} with the specified filters.`;
|
|
1058
1374
|
const commentSummaries = comments.map((comment, index) => {
|
|
1059
1375
|
const truncatedBody = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
@@ -1069,7 +1385,7 @@ In r/${comment.subreddit} on "${comment.submissionTitle}"
|
|
|
1069
1385
|
}).join("\n\n");
|
|
1070
1386
|
return `# Comments by u/${args.username} (${args.sort} - ${args.time_filter})
|
|
1071
1387
|
|
|
1072
|
-
${commentSummaries}`;
|
|
1388
|
+
${commentSummaries}${nextPageHint(page.after)}`;
|
|
1073
1389
|
});
|
|
1074
1390
|
}
|
|
1075
1391
|
});
|
|
@@ -1130,12 +1446,14 @@ server.addTool({
|
|
|
1130
1446
|
"year",
|
|
1131
1447
|
"all"
|
|
1132
1448
|
]).default("week").describe("Time period for top posts"),
|
|
1133
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
|
|
1449
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1450
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1134
1451
|
}),
|
|
1135
1452
|
execute: async (args) => {
|
|
1136
|
-
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit)).fold((err) => {
|
|
1453
|
+
return (await unwrapClient().getTopPosts(args.subreddit ?? "", args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1137
1454
|
throw new Error(`Failed to get top posts: ${err.message}`);
|
|
1138
|
-
}, (
|
|
1455
|
+
}, (page) => {
|
|
1456
|
+
const posts = page.items;
|
|
1139
1457
|
if (posts.length === 0) return `No posts found in ${Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`)} for the specified time period.`;
|
|
1140
1458
|
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
1141
1459
|
- Author: u/${post.author}
|
|
@@ -1145,7 +1463,7 @@ server.addTool({
|
|
|
1145
1463
|
- Link: ${post.links.shortLink}`).join("\n\n");
|
|
1146
1464
|
return `# Top Posts from ${Option(args.subreddit).fold(() => "Home Feed", (sr) => `r/${sr}`)} (${args.time_filter})
|
|
1147
1465
|
|
|
1148
|
-
${postSummaries}`;
|
|
1466
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1149
1467
|
});
|
|
1150
1468
|
}
|
|
1151
1469
|
});
|
|
@@ -1169,12 +1487,14 @@ server.addTool({
|
|
|
1169
1487
|
"year",
|
|
1170
1488
|
"all"
|
|
1171
1489
|
]).default("week").describe("Time period (only applies to top and controversial sorts)"),
|
|
1172
|
-
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve")
|
|
1490
|
+
limit: z.number().min(1).max(100).default(10).describe("Number of posts to retrieve"),
|
|
1491
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1173
1492
|
}),
|
|
1174
1493
|
execute: async (args) => {
|
|
1175
|
-
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit)).fold((err) => {
|
|
1494
|
+
return (await unwrapClient().browseSubreddit(args.subreddit ?? "", args.sort, args.time_filter, args.limit, args.after)).fold((err) => {
|
|
1176
1495
|
throw new Error(`Failed to browse subreddit: ${err.message}`);
|
|
1177
|
-
}, (
|
|
1496
|
+
}, (page) => {
|
|
1497
|
+
const posts = page.items;
|
|
1178
1498
|
const location = Option(args.subreddit).fold(() => "home feed", (sr) => `r/${sr}`);
|
|
1179
1499
|
if (posts.length === 0) return `No posts found in ${location}.`;
|
|
1180
1500
|
const postSummaries = posts.map(formatPostInfo).map((post, index) => `### ${index + 1}. ${post.title}
|
|
@@ -1187,7 +1507,7 @@ server.addTool({
|
|
|
1187
1507
|
const heading = location === "home feed" ? "Home Feed" : location;
|
|
1188
1508
|
return `# ${args.sort} posts from ${heading} (${args.sort}${timeSuffix})
|
|
1189
1509
|
|
|
1190
|
-
${postSummaries}`;
|
|
1510
|
+
${postSummaries}${nextPageHint(page.after)}`;
|
|
1191
1511
|
});
|
|
1192
1512
|
}
|
|
1193
1513
|
});
|
|
@@ -1230,6 +1550,47 @@ ${formattedSubreddit.description.full}
|
|
|
1230
1550
|
});
|
|
1231
1551
|
}
|
|
1232
1552
|
});
|
|
1553
|
+
server.addTool({
|
|
1554
|
+
name: "get_subreddit_rules",
|
|
1555
|
+
description: "Get a subreddit's posting rules. Useful to check requirements before creating a post to avoid auto-removal.",
|
|
1556
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
|
|
1557
|
+
execute: async (args) => {
|
|
1558
|
+
return (await unwrapClient().getSubredditRules(args.subreddit_name)).fold((err) => {
|
|
1559
|
+
throw new Error(`Failed to get subreddit rules: ${err.message}`);
|
|
1560
|
+
}, (rules) => {
|
|
1561
|
+
if (rules.length === 0) return `r/${args.subreddit_name} has no listed subreddit-specific rules.`;
|
|
1562
|
+
const ruleList = rules.map((rule, index) => {
|
|
1563
|
+
const applies = rule.kind === "all" ? "posts & comments" : `${rule.kind}s`;
|
|
1564
|
+
const detail = rule.description.trim() === "" ? "" : `\n${rule.description.trim()}`;
|
|
1565
|
+
return `### ${index + 1}. ${rule.shortName} _(applies to ${applies})_${detail}`;
|
|
1566
|
+
}).join("\n\n");
|
|
1567
|
+
return `# Posting Rules for r/${args.subreddit_name}
|
|
1568
|
+
|
|
1569
|
+
${ruleList}`;
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
server.addTool({
|
|
1574
|
+
name: "get_post_flairs",
|
|
1575
|
+
description: "List the available link flairs for a subreddit (use a flair_id with create_post). Requires user credentials; many subreddits only expose flairs to members, so this may fail in anonymous mode.",
|
|
1576
|
+
parameters: z.object({ subreddit_name: z.string().describe("The subreddit name (without r/ prefix)") }),
|
|
1577
|
+
execute: async (args) => {
|
|
1578
|
+
return (await unwrapClient().getPostFlairs(args.subreddit_name)).fold((err) => {
|
|
1579
|
+
throw new Error(`Failed to get post flairs: ${err.message}`);
|
|
1580
|
+
}, (flairs) => {
|
|
1581
|
+
if (flairs.length === 0) return `r/${args.subreddit_name} has no selectable link flairs (or none are visible to this account).`;
|
|
1582
|
+
const flairList = flairs.map((flair) => {
|
|
1583
|
+
const editable = flair.textEditable === true ? " _(text editable)_" : "";
|
|
1584
|
+
return `- ${flair.text}${editable} — \`flair_id: ${flair.id}\``;
|
|
1585
|
+
}).join("\n");
|
|
1586
|
+
return `# Available Link Flairs for r/${args.subreddit_name}
|
|
1587
|
+
|
|
1588
|
+
${flairList}
|
|
1589
|
+
|
|
1590
|
+
Pass the desired \`flair_id\` to \`create_post\`.`;
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1233
1594
|
server.addTool({
|
|
1234
1595
|
name: "get_trending_subreddits",
|
|
1235
1596
|
description: "Get a list of currently trending subreddits",
|
|
@@ -1254,7 +1615,7 @@ server.addTool({
|
|
|
1254
1615
|
"top",
|
|
1255
1616
|
"new",
|
|
1256
1617
|
"comments"
|
|
1257
|
-
]).default("relevance").describe("Sort order"),
|
|
1618
|
+
]).default("relevance").describe("Sort order. Prefer 'relevance' (default) for finding posts about a topic. Use 'top'/'hot' only for what's currently popular and 'new' for the latest — these rank by karma/recency and, especially combined with a narrow time_filter, can surface loosely-matching posts over the best topical results."),
|
|
1258
1619
|
time_filter: z.enum([
|
|
1259
1620
|
"hour",
|
|
1260
1621
|
"day",
|
|
@@ -1268,7 +1629,8 @@ server.addTool({
|
|
|
1268
1629
|
"link",
|
|
1269
1630
|
"sr",
|
|
1270
1631
|
"user"
|
|
1271
|
-
]).default("link").describe("Type of content to search")
|
|
1632
|
+
]).default("link").describe("Type of content to search"),
|
|
1633
|
+
after: z.string().optional().describe("Pagination cursor: pass the `after` value from a previous page to fetch the next page")
|
|
1272
1634
|
}),
|
|
1273
1635
|
execute: async (args) => {
|
|
1274
1636
|
const client = unwrapClient();
|
|
@@ -1278,10 +1640,12 @@ server.addTool({
|
|
|
1278
1640
|
sort: args.sort,
|
|
1279
1641
|
timeFilter: args.time_filter,
|
|
1280
1642
|
limit: args.limit,
|
|
1281
|
-
type: args.type
|
|
1643
|
+
type: args.type,
|
|
1644
|
+
after: args.after
|
|
1282
1645
|
})).fold((err) => {
|
|
1283
1646
|
throw new Error(`Failed to search: ${err.message}`);
|
|
1284
|
-
}, (
|
|
1647
|
+
}, (page) => {
|
|
1648
|
+
const posts = page.items;
|
|
1285
1649
|
if (posts.length === 0) {
|
|
1286
1650
|
const searchLocation = Option(args.subreddit).fold(() => "", (sr) => ` in r/${sr}`);
|
|
1287
1651
|
return `No results found for "${args.query}"${searchLocation}.`;
|
|
@@ -1301,7 +1665,7 @@ server.addTool({
|
|
|
1301
1665
|
|
|
1302
1666
|
Sorted by: ${args.sort} | Time: ${args.time_filter} | Type: ${args.type}
|
|
1303
1667
|
|
|
1304
|
-
${searchResults}`;
|
|
1668
|
+
${searchResults}${nextPageHint(page.after)}`;
|
|
1305
1669
|
});
|
|
1306
1670
|
}
|
|
1307
1671
|
});
|
|
@@ -1312,12 +1676,14 @@ server.addTool({
|
|
|
1312
1676
|
subreddit: z.string().describe("The subreddit name (without r/ prefix)"),
|
|
1313
1677
|
title: z.string().describe("The post title"),
|
|
1314
1678
|
content: z.string().describe("The post content (text for self posts, URL for link posts)"),
|
|
1315
|
-
is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post")
|
|
1679
|
+
is_self: z.boolean().default(true).describe("Whether this is a self post (text) or link post"),
|
|
1680
|
+
flair_id: z.string().optional().describe("Link flair template id (from get_post_flairs); many subreddits require a flair"),
|
|
1681
|
+
flair_text: z.string().optional().describe("Custom flair text, only for flairs whose template is text-editable")
|
|
1316
1682
|
}),
|
|
1317
1683
|
execute: async (args) => {
|
|
1318
1684
|
const client = unwrapClient();
|
|
1319
1685
|
if (process.env.REDDIT_USERNAME === void 0 || process.env.REDDIT_PASSWORD === void 0) throw new Error("User authentication required. Please set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.");
|
|
1320
|
-
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self)).fold((err) => {
|
|
1686
|
+
return (await client.createPost(args.subreddit, args.title, args.content, args.is_self, args.flair_id, args.flair_text)).fold((err) => {
|
|
1321
1687
|
throw new Error(`Failed to create post: ${err.message}`);
|
|
1322
1688
|
}, (post) => {
|
|
1323
1689
|
const formattedPost = formatPostInfo(post);
|
|
@@ -1477,6 +1843,33 @@ ${comment.body}
|
|
|
1477
1843
|
});
|
|
1478
1844
|
}
|
|
1479
1845
|
});
|
|
1846
|
+
server.addTool({
|
|
1847
|
+
name: "get_more_comments",
|
|
1848
|
+
description: "Expand truncated 'load more comments' stubs in a thread. Pass the post's link id and the comment ids from a 'more' node (returned by get_post_comments) to fetch those comments.",
|
|
1849
|
+
parameters: z.object({
|
|
1850
|
+
link_id: z.string().describe("The post (link) id, with or without the t3_ prefix"),
|
|
1851
|
+
comment_ids: z.array(z.string()).min(1).describe("Comment ids to expand (from a 'more' node)")
|
|
1852
|
+
}),
|
|
1853
|
+
execute: async (args) => {
|
|
1854
|
+
return (await unwrapClient().getMoreComments(args.link_id, args.comment_ids)).fold((err) => {
|
|
1855
|
+
throw new Error(`Failed to expand comments: ${err.message}`);
|
|
1856
|
+
}, (comments) => {
|
|
1857
|
+
if (comments.length === 0) return "No additional comments were returned for those ids.";
|
|
1858
|
+
const commentList = comments.map((comment, index) => {
|
|
1859
|
+
const truncated = comment.body.length > 300 ? `${comment.body.substring(0, 300)}...` : comment.body;
|
|
1860
|
+
const flags = [...comment.edited ? ["*(edited)*"] : [], ...comment.isSubmitter ? ["**OP**"] : []];
|
|
1861
|
+
return `### ${index + 1}. u/${comment.author} ${flags.join(" ")}
|
|
1862
|
+
> ${truncated}
|
|
1863
|
+
|
|
1864
|
+
- Score: ${comment.score.toLocaleString()}
|
|
1865
|
+
- Link: https://reddit.com${comment.permalink}`;
|
|
1866
|
+
}).join("\n\n");
|
|
1867
|
+
return `# Expanded Comments (${comments.length})
|
|
1868
|
+
|
|
1869
|
+
${commentList}`;
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
});
|
|
1480
1873
|
async function main() {
|
|
1481
1874
|
await setupRedditClient();
|
|
1482
1875
|
const useHttp = process.env.TRANSPORT_TYPE === "httpStream" || process.env.TRANSPORT_TYPE === "http";
|